Implement better user password validation (#3344)

* Add zxcvbn dependency

* Change password length range requirement from 6-64 to 12-128

* Reimplement register form in LV

* Implement server-side check for password strength

* Add rudimentary strength meter

* Make password input with strength a separate component and improve it

* Fix existing tests to provide strong enough password

* Apply formatting

* Replace existing registration form with new one

* Hide built-in label in `.input` component when none provided

* Crop password to first 32 chars for analysis by zxcvbn

* Add tests for new form components

* Integrate hCaptcha into LV

* Fix existing AuthController tests

* Add tests for Live.RegisterForm

* Hide strength meter when password input is empty

* Randomize client IP in headers during tests to avoid hitting rate limit

* Apply auxilliary formatting fixes to AuthController

* Integrate registration from invitation into LV registration logic

* Fix existing password set and reset forms

* Make `password_length_hint` component more customizable

* Optimize `Auth.User.set_password/2`

* Remove unnecessary attribute from registration form

* Move password set and reset forms to LV

* Add tests for SetPasswordForm LV component

* Add tests for password checks in `Auth.User`

* Document code a bit

* Implement simpler approach to hCaptcha integration

* Update CHANGELOG.md

* Improve consistency of color scheme

* Introduce debounce across all text inputs in registration and password forms

* Fix email input background in register form

* Ensure only single error is rendered for empty password confirmation case

* Remove `/password` form entirely in favor of preferred password reset

* Remove unnecessary `router` option from `live_render` calls

* Make expensive assigns in LV with `assign_new` (h/t @aerosol)

* Accept passwords longer than 32 bytes uniformly as very strong

* Avoid displaying blank error side by side with weak password error

* Make register actions handle errors gracefully

* Render only a single piece of feedback to reduce noise

* Make register and password reset forms pw manager friendly (h/t @cnkk)

* Move registration forms to live routes

* Delete no longer used deadviews

* Adjust registration form in accordance to changes in #3290

* Reintroduce dogfood page path for invitation form from #3290

* Use alternative approach to submitting plausible metrics from LV form

* Rename metrics events and extend tests to account for them
This commit is contained in:
Adrian Gruntkowski 2023-09-25 10:27:29 +02:00 committed by GitHub
parent b1ade191eb
commit 51c1138d0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1537 additions and 584 deletions

View File

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Add a new Properties section in the dashboard to break down by custom properties
- Add `custom_props.csv` to CSV export (almost the same as the old `prop_breakdown.csv`, but has different column headers, and includes props for pageviews too, not only custom events)
- Add `referrers.csv` to CSV export
- Improve password validation in registration and password reset forms
### Removed
- Removed the nested custom event property breakdown UI when filtering by a goal in Goal Conversions

View File

@ -36,25 +36,6 @@ if (triggers.length > 0) {
})
}
const registerForm = document.getElementById('register-via-invitation-form')
if (registerForm) {
registerForm.addEventListener('submit', function(e) {
e.preventDefault();
setTimeout(submitForm, 5000);
var formSubmitted = false;
function submitForm() {
if (!formSubmitted) {
formSubmitted = true;
registerForm.submit();
}
}
/* eslint-disable-next-line no-undef */
plausible('Signup via invitation', {u: '/register/invitation/:invitation_id', callback: submitForm });
})
}
const changelogNotification = document.getElementById('changelog-notification')
if (changelogNotification) {

View File

@ -5,12 +5,25 @@ import { LiveSocket } from "phoenix_live_view"
let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']")
if (csrfToken && websocketUrl) {
let Hooks = {}
Hooks.Metrics = {
mounted() {
this.handleEvent("send-metrics", ({ event_name, params }) => {
const afterMetrics = () => {
this.pushEvent("send-metrics-after", {event_name, params})
}
setTimeout(afterMetrics, 5000)
params.callback = afterMetrics
window.plausible(event_name, params)
})
}
}
let token = csrfToken.getAttribute("content")
let url = websocketUrl.getAttribute("content")
let liveUrl = (url === "") ? "/live" : new URL("/live", url).href;
let liveSocket = new LiveSocket(liveUrl, Socket, {
heartbeatIntervalMs: 10000,
params: { _csrf_token: token }, hooks: {}, dom: {
params: { _csrf_token: token }, hooks: Hooks, dom: {
// for alpinejs integration
onBeforeElUpdated(from, to) {
if (from.__x) {

View File

@ -1,4 +1,5 @@
const colors = require('tailwindcss/colors')
const plugin = require('tailwindcss/plugin')
module.exports = {
content: [
@ -51,5 +52,9 @@ module.exports = {
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/aspect-ratio'),
plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
]
}

View File

@ -16,7 +16,8 @@ defmodule Plausible.Auth.User do
@type t() :: %__MODULE__{}
@required [:email, :name, :password, :password_confirmation]
@required [:email, :name, :password]
schema "users" do
field :email, :string
field :password_hash
@ -43,9 +44,10 @@ defmodule Plausible.Auth.User do
%Plausible.Auth.User{}
|> cast(attrs, @required)
|> validate_required(@required)
|> validate_length(:password, min: 6, message: "has to be at least 6 characters")
|> validate_length(:password, max: 64, message: "cannot be longer than 64 characters")
|> validate_confirmation(:password)
|> validate_length(:password, min: 12, message: "has to be at least 12 characters")
|> validate_length(:password, max: 128, message: "cannot be longer than 128 characters")
|> validate_confirmation(:password, required: true)
|> validate_password_strength()
|> hash_password()
|> start_trial
|> set_email_verified
@ -60,13 +62,13 @@ defmodule Plausible.Auth.User do
end
def set_password(user, password) do
hash = Plausible.Auth.Password.hash(password)
user
|> cast(%{password: password}, [:password])
|> validate_required(:password)
|> validate_length(:password, min: 6, message: "has to be at least 6 characters")
|> cast(%{password_hash: hash}, [:password_hash])
|> validate_required([:password])
|> validate_length(:password, min: 12, message: "has to be at least 12 characters")
|> validate_length(:password, max: 128, message: "cannot be longer than 128 characters")
|> validate_password_strength()
|> hash_password()
end
def hash_password(%{errors: [], changes: changes} = changeset) do
@ -88,6 +90,51 @@ defmodule Plausible.Auth.User do
change(user, trial_expiry_date: Timex.today() |> Timex.shift(days: -1))
end
def password_strength(changeset) do
case get_field(changeset, :password) do
nil ->
%{suggestions: [], warning: "", score: 0}
# Passwords past (approximately) 32 characters are treated
# as strong, despite what they contain, to avoid unnecessarily
# expensive computation.
password when byte_size(password) > 32 ->
%{suggestions: [], warning: "", score: 4}
password ->
existing_phrases =
[]
|> maybe_add_phrase(get_field(changeset, :name))
|> maybe_add_phrase(get_field(changeset, :email))
case ZXCVBN.zxcvbn(password, existing_phrases) do
%{score: score, feedback: feedback} ->
%{suggestions: feedback.suggestions, warning: feedback.warning, score: score}
:error ->
%{suggestions: [], warning: "", score: 3}
end
end
end
defp validate_password_strength(changeset) do
if get_change(changeset, :password) != nil and password_strength(changeset).score <= 2 do
add_error(changeset, :password, "is too weak", validation: :strength)
else
changeset
end
end
defp maybe_add_phrase(phrases, nil), do: phrases
defp maybe_add_phrase(phrases, phrase) do
parts = String.split(phrase)
[phrase, parts]
|> List.flatten(phrases)
|> Enum.uniq()
end
defp trial_expiry() do
if Application.get_env(:plausible, :is_selfhost) do
Timex.today() |> Timex.shift(years: 100)

View File

@ -1,158 +1,64 @@
defmodule PlausibleWeb.AuthController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.{Auth, Release}
alias Plausible.Auth
require Logger
plug PlausibleWeb.RequireLoggedOutPlug
when action in [
:register_form,
:register,
:register_from_invitation_form,
:register_from_invitation,
:login_form,
:login
]
plug(
PlausibleWeb.RequireLoggedOutPlug
when action in [
:register,
:register_from_invitation,
:login_form,
:login
]
)
plug PlausibleWeb.RequireAccountPlug
when action in [
:user_settings,
:save_settings,
:delete_me,
:password_form,
:set_password,
:activate_form
]
plug(
PlausibleWeb.RequireAccountPlug
when action in [
:user_settings,
:save_settings,
:delete_me,
:activate_form
]
)
plug :maybe_disable_registration when action in [:register_form, :register]
plug :assign_is_selfhost
defp maybe_disable_registration(conn, _opts) do
selfhost_config = Application.get_env(:plausible, :selfhost)
disable_registration = Keyword.fetch!(selfhost_config, :disable_registration)
first_launch? = Release.should_be_first_launch?()
cond do
first_launch? ->
conn
disable_registration in [:invite_only, true] ->
conn
|> put_flash(:error, "Registration is disabled on this instance")
|> redirect(to: Routes.auth_path(conn, :login_form))
|> halt()
true ->
conn
end
end
plug(:assign_is_selfhost)
defp assign_is_selfhost(conn, _opts) do
assign(conn, :is_selfhost, Plausible.Release.selfhost?())
end
def register_form(conn, _params) do
changeset = Auth.User.changeset(%Auth.User{})
render(conn, "register_form.html",
changeset: changeset,
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def register(conn, params) do
conn = put_layout(conn, html: {PlausibleWeb.LayoutView, :focus})
user = Plausible.Auth.User.new(params["user"])
def register(conn, %{"user" => %{"email" => email, "password" => password}}) do
with {:ok, user} <- login_user(conn, email, password) do
conn = set_user_session(conn, user)
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
case Repo.insert(user) do
{:ok, user} ->
conn = set_user_session(conn, user)
if user.email_verified do
redirect(conn, to: Routes.site_path(conn, :new))
else
send_email_verification(user)
redirect(conn, to: Routes.auth_path(conn, :activate_form))
end
{:error, changeset} ->
render(conn, "register_form.html", changeset: changeset)
end
else
render(conn, "register_form.html",
changeset: user,
captcha_error: "Please complete the captcha to register"
)
end
end
def register_from_invitation_form(conn, %{"invitation_id" => invitation_id}) do
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) == true do
redirect(conn, to: Routes.auth_path(conn, :login_form))
else
invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
changeset = Plausible.Auth.User.changeset(%Plausible.Auth.User{})
if invitation do
render(conn, "register_from_invitation_form.html",
changeset: changeset,
invitation: invitation,
dogfood_page_path: "/register/invitation/:invitation_id",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
if user.email_verified do
redirect(conn, to: Routes.site_path(conn, :new))
else
render(conn, "invitation_expired.html",
dogfood_page_path: "/register/invitation/:invitation_id",
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
send_email_verification(user)
redirect(conn, to: Routes.auth_path(conn, :activate_form))
end
end
end
def register_from_invitation(conn, %{"invitation_id" => invitation_id} = params) do
if Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) == true do
redirect(conn, to: Routes.auth_path(conn, :login_form))
else
invitation = Repo.get_by(Plausible.Auth.Invitation, invitation_id: invitation_id)
user = Plausible.Auth.User.new(params["user"])
def register_from_invitation(conn, %{"user" => %{"email" => email, "password" => password}}) do
with {:ok, user} <- login_user(conn, email, password) do
conn = set_user_session(conn, user)
user =
case invitation.role do
:owner -> user
_ -> Plausible.Auth.User.remove_trial_expiry(user)
end
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
case Repo.insert(user) do
{:ok, user} ->
conn = set_user_session(conn, user)
case user.email_verified do
false ->
send_email_verification(user)
redirect(conn, to: Routes.auth_path(conn, :activate_form))
true ->
redirect(conn, to: Routes.site_path(conn, :index))
end
{:error, changeset} ->
render(conn, "register_from_invitation_form.html",
invitation: invitation,
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"},
dogfood_page_path: "/register/invitation/:invitation_id"
)
end
if user.email_verified do
redirect(conn, to: Routes.site_path(conn, :index))
else
render(conn, "register_from_invitation_form.html",
invitation: invitation,
changeset: user,
captcha_error: "Please complete the captcha to register",
layout: {PlausibleWeb.LayoutView, "focus.html"},
dogfood_page_path: "/register/invitation/:invitation_id"
)
send_email_verification(user)
redirect(conn, to: Routes.auth_path(conn, :activate_form))
end
end
end
@ -169,28 +75,21 @@ defmodule PlausibleWeb.AuthController do
result
end
defp set_user_session(conn, user) do
conn
|> put_session(:current_user_id, user.id)
|> put_resp_cookie("logged_in", "true",
http_only: false,
max_age: 60 * 60 * 24 * 365 * 5000
)
end
def activate_form(conn, _params) do
user = conn.assigns[:current_user]
has_invitation =
Repo.exists?(
from i in Plausible.Auth.Invitation,
from(i in Plausible.Auth.Invitation,
where: i.email == ^user.email
)
)
has_code =
Repo.exists?(
from c in "email_verification_codes",
from(c in "email_verification_codes",
where: c.user_id == ^user.id
)
)
render(conn, "activate.html",
@ -205,8 +104,9 @@ defmodule PlausibleWeb.AuthController do
has_invitation =
Repo.exists?(
from i in Plausible.Auth.Invitation,
from(i in Plausible.Auth.Invitation,
where: i.email == ^user.email
)
)
{code, ""} = Integer.parse(code)
@ -298,6 +198,7 @@ defmodule PlausibleWeb.AuthController do
case Auth.Token.verify_password_reset(token) do
{:ok, _} ->
render(conn, "password_reset_form.html",
connect_live_socket: true,
token: token,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
@ -318,60 +219,32 @@ defmodule PlausibleWeb.AuthController do
end
end
def password_reset(conn, %{"token" => token, "password" => pw}) do
case Auth.Token.verify_password_reset(token) do
{:ok, %{email: email}} ->
user = Repo.get_by(Auth.User, email: email)
changeset = Auth.User.set_password(user, pw)
case Repo.update(changeset) do
{:ok, _updated} ->
conn
|> put_flash(:login_title, "Password updated successfully")
|> put_flash(:login_instructions, "Please log in with your new credentials")
|> put_session(:current_user_id, nil)
|> delete_resp_cookie("logged_in")
|> redirect(to: Routes.auth_path(conn, :login_form))
{:error, changeset} ->
render(conn, "password_reset_form.html",
changeset: changeset,
token: token,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
{:error, :expired} ->
render_error(
conn,
401,
"Your token has expired. Please request another password reset link."
)
{:error, _} ->
render_error(
conn,
401,
"Your token is invalid. Please request another password reset link."
)
end
def password_reset(conn, _params) do
conn
|> put_flash(:login_title, "Password updated successfully")
|> put_flash(:login_instructions, "Please log in with your new credentials")
|> put_session(:current_user_id, nil)
|> delete_resp_cookie("logged_in")
|> redirect(to: Routes.auth_path(conn, :login_form))
end
def login(conn, %{"email" => email, "password" => password}) do
with {:ok, user} <- login_user(conn, email, password) do
login_dest = get_session(conn, :login_dest) || Routes.site_path(conn, :index)
conn
|> set_user_session(user)
|> put_session(:login_dest, nil)
|> redirect(to: login_dest)
end
end
defp login_user(conn, email, password) do
with :ok <- check_ip_rate_limit(conn),
{:ok, user} <- find_user(email),
:ok <- check_user_rate_limit(user),
:ok <- check_password(user, password) do
login_dest = get_session(conn, :login_dest) || Routes.site_path(conn, :index)
conn
|> put_session(:current_user_id, user.id)
|> put_resp_cookie("logged_in", "true",
http_only: false,
max_age: 60 * 60 * 24 * 365 * 5000
)
|> put_session(:login_dest, nil)
|> redirect(to: login_dest)
{:ok, user}
else
:wrong_password ->
maybe_log_failed_login_attempts("wrong password for #{email}")
@ -401,6 +274,15 @@ defmodule PlausibleWeb.AuthController do
end
end
defp set_user_session(conn, user) do
conn
|> put_session(:current_user_id, user.id)
|> put_resp_cookie("logged_in", "true",
http_only: false,
max_age: 60 * 60 * 24 * 365 * 5000
)
end
defp maybe_log_failed_login_attempts(message) do
if Application.get_env(:plausible, :log_failed_login_attempts) do
Logger.warning("[login] #{message}")
@ -421,8 +303,9 @@ defmodule PlausibleWeb.AuthController do
defp find_user(email) do
user =
Repo.one(
from u in Plausible.Auth.User,
from(u in Plausible.Auth.User,
where: u.email == ^email
)
)
if user, do: {:ok, user}, else: :user_not_found
@ -447,28 +330,6 @@ defmodule PlausibleWeb.AuthController do
render(conn, "login_form.html", layout: {PlausibleWeb.LayoutView, "focus.html"})
end
def password_form(conn, _params) do
render(conn, "password_form.html",
layout: {PlausibleWeb.LayoutView, "focus.html"},
skip_plausible_tracking: true
)
end
def set_password(conn, %{"password" => pw}) do
changeset = Auth.User.set_password(conn.assigns[:current_user], pw)
case Repo.update(changeset) do
{:ok, _user} ->
redirect(conn, to: "/sites/new")
{:error, changeset} ->
render(conn, "password_form.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
end
def user_settings(conn, _params) do
changeset = Auth.User.changeset(conn.assigns[:current_user])
render_settings(conn, changeset)
@ -540,8 +401,9 @@ defmodule PlausibleWeb.AuthController do
def delete_api_key(conn, %{"id" => id}) do
query =
from k in Auth.ApiKey,
from(k in Auth.ApiKey,
where: k.id == ^id and k.user_id == ^conn.assigns[:current_user].id
)
query
|> Repo.one!()

View File

@ -58,7 +58,7 @@ defmodule PlausibleWeb.Live.Components.Form do
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>
<.label :if={@label != nil and @label != ""} for={@id}>
<%= @label %>
</.label>
<input
@ -68,6 +68,7 @@ defmodule PlausibleWeb.Live.Components.Form do
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
{@rest}
/>
<%= render_slot(@inner_block) %>
<.error :for={msg <- @errors}>
<%= msg %>
</.error>
@ -75,6 +76,144 @@ defmodule PlausibleWeb.Live.Components.Form do
"""
end
attr(:id, :any, default: nil)
attr(:label, :string, default: nil)
attr(:field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:password]",
required: true
)
attr(:strength, :any)
attr(:rest, :global,
include: ~w(autocomplete disabled form maxlength minlength readonly required size)
)
def password_input_with_strength(%{field: field} = assigns) do
{too_weak?, errors} =
case pop_strength_errors(field.errors) do
{strength_errors, other_errors} when strength_errors != [] ->
{true, other_errors}
{[], other_errors} ->
{false, other_errors}
end
strength =
if too_weak? and assigns.strength.score >= 3 do
%{assigns.strength | score: 2}
else
assigns.strength
end
assigns =
assigns
|> assign(:too_weak?, too_weak?)
|> assign(:field, %{field | errors: errors})
|> assign(:strength, strength)
~H"""
<.input field={@field} type="password" autocomplete="new-password" label={@label} id={@id} {@rest}>
<.strength_meter :if={@too_weak? or @strength.score > 0} {@strength} />
</.input>
"""
end
attr(:minimum, :integer, required: true)
attr(:class, :any)
attr(:ok_class, :any)
attr(:error_class, :any)
attr(:field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:password]",
required: true
)
def password_length_hint(%{field: field} = assigns) do
{strength_errors, _} = pop_strength_errors(field.errors)
ok_class = assigns[:ok_class] || "text-gray-500"
error_class = assigns[:error_class] || "text-red-500"
class = assigns[:class] || ["text-xs", "mt-1"]
color =
if :length in strength_errors do
error_class
else
ok_class
end
final_class = [color | class]
assigns = assign(assigns, :class, final_class)
~H"""
<p class={@class}>Min <%= @minimum %> characters</p>
"""
end
defp pop_strength_errors(errors) do
Enum.reduce(errors, {[], []}, fn {_, meta} = error, {detected, other_errors} ->
cond do
meta[:validation] == :required ->
{[:required | detected], other_errors}
meta[:validation] == :length and meta[:kind] == :min ->
{[:length | detected], other_errors}
meta[:validation] == :strength ->
{[:strength | detected], other_errors}
true ->
{detected, [error | other_errors]}
end
end)
end
attr(:score, :integer, default: 0)
attr(:warning, :string, default: "")
attr(:suggestions, :list, default: [])
def strength_meter(assigns) do
color =
cond do
assigns.score <= 1 -> ["bg-red-500", "dark:bg-red-500"]
assigns.score == 2 -> ["bg-red-300", "dark:bg-red-300"]
assigns.score == 3 -> ["bg-indigo-300", "dark:bg-indigo-300"]
assigns.score >= 4 -> ["bg-indigo-600", "dark:bg-indigo-500"]
end
feedback =
cond do
assigns.warning != "" -> assigns.warning <> "."
assigns.suggestions != [] -> List.first(assigns.suggestions)
true -> nil
end
assigns =
assigns
|> assign(:color, color)
|> assign(:feedback, feedback)
~H"""
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-2 mt-2 dark:bg-gray-700 mt-1">
<div
class={["h-1.5", "rounded-full"] ++ @color}
style={["width: " <> to_string(@score * 25) <> "%"]}
>
</div>
</div>
<p :if={@score <= 2} class="text-sm text-red-500 phx-no-feedback:hidden">
Password is too weak
</p>
<p :if={@feedback} class="text-xs text-gray-500">
<%= @feedback %>
</p>
"""
end
@doc """
Renders a label.
"""

View File

@ -0,0 +1,328 @@
defmodule PlausibleWeb.Live.RegisterForm do
@moduledoc """
LiveView for registration form.
"""
use Phoenix.LiveView
use Phoenix.HTML
import PlausibleWeb.Live.Components.Form
alias Plausible.Auth
alias Plausible.Repo
alias PlausibleWeb.Router.Helpers, as: Routes
def mount(params, _session, socket) do
socket =
assign_new(socket, :invitation, fn ->
if invitation_id = params["invitation_id"] do
Repo.get_by(Auth.Invitation, invitation_id: invitation_id)
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.changeset(%Auth.User{email: invitation.email})
else
Auth.User.changeset(%Auth.User{})
end
{:ok,
assign(socket,
form: to_form(changeset),
captcha_error: nil,
password_strength: Auth.User.password_strength(changeset),
is_selfhost: Plausible.Release.selfhost?(),
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 Analytics</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 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 text-sm">
Your invitation has expired or been revoked. Please request fresh one or you can <%= link(
"sign up",
class: "text-indigo-600 hover:text-indigo-900",
to: Routes.auth_path(@socket, :register)
) %> for a 30-day unlimited free trial without an invitation.
</p>
</div>
"""
end
def render(assigns) do
~H"""
<div class="mx-auto mt-6 text-center dark:text-gray-300">
<h1 class="text-3xl font-black">
<%= if @is_selfhost or @live_action == :register_from_invitation_form do %>
Register your Plausible Analytics account
<% else %>
Register your 30-day unlimited-use free trial
<% end %>
</h1>
<div class="text-xl font-medium">Set up privacy-friendly analytics with just a few clicks</div>
</div>
<div class="w-full max-w-3xl mt-4 mx-auto flex flex-shrink-0">
<.form
:let={f}
for={@form}
id="register-form"
phx-hook="Metrics"
phx-change="validate"
phx-submit="register"
phx-trigger-action={@trigger_submit}
class="w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8"
>
<input name="_csrf_token" type="hidden" value={Plug.CSRFProtection.get_csrf_token()} />
<h2 class="text-xl font-black dark:text-gray-100">Enter your details</h2>
<%= 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 text-sm 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-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm 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 text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password confirmation
</label>
<div class="mt-1">
<.input
type="password"
autocomplete="new-password"
field={f[:password_confirmation]}
phx-debounce={200}
class="dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm 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"><%= @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 @is_selfhost or @invitation do
"Create my account →"
else
"Start my free trial →"
end %>
<button id="register" type="submit" class="button mt-4 w-full">
<%= submit_text %>
</button>
<p class="text-center text-gray-600 dark:text-gray-500 text-xs mt-4">
Already have an account? <%= link("Log in",
to: "/login",
class: "underline text-gray-800 dark:text-gray-50"
) %> instead.
</p>
</.form>
<div :if={@live_action == :register_form} class="pt-12 pl-8 hidden md:block">
<%= PlausibleWeb.AuthView.render("_onboarding_steps.html", current_step: 0) %>
</div>
</div>
"""
end
defp name_input(assigns) do
~H"""
<div class="my-4">
<label for={@field.name} class="block text-sm 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-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm 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
sm:text-sm
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 text-sm 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)}
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()
user =
case invitation.role do
:owner -> user
_ -> Plausible.Auth.User.remove_trial_expiry(user)
end
add_user(socket, user)
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) do
case Repo.insert(user) do
{:ok, _user} ->
metrics_params =
if socket.assigns.invitation do
%{
event_name: "Signup via invitation",
params: %{u: "/register/invitation/:invitation_id"}
}
else
%{event_name: "Signup", params: %{}}
end
{:noreply, push_event(socket, "send-metrics", metrics_params)}
{:error, changeset} ->
{:noreply,
assign(socket,
form: to_form(Map.put(changeset, :action, :validate))
)}
end
end
end

View File

@ -0,0 +1,102 @@
defmodule PlausibleWeb.Live.ResetPasswordForm do
@moduledoc """
LiveView for password reset form.
"""
use Phoenix.LiveView
use Phoenix.HTML
import PlausibleWeb.Live.Components.Form
alias Plausible.Auth
alias Plausible.Repo
def mount(_params, %{"reset_token" => reset_token}, socket) do
socket =
assign_new(socket, :user, fn ->
# by that point token should be already verified
{:ok, %{email: email}} = Auth.Token.verify_password_reset(reset_token)
Repo.get_by!(Auth.User, email: email)
end)
changeset = Auth.User.changeset(socket.assigns.user)
{:ok,
assign(socket,
form: to_form(changeset),
reset_token: reset_token,
password_strength: Auth.User.password_strength(changeset),
trigger_submit: false
)}
end
def render(assigns) do
~H"""
<.form
:let={f}
for={@form}
method="post"
phx-change="validate"
phx-submit="set"
phx-trigger-action={@trigger_submit}
class="bg-white dark:bg-gray-800 max-w-md w-full mx-auto shadow-md rounded px-8 py-6 mt-8"
>
<input name="_csrf_token" type="hidden" value={Plug.CSRFProtection.get_csrf_token()} />
<h2 class="text-xl font-black dark:text-gray-100">
Reset your password
</h2>
<div class="my-4">
<.password_length_hint
minimum={12}
field={f[:password]}
class={["text-sm", "mt-1", "mb-2"]}
ok_class="text-gray-600 dark:text-gray-600"
error_class="text-red-600 dark:text-red-500"
/>
<.password_input_with_strength
field={f[:password]}
strength={@password_strength}
phx-debounce={200}
class="transition bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500"
/>
</div>
<input name="token" type="hidden" value={@reset_token} />
<button id="set" type="submit" class="button mt-4 w-full">
Set password
</button>
<p class="text-center text-gray-500 text-xs mt-4">
Don't have an account? <%= link("Register",
to: "/register",
class: "underline text-gray-800 dark:text-gray-200"
) %> instead.
</p>
</.form>
"""
end
def handle_event("validate", %{"user" => %{"password" => password}}, socket) do
changeset =
socket.assigns.user
|> Auth.User.set_password(password)
|> Map.put(:action, :validate)
password_strength = Auth.User.password_strength(changeset)
{:noreply, assign(socket, form: to_form(changeset), password_strength: password_strength)}
end
def handle_event("set", %{"user" => %{"password" => password}}, socket) do
user = Auth.User.set_password(socket.assigns.user, password)
case Repo.update(user) do
{:ok, _user} ->
{:noreply, assign(socket, trigger_submit: true)}
{:error, changeset} ->
{:noreply,
assign(socket,
form: to_form(Map.put(changeset, :action, :validate))
)}
end
end
end

View File

@ -0,0 +1,37 @@
defmodule PlausibleWeb.Plugs.MaybeDisableRegistration do
@moduledoc """
Plug toggling registration according to selfhosted state.
"""
import Phoenix.Controller
import Plug.Conn
alias Plausible.Release
alias PlausibleWeb.Router.Helpers, as: Routes
def init(opts) do
opts
end
def call(conn, _opts) do
disabled_for = List.wrap(conn.assigns.disable_registration_for)
selfhost_config = Application.get_env(:plausible, :selfhost)
disable_registration = Keyword.fetch!(selfhost_config, :disable_registration)
first_launch? = Release.should_be_first_launch?()
cond do
first_launch? ->
conn
disable_registration in disabled_for ->
conn
|> put_flash(:error, "Registration is disabled on this instance")
|> redirect(to: Routes.auth_path(conn, :login_form))
|> halt()
true ->
conn
end
end
end

View File

@ -25,6 +25,10 @@ defmodule PlausibleWeb.Router do
plug :protect_from_forgery
end
pipeline :focus_layout do
plug :put_root_layout, html: {PlausibleWeb.LayoutView, :focus}
end
pipeline :api do
plug :accepts, ["json"]
plug :fetch_session
@ -132,9 +136,27 @@ defmodule PlausibleWeb.Router do
scope "/", PlausibleWeb do
pipe_through [:browser, :csrf]
get "/register", AuthController, :register_form
scope alias: Live, assigns: %{connect_live_socket: true} do
pipe_through [PlausibleWeb.RequireLoggedOutPlug, :focus_layout]
scope assigns: %{disable_registration_for: [:invite_only, true]} do
pipe_through PlausibleWeb.Plugs.MaybeDisableRegistration
live "/register", RegisterForm, :register_form, as: :auth
end
scope assigns: %{
disable_registration_for: true,
dogfood_page_path: "/register/invitation/:invitation_id"
} do
pipe_through PlausibleWeb.Plugs.MaybeDisableRegistration
live "/register/invitation/:invitation_id", RegisterForm, :register_from_invitation_form,
as: :auth
end
end
post "/register", AuthController, :register
get "/register/invitation/:invitation_id", AuthController, :register_from_invitation_form
post "/register/invitation/:invitation_id", AuthController, :register_from_invitation
get "/activate", AuthController, :activate_form
post "/activate/request-code", AuthController, :request_activation_code
@ -158,8 +180,6 @@ defmodule PlausibleWeb.Router do
scope "/", PlausibleWeb do
pipe_through [:browser, :csrf]
get "/password", AuthController, :password_form
post "/password", AuthController, :set_password
get "/logout", AuthController, :logout
get "/settings", AuthController, :user_settings
put "/settings", AuthController, :save_settings

View File

@ -6,11 +6,22 @@
<!-- Complete Step -->
<li class="flex items-start">
<span class="flex-shrink-0 relative h-5 w-5 flex items-center justify-center">
<svg class="h-full w-full text-indigo-600 dark:text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
<svg
class="h-full w-full text-indigo-600 dark:text-indigo-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</span>
<span class="ml-3 text-sm font-medium text-gray-500 dark:text-gray-300"><%= step %></span>
<span class="ml-3 text-sm font-medium text-gray-500 dark:text-gray-300">
<%= step %>
</span>
</li>
<% end %>
<%= if index == @current_step do %>
@ -18,9 +29,12 @@
<li class="flex items-start">
<span class="flex-shrink-0 h-5 w-5 relative flex items-center justify-center">
<span class="absolute h-4 w-4 rounded-full bg-indigo-200 dark:bg-indigo-100"></span>
<span class="relative block w-2 h-2 bg-indigo-600 dark:bg-indigo-500 rounded-full"></span>
<span class="relative block w-2 h-2 bg-indigo-600 dark:bg-indigo-500 rounded-full">
</span>
</span>
<span class="ml-3 text-sm font-medium text-indigo-600 dark:text-indigo-500">
<%= step %>
</span>
<span class="ml-3 text-sm font-medium text-indigo-600 dark:text-indigo-500"><%= step %></span>
</li>
<% end %>
<%= if index > @current_step do %>
@ -33,7 +47,6 @@
</li>
<% end %>
<% end %>
</ol>
</nav>
</div>

View File

@ -1,12 +0,0 @@
<div class="mx-auto mt-6 text-center dark:text-gray-300">
<h1 class="text-3xl font-black">Plausible Analytics</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 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 text-sm">
Your invitation has expired or been revoked. Please request fresh one or you can <%= link("sign up", class: "text-indigo-600 hover:text-indigo-900", to: Routes.auth_path(@conn, :register)) %> for a 30-day unlimited free trial without an invitation.
</p>
</div>

View File

@ -8,11 +8,11 @@
<% end %>
<div class="my-4 mt-8">
<%= label f, :email, class: "block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2" %>
<%= email_input f, :email, class: "bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500", placeholder: "user@example.com" %>
<%= email_input f, :email, autocomplete: "username", class: "bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500", placeholder: "user@example.com" %>
</div>
<div class="my-4">
<%= label f, :password, class: "block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2" %>
<%= password_input f, :password, class: "transition bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500" %>
<%= password_input f, :password, id: "current-password", autocomplete: "current-password", class: "transition bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500" %>
<p class="text-gray-500 text-xs my-2">Forgot password? <a href="/password/request-reset" class="underline text-gray-800 dark:text-gray-50">Click here</a> to reset it.</p>
</div>
<%= submit "Login →", class: "button mt-4 w-full" %>

View File

@ -1,14 +0,0 @@
<%= form_for @conn, "/password", [class: "bg-white dark:bg-gray-800 max-w-md w-full mx-auto shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Set your password</h2>
<div class="my-4">
<p class="text-gray-600 dark:text-gray-400 text-sm mt-1 mb-2">Min 6 characters</p>
<%= password_input f, :password, class: "transition bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500" %>
<%= if @conn.assigns[:changeset] do %>
<%= error_tag @changeset, :password %>
<% end %>
</div>
<%= submit "Set password →", class: "button mt-4 w-full" %>
<p class="text-center text-gray-500 text-xs mt-4">
Don't have an account? <%= link("Register", to: "/register", class: "underline text-gray-800 dark:text-gray-200") %> instead.
</p>
<% end %>

View File

@ -1,15 +1,4 @@
<%= form_for @conn, "/password/reset", [class: "bg-white dark:bg-gray-800 max-w-md w-full mx-auto shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Reset your password</h2>
<div class="my-4">
<p class="text-gray-600 dark:text-gray-400 text-sm mt-1 mb-2">Min 6 characters</p>
<%= password_input f, :password, class: "transition bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal appearance-none focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500" %>
<%= if @conn.assigns[:changeset] do %>
<%= error_tag @changeset, :password %>
<% end %>
</div>
<%= hidden_input f, :token, value: @token %>
<%= submit "Set password →", class: "button mt-4 w-full" %>
<p class="text-center text-gray-500 text-xs mt-4">
Don't have an account? <%= link("Register", to: "/register", class: "underline text-gray-800 dark:text-gray-200") %> instead.
</p>
<% end %>
<%= live_render(@conn, PlausibleWeb.Live.ResetPasswordForm,
container: {:div, class: "contents"},
session: %{"reset_token" => @token}
) %>

View File

@ -1,79 +0,0 @@
<div class="mx-auto mt-6 text-center dark:text-gray-300">
<h1 class="text-3xl font-black">
<%= if @is_selfhost do %>
Register your Plausible Analytics account
<% else %>
Register your 30-day unlimited-use free trial
<% end %>
</h1>
<div class="text-xl font-medium">Set up privacy-friendly analytics with just a few clicks</div>
</div>
<div class="w-full max-w-3xl mt-4 mx-auto flex flex-shrink-0">
<%= form_for @changeset, "/register", [class: "w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8 plausible-event-name=Signup"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Enter your details</h2>
<div class="my-4">
<%= label f, :name, "Full name", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= text_input f, :name, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300", placeholder: "Jane Doe" %>
</div>
<%= error_tag f, :name %>
</div>
<div class="my-4">
<div class="flex justify-between">
<%= label f, :email, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<p class="text-xs text-gray-500 mt-1">No spam, guaranteed.</p>
</div>
<div class="mt-1">
<%= email_input f, :email, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300", placeholder: "example@email.com" %>
</div>
<%= error_tag f, :email %>
</div>
<div class="my-4">
<div class="flex justify-between">
<%= label f, :password, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<p class="text-xs text-gray-500 mt-1">Min 6 characters</p>
</div>
<div class="mt-1">
<%= password_input f, :password, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" %>
</div>
<%= error_tag f, :password %>
</div>
<div class="my-4">
<%= label f, :password_confirmation, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= password_input f, :password_confirmation, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" %>
</div>
<%= error_tag f, :password_confirmation %>
</div>
<%= if PlausibleWeb.Captcha.enabled?() do %>
<div class="mt-4">
<div class="h-captcha" data-sitekey="<%= PlausibleWeb.Captcha.sitekey() %>"></div>
<%= if assigns[:captcha_error] do %>
<div class="text-red-500 text-xs italic mt-3"><%= @captcha_error %></div>
<% end %>
<script src="https://hcaptcha.com/1/api.js" async defer></script>
</div>
<% end %>
<%
submit_text =
if @is_selfhost do
"Create my account →"
else
"Start my free trial →"
end
%>
<%= submit submit_text, class: "button mt-4 w-full" %>
<p class="text-center text-gray-600 dark:text-gray-500 text-xs mt-4">
Already have an account? <%= link("Log in", to: "/login", class: "underline text-gray-800 dark:text-gray-50") %> instead.
</p>
<% end %>
<div class="pt-12 pl-8 hidden md:block">
<%= render(PlausibleWeb.AuthView, "_onboarding_steps.html", current_step: 0) %>
</div>
</div>

View File

@ -1,61 +0,0 @@
<div class="mx-auto mt-6 text-center dark:text-gray-300">
<h1 class="text-3xl font-black">Register your Plausible Analytics account</h1>
<div class="text-xl font-medium">Set up privacy-friendly analytics with just a few clicks</div>
</div>
<%= form_for @changeset, Routes.auth_path(@conn, :register_from_invitation_form, @invitation.invitation_id), [class: "w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mb-4 mt-8", id: "register-via-invitation-form"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Enter your details</h2>
<div class="my-4">
<div class="flex justify-between">
<%= label f, :email, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<p class="text-xs text-gray-500 mt-1">No spam, guaranteed.</p>
</div>
<div class="mt-1">
<%= email_input f, :email, class: "bg-gray-100 dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300", placeholder: "example@email.com", value: @invitation.email, readonly: "readonly" %>
</div>
<%= error_tag f, :email %>
</div>
<div class="my-4">
<%= label f, :name, "Full name", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= text_input f, :name, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300", placeholder: "Jane Doe" %>
</div>
<%= error_tag f, :name %>
</div>
<div class="my-4">
<div class="flex justify-between">
<%= label f, :password, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<p class="text-xs text-gray-500 mt-1">Min 6 characters</p>
</div>
<div class="mt-1">
<%= password_input f, :password, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" %>
</div>
<%= error_tag f, :password %>
</div>
<div class="my-4">
<%= label f, :password_confirmation, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= password_input f, :password_confirmation, class: "dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" %>
</div>
<%= error_tag f, :password_confirmation %>
</div>
<%= if PlausibleWeb.Captcha.enabled?() do %>
<div class="mt-4">
<div class="h-captcha" data-sitekey="<%= PlausibleWeb.Captcha.sitekey() %>"></div>
<%= if assigns[:captcha_error] do %>
<div class="text-red-500 text-xs italic mt-3"><%= @captcha_error %></div>
<% end %>
<script src="https://hcaptcha.com/1/api.js" async defer></script>
</div>
<% end %>
<%= submit "Create my account →", class: "button mt-4 w-full" %>
<p class="text-center text-gray-600 dark:text-gray-500 text-xs mt-4">
Already have an account? <%= link("Log in", to: "/login", class: "underline text-gray-800 dark:text-gray-50") %> instead.
</p>
<% end %>

View File

@ -5,6 +5,10 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content="A lightweight, non-intrusive alternative to Google Analytics."/>
<%= if @conn.assigns[:connect_live_socket] do %>
<meta name="csrf-token" content="<%= Plug.CSRFProtection.get_csrf_token() %>" />
<meta name="websocket-url" content="<%= websocket_url() %>" />
<% end %>
<meta name="robots" content="<%= @conn.private.robots %>" />
<link rel="icon" type="image/png" sizes="32x32" href="<%= PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_favicon.png") %>">
<title><%= assigns[:title] || "Plausible · Web analytics" %></title>

View File

@ -41,8 +41,7 @@
conn={@conn}
>
<%= live_render(@conn, PlausibleWeb.Live.FunnelSettings,
session: %{"site_id" => @site.id, "domain" => @site.domain},
router: PlausibleWeb.Router
session: %{"site_id" => @site.id, "domain" => @site.domain}
) %>
</PlausibleWeb.Components.Site.Feature.toggle>
</div>

View File

@ -1,8 +1,8 @@
defmodule PlausibleWeb.ErrorHelpers do
use Phoenix.HTML
def error_tag(%Phoenix.HTML.Form{} = form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error ->
def error_tag(%{errors: errors}, field) do
Enum.map(Keyword.get_values(errors, field), fn error ->
content_tag(:div, translate_error(error), class: "mt-2 text-sm text-red-600")
end)
end

View File

@ -121,7 +121,8 @@ defmodule Plausible.MixProject do
{:ex_money, "~> 5.12"},
{:mjml_eex, "~> 0.9.0"},
{:mjml, "~> 1.5.0"},
{:heroicons, "~> 0.5.0"}
{:heroicons, "~> 0.5.0"},
{:zxcvbn, git: "https://github.com/techgaun/zxcvbn-elixir.git"}
]
end

View File

@ -142,4 +142,5 @@
"websock": {:hex, :websock, "0.5.2", "b3c08511d8d79ed2c2f589ff430bd1fe799bb389686dafce86d28801783d8351", [:mix], [], "hexpm", "925f5de22fca6813dfa980fb62fd542ec43a2d1a1f83d2caec907483fe66ff05"},
"websock_adapter": {:hex, :websock_adapter, "0.5.3", "4908718e42e4a548fc20e00e70848620a92f11f7a6add8cf0886c4232267498d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "cbe5b814c1f86b6ea002b52dd99f345aeecf1a1a6964e209d208fb404d930d3d"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"zxcvbn": {:git, "https://github.com/techgaun/zxcvbn-elixir.git", "aede1d49d39e89d7b3d1c381de5f04c9907d8171", []},
}

View File

@ -0,0 +1,86 @@
defmodule Plausible.Auth.UserTest do
use Plausible.DataCase, async: true
alias Plausible.Auth.User
describe "password_strength/1" do
test "scores password with all arguments in changes" do
assert %{score: score, warning: warning, suggestions: suggestions} =
%User{}
|> change(
name: "Jane Doe",
email: "user@example.com",
password: "asd"
)
|> User.password_strength()
assert score < 3
assert warning != ""
assert length(suggestions) > 0
end
test "checks for existing phrases using name and email from changes" do
strength =
%User{}
|> change(
name: "Clayman Sillywaggle",
email: "clay@example.com",
password: "claymansillywaggle"
)
|> User.password_strength()
assert strength.score == 1
end
test "checks for existing phrases using name and email from source" do
strength =
%User{
name: "Clayman Sillywaggle",
email: "clay@example.com"
}
|> change(password: "claymansillywaggle")
|> User.password_strength()
assert strength.score == 1
end
test "treats passwords past 32 bytes as very strong" do
strength =
%User{
name: "Clayman Sillywaggle",
email: "clay@example.com"
}
|> change(password: String.duplicate("a", 33))
|> User.password_strength()
assert strength.score == 4
end
end
describe "password strength validation" do
test "succeeds for complex enough password" do
changeset =
User.new(%{
name: "Jane Doe",
email: "jane@example.com",
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
})
assert changeset.valid?
end
test "fails for password not complex enough" do
changeset =
User.new(%{
name: "Jane Doe",
email: "jane@example.com",
password: "asdasdasdasd",
password_confirmation: "asdasdasdasd"
})
refute changeset.valid?
assert {"is too weak", [validation: :strength]} = changeset.errors[:password]
end
end
end

View File

@ -936,7 +936,6 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
end
test "Uses the Forwarded header when cf-connecting-ip and x-forwarded-for are missing", %{
conn: conn,
site: site
} do
params = %{
@ -945,7 +944,7 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
url: "http://gigride.live/"
}
conn
build_conn()
|> put_req_header("forwarded", "by=0.0.0.0;for=216.160.83.56;host=somehost.com;proto=https")
|> post("/api/event", params)
@ -954,14 +953,14 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert pageview.country_code == "US"
end
test "Forwarded header can parse ipv6", %{conn: conn, site: site} do
test "Forwarded header can parse ipv6", %{site: site} do
params = %{
name: "pageview",
domain: site.domain,
url: "http://gigride.live/"
}
conn
build_conn()
|> put_req_header(
"forwarded",
"by=0.0.0.0;for=\"[2001:218:1:1:1:1:1:1]\",for=0.0.0.0;host=somehost.com;proto=https"

View File

@ -4,6 +4,9 @@ defmodule PlausibleWeb.AuthControllerTest do
use Plausible.Repo
import Mox
alias Plausible.Auth.User
setup :verify_on_exit!
describe "GET /register" do
@ -15,18 +18,20 @@ defmodule PlausibleWeb.AuthControllerTest do
end
describe "POST /register" do
setup do
mock_captcha_success()
:ok
end
test "registering sends an activation link", %{conn: conn} do
post(conn, "/register",
user: %{
Repo.insert!(
User.new(%{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
})
)
post(conn, "/register",
user: %{
email: "user@example.com",
password: "very-secret-and-very-long-123"
}
)
@ -36,60 +41,46 @@ defmodule PlausibleWeb.AuthControllerTest do
end
test "user is redirected to activate page after registration", %{conn: conn} do
Repo.insert!(
User.new(%{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
})
)
conn =
post(conn, "/register",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
password: "very-secret-and-very-long-123"
}
)
assert redirected_to(conn, 302) == "/activate"
end
test "creates user record", %{conn: conn} do
post(conn, "/register",
user: %{
test "logs the user in", %{conn: conn} do
Repo.insert!(
User.new(%{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
}
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
})
)
user = Repo.one(Plausible.Auth.User)
assert user.name == "Jane Doe"
end
test "logs the user in", %{conn: conn} do
conn =
post(conn, "/register",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
password: "very-secret-and-very-long-123"
}
)
assert get_session(conn, :current_user_id)
end
test "user is redirected to activation after registration", %{conn: conn} do
conn =
post(conn, "/register",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
}
)
assert redirected_to(conn) == "/activate"
end
end
describe "GET /register/invitations/:invitation_id" do
@ -113,7 +104,6 @@ defmodule PlausibleWeb.AuthControllerTest do
describe "POST /register/invitation/:invitation_id" do
setup do
mock_captcha_success()
inviter = insert(:user)
site = insert(:site, members: [inviter])
@ -125,6 +115,15 @@ defmodule PlausibleWeb.AuthControllerTest do
role: :admin
)
Repo.insert!(
User.new(%{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
})
)
{:ok, %{site: site, invitation: invitation}}
end
@ -133,8 +132,8 @@ defmodule PlausibleWeb.AuthControllerTest do
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
}
)
@ -152,107 +151,27 @@ defmodule PlausibleWeb.AuthControllerTest do
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
}
)
assert redirected_to(conn, 302) == "/activate"
end
test "creates user record", %{conn: conn, invitation: invitation} do
post(conn, "/register/invitation/#{invitation.invitation_id}",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
}
)
user = Repo.get_by(Plausible.Auth.User, email: "user@example.com")
assert user.name == "Jane Doe"
end
test "leaves trial_expiry_date null when invitation role is not :owner", %{
conn: conn,
invitation: invitation
} do
post(conn, "/register/invitation/#{invitation.invitation_id}",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
}
)
user = Repo.get_by(Plausible.Auth.User, email: "user@example.com")
assert is_nil(user.trial_expiry_date)
end
test "logs the user in", %{conn: conn, invitation: invitation} do
conn =
post(conn, "/register/invitation/#{invitation.invitation_id}",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
password: "very-secret-and-very-long-123",
password_confirmation: "very-secret-and-very-long-123"
}
)
assert get_session(conn, :current_user_id)
end
test "user is redirected to activation after registration", %{conn: conn} do
conn =
post(conn, "/register",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
}
)
assert redirected_to(conn) == "/activate"
end
end
describe "captcha failure" do
setup do
mock_captcha_failure()
inviter = insert(:user)
site = insert(:site, members: [inviter])
invitation =
insert(:invitation,
site_id: site.id,
inviter: inviter,
email: "user@email.co",
role: :admin
)
{:ok, %{site: site, invitation: invitation}}
end
test "renders captcha errors in case of captcha input verification failure", %{
conn: conn,
invitation: invitation
} do
conn =
post(conn, "/register/invitation/#{invitation.invitation_id}",
user: %{
name: "Jane Doe",
email: "user@example.com",
password: "very-secret",
password_confirmation: "very-secret"
}
)
assert html_response(conn, 200) =~ "Please complete the captcha"
end
end
describe "GET /activate" do
@ -478,7 +397,8 @@ defmodule PlausibleWeb.AuthControllerTest do
describe "GET /password/reset" do
test "with valid token - shows form", %{conn: conn} do
token = Plausible.Auth.Token.sign_password_reset("email@example.com")
user = insert(:user)
token = Plausible.Auth.Token.sign_password_reset(user.email)
conn = get(conn, "/password/reset", %{token: token})
assert html_response(conn, 200) =~ "Reset your password"
@ -492,21 +412,13 @@ defmodule PlausibleWeb.AuthControllerTest do
end
describe "POST /password/reset" do
alias Plausible.Auth.{User, Token, Password}
alias Plausible.Auth.Token
test "with valid token - resets the password", %{conn: conn} do
test "redirects the user to login and shows success message", %{conn: conn} do
user = insert(:user)
token = Token.sign_password_reset(user.email)
post(conn, "/password/reset", %{token: token, password: "new-password"})
user = Plausible.Repo.get(User, user.id)
assert Password.match?("new-password", user.password_hash)
end
test "with valid token - redirects the user to login and shows success message", %{conn: conn} do
user = insert(:user)
token = Token.sign_password_reset(user.email)
conn = post(conn, "/password/reset", %{token: token, password: "new-password"})
conn = post(conn, "/password/reset", %{token: token})
assert location = "/login" = redirected_to(conn, 302)

View File

@ -0,0 +1,198 @@
defmodule PlausibleWeb.Live.Components.FormTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
alias Plausible.Auth.User
alias PlausibleWeb.Live.Components.Form
describe "password_input_with_strength/1" do
test "renders for correct, strong password" do
doc = render_password_input_with_strength("very-secret-and-very-long-123")
assert element_exists?(doc, ~s/input#user_password[type="password"][name="user[password]"]/)
assert element_exists?(doc, ~s/div.rounded-full.bg-indigo-600/)
refute element_exists?(doc, "label")
refute element_exists?(doc, "p")
end
test "renders with label when passed" do
doc =
render_password_input_with_strength(
"very-secret-and-very-long-123",
label: "New password"
)
assert element_exists?(doc, ~s/input#user_password[type="password"][name="user[password]"]/)
assert element_exists?(doc, ~s/label[for="user_password"]/)
assert text_of_element(doc, ~s/label[for="user_password"]/) == "New password"
end
test "renders weak password warning and hints when password too short" do
doc = render_password_input_with_strength("too-short")
assert [warning_p, hint_p] = find(doc, "p")
assert text(warning_p) == "Password is too weak"
assert text(hint_p) != ""
end
test "does not render hints and suggestions paragraph when there's none" do
doc =
render_password_input_with_strength("very-secret-but-too-short",
strength: %{
score: 2,
warning: "",
suggestions: []
}
)
assert [warning_p] = find(doc, "p")
assert text(warning_p) == "Password is too weak"
end
test "renders too long password case gracefully" do
too_long_password = String.duplicate("very-long-very-secret-1234567890", 10)
doc = render_password_input_with_strength(too_long_password)
assert [error_p] = find(doc, "p")
assert text(error_p) =~ "cannot be longer than"
end
end
describe "password_length_hint/1" do
test "renders for long enough password" do
doc = render_password_length_hint("very-secret-and-very-long-123", 12)
assert [p_hint] = find(doc, "p")
assert text_of_attr(p_hint, "class") =~ "text-gray-500"
assert text(p_hint) == "Min 12 characters"
end
test "renders for too short password" do
doc = render_password_length_hint("too-short", 12)
assert [p_hint] = find(doc, "p")
assert text_of_attr(p_hint, "class") =~ "text-red-500"
assert text(p_hint) == "Min 12 characters"
end
test "renders gracefully for too long password" do
too_long_password = String.duplicate("very-long-very-secret-1234567890", 10)
doc = render_password_length_hint(too_long_password, 12)
assert [p_hint] = find(doc, "p")
assert text_of_attr(p_hint, "class") =~ "text-gray-500"
assert text(p_hint) == "Min 12 characters"
end
end
describe "strength_meter/1" do
test "renders too weak level" do
doc = render_component(&Form.strength_meter/1, score: 0, warning: "", suggestions: [])
meter = find(doc, "div.rounded-full")
assert text_of_attr(meter, "style") == "width: 0%"
assert [p_warning] = find(doc, "p")
assert text(p_warning) == "Password is too weak"
end
test "renders very weak level" do
doc = render_component(&Form.strength_meter/1, score: 1, warning: "", suggestions: [])
meter = find(doc, "div.rounded-full")
assert text_of_attr(meter, "style") == "width: 25%"
assert [p_warning] = find(doc, "p")
assert text(p_warning) == "Password is too weak"
end
test "renders somewhat weak level" do
doc = render_component(&Form.strength_meter/1, score: 2, warning: "", suggestions: [])
meter = find(doc, "div.rounded-full")
assert text_of_attr(meter, "style") == "width: 50%"
assert [p_warning] = find(doc, "p")
assert text(p_warning) == "Password is too weak"
end
test "renders strong level" do
doc = render_component(&Form.strength_meter/1, score: 3, warning: "", suggestions: [])
meter = find(doc, "div.rounded-full")
assert text_of_attr(meter, "style") == "width: 75%"
assert find(doc, "p") == []
end
test "renders very strong level" do
doc = render_component(&Form.strength_meter/1, score: 4, warning: "", suggestions: [])
meter = find(doc, "div.rounded-full")
assert text_of_attr(meter, "style") == "width: 100%"
assert find(doc, "p") == []
end
test "renders hints paragraph when warning hint is present" do
doc =
render_component(&Form.strength_meter/1,
score: 2,
warning: "Test warning hint",
suggestions: []
)
assert [_p_warning, p_hint] = find(doc, "p")
assert text(p_hint) == "Test warning hint."
end
test "renders only first suggestion when no warning present" do
doc =
render_component(&Form.strength_meter/1,
score: 2,
warning: "",
suggestions: ["Test suggestion 1.", "Test suggestion 2."]
)
assert [_p_warning, p_hint] = find(doc, "p")
assert text(p_hint) =~ "Test suggestion 1."
refute text(p_hint) =~ "Test suggestion 2."
end
test "favors hint warning over suggestion when both present" do
doc =
render_component(&Form.strength_meter/1,
score: 2,
warning: "Test warning hint",
suggestions: ["Test suggestion 1.", "Test suggestion 2."]
)
assert [_p_warning, p_hint] = find(doc, "p")
assert text(p_hint) =~ "Test warning hint."
refute text(p_hint) =~ "Test suggestion 1."
refute text(p_hint) =~ "Test suggestion 2."
end
end
defp render_password_input_with_strength(password, attrs \\ []) do
changeset =
%{"password" => password}
|> User.new()
|> Map.put(:action, :validate)
strength = User.password_strength(changeset)
form = Phoenix.Component.to_form(changeset)
render_component(
&Form.password_input_with_strength/1,
Keyword.merge([field: form[:password], strength: strength], attrs)
)
end
defp render_password_length_hint(password, minimum) do
changeset =
%{"password" => password}
|> User.new()
|> Map.put(:action, :validate)
form = Phoenix.Component.to_form(changeset)
render_component(&Form.password_length_hint/1, field: form[:password], minimum: minimum)
end
end

View File

@ -0,0 +1,309 @@
defmodule PlausibleWeb.Live.RegisterFormTest do
use PlausibleWeb.ConnCase, async: true
import Mox
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
alias Plausible.Auth.User
alias Plausible.Repo
setup :verify_on_exit!
describe "/register" do
test "renders registration form (LV)", %{conn: conn} do
lv = get_liveview(conn, "/register")
html = render(lv)
assert element_exists?(html, ~s|form[phx-change="validate"][phx-submit="register"]|)
assert element_exists?(html, ~s|input[type="hidden"][name="_csrf_token"]|)
assert element_exists?(html, ~s|input#register-form_name[type="text"][name="user[name]"]|)
assert element_exists?(
html,
~s|input#register-form_email[type="email"][name="user[email]"]|
)
assert element_exists?(
html,
~s|input#register-form_password[type="password"][name="user[password]"]|
)
assert element_exists?(
html,
~s|input#register-form_password_confirmation[type="password"][name="user[password_confirmation]"]|
)
assert element_exists?(html, ~s|button[type="submit"]|)
end
test "renders validation errors depending on input", %{conn: conn} do
lv = get_liveview(conn, "/register")
type_into_input(lv, "user[password]", "too-short")
html = render(lv)
assert html =~ "Password is too weak"
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
html = render(lv)
refute html =~ "Password is too weak"
end
test "creates user entry on valid input", %{conn: conn} do
mock_captcha_success()
lv = get_liveview(conn, "/register")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[email]", "mary.sue@plausible.test")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123")
html = lv |> element("form") |> render_submit()
assert_push_event(lv, "send-metrics", %{event_name: "Signup", params: %{}})
assert [
csrf_input,
name_input,
email_input,
password_input,
password_confirmation_input | _
] = find(html, "input")
assert String.length(text_of_attr(csrf_input, "value")) > 0
assert text_of_attr(name_input, "value") == "Mary Sue"
assert text_of_attr(email_input, "value") == "mary.sue@plausible.test"
assert text_of_attr(password_input, "value") == "very-long-and-very-secret-123"
assert text_of_attr(password_confirmation_input, "value") == "very-long-and-very-secret-123"
assert %{
name: "Mary Sue",
email: "mary.sue@plausible.test",
password_hash: password_hash
} = Repo.one(User)
assert String.length(password_hash) > 0
end
test "renders only one error on empty password confirmation", %{conn: conn} do
mock_captcha_success()
lv = get_liveview(conn, "/register")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[email]", "mary.sue@plausible.test")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "")
html = lv |> element("form") |> render_submit()
assert html =~ "does not match confirmation"
refute html =~ "can't be blank"
end
test "renders error on failed captcha", %{conn: conn} do
mock_captcha_failure()
lv = get_liveview(conn, "/register")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[email]", "mary.sue@plausible.test")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123")
html = lv |> element("form") |> render_submit()
assert html =~ "Please complete the captcha to register"
refute Repo.one(User)
end
test "pushing send-metrics-after event submits the form", %{conn: conn} do
lv = get_liveview(conn, "/register")
refute render(lv) =~ ~s|phx-trigger-action="phx-trigger-action"|
render_hook(lv, "send-metrics-after", %{event_name: "Signup", params: %{}})
assert render(lv) =~ ~s|phx-trigger-action="phx-trigger-action"|
end
end
describe "/register/invitation/:invitation_id" do
setup do
inviter = insert(:user)
site = insert(:site, members: [inviter])
invitation =
insert(:invitation,
site_id: site.id,
inviter: inviter,
email: "user@email.co",
role: :admin
)
{:ok, %{site: site, invitation: invitation, inviter: inviter}}
end
test "registers user from invitation", %{conn: conn, invitation: invitation} do
mock_captcha_success()
lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123")
html = lv |> element("form") |> render_submit()
assert_push_event(lv, "send-metrics", %{event_name: "Signup via invitation", params: %{}})
assert [
csrf_input,
email_input,
name_input,
password_input,
password_confirmation_input | _
] = find(html, "input")
assert String.length(text_of_attr(csrf_input, "value")) > 0
assert text_of_attr(name_input, "value") == "Mary Sue"
assert text_of_attr(email_input, "value") == "user@email.co"
assert text_of_attr(password_input, "value") == "very-long-and-very-secret-123"
assert text_of_attr(password_confirmation_input, "value") == "very-long-and-very-secret-123"
assert %{
name: "Mary Sue",
email: "user@email.co",
password_hash: password_hash,
# leaves trial_expiry_date null when invitation role is not :owner
trial_expiry_date: nil
} = Repo.get_by(User, email: "user@email.co")
assert String.length(password_hash) > 0
end
test "preserves trial_expiry_date when invitation role is :owner", %{
conn: conn,
site: site,
inviter: inviter
} do
mock_captcha_success()
invitation =
insert(:invitation,
site_id: site.id,
inviter: inviter,
email: "owner_user@email.co",
role: :owner
)
lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123")
_html = lv |> element("form") |> render_submit()
assert %{
email: "owner_user@email.co",
trial_expiry_date: trial_expiry_date
} = Repo.get_by(User, email: "owner_user@email.co")
assert trial_expiry_date != nil
end
test "always uses original email from the invitation", %{conn: conn, invitation: invitation} do
mock_captcha_success()
lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[email]", "mary.sue@plausible.test")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123")
html = lv |> element("form") |> render_submit()
assert [
_csrf_input,
email_input | _
] = find(html, "input")
# attempt at tampering with form
assert text_of_attr(email_input, "value") == "mary.sue@plausible.test"
assert Repo.get_by(User, email: "user@email.co")
refute Repo.get_by(User, email: "mary.sue@plausible.test")
end
test "renders error on failed captcha", %{conn: conn, invitation: invitation} do
mock_captcha_failure()
lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}")
type_into_input(lv, "user[name]", "Mary Sue")
type_into_input(lv, "user[password]", "very-long-and-very-secret-123")
type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123")
html = lv |> element("form") |> render_submit()
assert html =~ "Please complete the captcha to register"
refute Repo.get_by(User, email: "user@email.co")
end
test "pushing send-metrics-after event submits the form", %{
conn: conn,
invitation: invitation
} do
lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}")
refute render(lv) =~ ~s|phx-trigger-action="phx-trigger-action"|
render_hook(lv, "send-metrics-after", %{event_name: "Signup via invitation", params: %{}})
assert render(lv) =~ ~s|phx-trigger-action="phx-trigger-action"|
end
end
defp get_liveview(conn, url) do
conn = assign(conn, :live_module, PlausibleWeb.Live.RegisterForm)
{:ok, lv, _html} = live(conn, url)
lv
end
defp type_into_input(lv, id, text) do
lv
|> element("form")
|> render_change(%{id => text})
end
defp mock_captcha_success() do
mock_captcha(true)
end
defp mock_captcha_failure() do
mock_captcha(false)
end
defp mock_captcha(success) do
expect(
Plausible.HTTPClient.Mock,
:post,
fn _, _, _ ->
{:ok,
%Finch.Response{
status: 200,
headers: [{"content-type", "application/json"}],
body: %{"success" => success}
}}
end
)
end
end

View File

@ -0,0 +1,57 @@
defmodule PlausibleWeb.Live.ResetPasswordFormTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
alias Plausible.Auth.User
alias Plausible.Auth.Token
alias Plausible.Repo
describe "/password/reset" do
test "sets new password with valid token", %{conn: conn} do
user = insert(:user)
token = Token.sign_password_reset(user.email)
lv = get_liveview(conn, "/password/reset?token=#{token}")
type_into_passowrd(lv, "very-secret-and-very-long-123")
html = lv |> element("form") |> render_submit()
assert [csrf_input, password_input, token_input | _] = find(html, "input")
assert String.length(text_of_attr(csrf_input, "value")) > 0
assert text_of_attr(token_input, "value") == token
assert text_of_attr(password_input, "value") == "very-secret-and-very-long-123"
assert %{password_hash: new_hash} = Repo.one(User)
assert new_hash != user.password_hash
end
test "renders error when new password fails validation", %{conn: conn} do
user = insert(:user)
token = Token.sign_password_reset(user.email)
lv = get_liveview(conn, "/password/reset?token=#{token}")
type_into_passowrd(lv, "too-short")
html = lv |> element("form") |> render_submit()
assert html =~ "Password is too weak"
assert %{password_hash: hash} = Repo.one(User)
assert hash == user.password_hash
end
end
defp get_liveview(conn, url) do
conn = assign(conn, :live_module, PlausibleWeb.Live.ResetPasswordForm)
{:ok, lv, _html} = live(conn, url)
lv
end
defp type_into_passowrd(lv, text) do
lv
|> element("form")
|> render_change(%{"user[password]" => text})
end
end

View File

@ -36,6 +36,12 @@ defmodule PlausibleWeb.ConnCase do
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, {:shared, self()})
end
{:ok, conn: Phoenix.ConnTest.build_conn()}
# randomize client ip to avoid accidentally hitting
# rate limiting during tests
conn =
Phoenix.ConnTest.build_conn()
|> Plug.Conn.put_req_header("x-forwarded-for", Plausible.TestUtils.random_ip())
{:ok, conn: conn}
end
end

View File

@ -31,6 +31,12 @@ defmodule Plausible.Test.Support.HTML do
|> String.trim()
end
def text(element) do
element
|> Floki.text()
|> String.trim()
end
def text_of_attr(element, attr) do
element
|> Floki.attribute(attr)

View File

@ -237,4 +237,8 @@ defmodule Plausible.TestUtils do
10
)
end
def random_ip() do
Enum.map_join(1..4, ".", fn _ -> Enum.random(1..254) end)
end
end