mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
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:
parent
b1ade191eb
commit
51c1138d0d
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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 &"])),
|
||||
]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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!()
|
||||
|
@ -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.
|
||||
"""
|
||||
|
328
lib/plausible_web/live/register_form.ex
Normal file
328
lib/plausible_web/live/register_form.ex
Normal 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
|
102
lib/plausible_web/live/reset_password_form.ex
Normal file
102
lib/plausible_web/live/reset_password_form.ex
Normal 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
|
37
lib/plausible_web/plugs/maybe_disable_registration.ex
Normal file
37
lib/plausible_web/plugs/maybe_disable_registration.ex
Normal 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
|
@ -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
|
||||
|
@ -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>
|
@ -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>
|
@ -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" %>
|
||||
|
@ -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 %>
|
@ -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}
|
||||
) %>
|
||||
|
@ -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>
|
@ -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 %>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
3
mix.exs
3
mix.exs
@ -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
|
||||
|
||||
|
1
mix.lock
1
mix.lock
@ -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", []},
|
||||
}
|
||||
|
86
test/plausible/auth/user_test.exs
Normal file
86
test/plausible/auth/user_test.exs
Normal 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
|
@ -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"
|
||||
|
@ -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)
|
||||
|
||||
|
198
test/plausible_web/live/components/form_test.exs
Normal file
198
test/plausible_web/live/components/form_test.exs
Normal 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
|
309
test/plausible_web/live/register_form_test.exs
Normal file
309
test/plausible_web/live/register_form_test.exs
Normal 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
|
57
test/plausible_web/live/reset_password_form_test.exs
Normal file
57
test/plausible_web/live/reset_password_form_test.exs
Normal 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
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user