diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d71dfd415b9..4e6800129f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to this project will be documented in this file. - Use ResizeObserver instead of polling in tracker for scroll depth. Removes forced reflows caused by the tracker script. - Update custom range datepicker styles - Improved site transfer UI +- Redesigned authentication pages (register, sign in, 2FA, password reset, account activation) ### Fixed diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts index be00032decf7..ef7e611de15d 100644 --- a/e2e/tests/fixtures.ts +++ b/e2e/tests/fixtures.ts @@ -87,14 +87,13 @@ export async function register({ await page.getByLabel('Full name').fill(user.name) await page.getByLabel('Email').fill(user.email) await page.getByLabel('Password', { exact: true }).fill(user.password) - await page.getByLabel('Confirm password', { exact: true }).fill(user.password) await expect( page.getByRole('button', { name: 'Start my free trial' }) ).toBeEnabled() await page.getByRole('button', { name: 'Start my free trial' }).click() await expect( - page.getByRole('heading', { name: 'Activate your account' }) + page.getByRole('heading', { name: 'Check your email' }) ).toBeVisible() const response = await request.get('/sent-emails-api/emails.json') @@ -124,11 +123,11 @@ export async function register({ export async function login({ page, user }: { page: Page; user: User }) { await page.goto('/login', { waitUntil: 'commit' }) - await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible() await page.getByLabel('Email').fill(user.email) await page.getByLabel('Password').fill(user.password) - await page.getByRole('button', { name: 'Log in' }).click() + await page.getByRole('button', { name: 'Sign in' }).click() await expect(page.getByRole('button', { name: user.name })).toBeVisible() } diff --git a/extra/lib/plausible_web/controllers/sso_controller.ex b/extra/lib/plausible_web/controllers/sso_controller.ex index dfad5d4f0b9c..4c221cbf362a 100644 --- a/extra/lib/plausible_web/controllers/sso_controller.ex +++ b/extra/lib/plausible_web/controllers/sso_controller.ex @@ -24,7 +24,10 @@ defmodule PlausibleWeb.SSOController do redirect(conn, to: Routes.auth_path(conn, :login_form, return_to: params["return_to"])) _ -> - render(conn, "login_form.html", autosubmit: params["autosubmit"] != nil) + render_auth_page(conn, "login_form.html", + autosubmit: params["autosubmit"] != nil, + heading: "Sign in with SSO" + ) end end @@ -44,7 +47,10 @@ defmodule PlausibleWeb.SSOController do else {:error, :not_found} -> conn - |> put_flash(:login_error, "Wrong email.") + |> put_flash( + :login_error, + "We couldn't find a Single Sign-On account for that email." + ) |> redirect(to: Routes.sso_path(conn, :login_form)) {:error, {:rate_limit, _}} -> @@ -59,7 +65,7 @@ defmodule PlausibleWeb.SSOController do end def provision_notice(conn, _params) do - render(conn, "provision_notice.html") + render_auth_page(conn, "provision_notice.html", heading: "Single Sign-On required") end def provision_issue(conn, params) do @@ -73,7 +79,10 @@ defmodule PlausibleWeb.SSOController do _ -> :unknown end - render(conn, "provision_issue.html", issue: issue) + render_auth_page(conn, "provision_issue.html", + heading: "Single Sign-On required", + issue: issue + ) end def saml_signin(conn, params) do diff --git a/extra/lib/plausible_web/sso/fake_saml_adapter.ex b/extra/lib/plausible_web/sso/fake_saml_adapter.ex index 6a3569b15ed3..dccb42098ea1 100644 --- a/extra/lib/plausible_web/sso/fake_saml_adapter.ex +++ b/extra/lib/plausible_web/sso/fake_saml_adapter.ex @@ -56,7 +56,10 @@ defmodule PlausibleWeb.SSO.FakeSAMLAdapter do {:error, :not_found} -> conn - |> Phoenix.Controller.put_flash(:login_error, "Wrong email.") + |> Phoenix.Controller.put_flash( + :login_error, + "We couldn't find a Single Sign-On account for that email." + ) |> Phoenix.Controller.redirect( to: Routes.sso_path(conn, :login_form, return_to: params["return_to"]) ) diff --git a/extra/lib/plausible_web/sso/real_saml_adapter.ex b/extra/lib/plausible_web/sso/real_saml_adapter.ex index e81fa2d67672..dc5235ba3d90 100644 --- a/extra/lib/plausible_web/sso/real_saml_adapter.ex +++ b/extra/lib/plausible_web/sso/real_saml_adapter.ex @@ -50,7 +50,10 @@ defmodule PlausibleWeb.SSO.RealSAMLAdapter do {:error, :not_found} -> conn - |> Phoenix.Controller.put_flash(:login_error, "Wrong email.") + |> Phoenix.Controller.put_flash( + :login_error, + "We couldn't find a Single Sign-On account for that email." + ) |> Phoenix.Controller.redirect( to: Routes.sso_path(conn, :login_form, return_to: return_to) ) @@ -108,7 +111,11 @@ defmodule PlausibleWeb.SSO.RealSAMLAdapter do PlausibleWeb.UserAuth.log_in_user(conn, identity, cookie.return_to) else {:error, :not_found} -> - login_error(conn, cookie, "Wrong email") + login_error( + conn, + cookie, + "We couldn't find a Single Sign-On account for that email." + ) {:error, reason} -> with {:ok, integration} <- SSO.get_integration(integration_id) do diff --git a/extra/lib/plausible_web/templates/sso/login_form.html.heex b/extra/lib/plausible_web/templates/sso/login_form.html.heex index 505d5ef94e1b..ff4b403811be 100644 --- a/extra/lib/plausible_web/templates/sso/login_form.html.heex +++ b/extra/lib/plausible_web/templates/sso/login_form.html.heex @@ -1,52 +1,54 @@ -<.focus_box> - <:title> - {Phoenix.Flash.get(@flash, :login_title) || "Enter your Single Sign-On email"} - - <:subtitle> - <%= if Phoenix.Flash.get(@flash, :login_instructions) do %> -

- {Phoenix.Flash.get(@flash, :login_instructions)} -

- <% end %> - +<.auth_container> - <.form :let={f} id="sso-login-form" for={@conn} action={Routes.sso_path(@conn, :login)}> -
- <.input - type="email" - autocomplete="username" - placeholder="user@example.com" - field={f[:email]} - /> -
- <%= if login_error = Phoenix.Flash.get(@flash, :login_error) do %> -
{login_error}
- <% end %> + <.form + :let={f} + id="sso-login-form" + for={@conn} + action={Routes.sso_path(@conn, :login)} + class="flex flex-col gap-y-6" + > +
+ +
+ <.input + type="email" + autocomplete="username" + placeholder="example@email.com" + field={f[:email]} + mt?={false} + autofocus="autofocus" + /> +
+

+ {login_error} +

+
- <.input type="hidden" field={f[:return_to]} /> + - <.button class="w-full" type="submit">Sign In - +
+ <.button class="w-full" type="submit" mt?={false}>Sign in - <:footer> - <.focus_list> - <:item> - Have a standard account? +

<.styled_link href={ Routes.auth_path(@conn, :login_form, return_to: @conn.params["return_to"], prefer: "manual" ) }> - Log in here + Use password instead - instead. - - - - +

+
+ + diff --git a/extra/lib/plausible_web/templates/sso/provision_issue.html.heex b/extra/lib/plausible_web/templates/sso/provision_issue.html.heex index 57abf7d4085a..e26a39e67192 100644 --- a/extra/lib/plausible_web/templates/sso/provision_issue.html.heex +++ b/extra/lib/plausible_web/templates/sso/provision_issue.html.heex @@ -1,51 +1,38 @@ -<.focus_box> - <:title> - Single Sign-On enforcement - - <:subtitle> - The owner of the team - "{@conn.assigns[:current_team].name}" - has turned off regular email and password logins. - To keep things secure and simple, you can only sign in using your organization's - Single Sign-On (SSO) system. - - -

- To access this team, you must first leave all other teams. -

- -

- To log in as an SSO user, you must first leave all other teams. -

- -
+<.auth_container> +

- To access this team, you must either remove or transfer all sites you own under "My Personal Sites". + The owner of + the team"{@conn.assigns[:current_team].name}" + has turned off email and password logins, so you can only sign in with your organization's Single Sign-On. + + To access this team, you must be added as a team member first. + + + To access this team, you must leave all other teams first. + + + To sign in with SSO, you must leave all other teams first. + + + To access this team, remove or transfer all sites under + "My Personal Sites" + and cancel its subscription if active. + + + To sign in with SSO, remove or transfer all sites under + "My Personal Sites" + and cancel its subscription if active. +

-

- You also have to cancel subscription for "My Personal Sites" if there is an active one. +

+ <.styled_link href={Routes.auth_path(@conn, :login_form, prefer: "manual")}> + Sign in with email and password + + to resolve the issue.

- -
-

- To log in as an SSO user, you must either remove or transfer all sites you own under "My Personal Sites". -

- -

- You also have to cancel subscription on "My Personal Sites" if there is an active one. -

-
- -

- To access this team, you must join as a team member first. -

- -

- <.styled_link href={Routes.auth_path(@conn, :login_form, prefer: "manual")}> - Log in - - with your email and password to resolve the issue. -

- + diff --git a/extra/lib/plausible_web/templates/sso/provision_notice.html.heex b/extra/lib/plausible_web/templates/sso/provision_notice.html.heex index 12d944f9fea4..dbac07d34478 100644 --- a/extra/lib/plausible_web/templates/sso/provision_notice.html.heex +++ b/extra/lib/plausible_web/templates/sso/provision_notice.html.heex @@ -1,17 +1,17 @@ -<.focus_box> - <:title> - Single Sign-On enforcement - - <:subtitle> - The owner of the team - "{@conn.assigns[:current_team].name}" - has turned off regular email and password logins. - To keep things secure and simple, you can only sign in using your organization's - Single Sign-On (SSO) system. - - -

- To access this team, you must first <.styled_link href="/logout">log out - and log in as SSO user. -

- +<.auth_container> +
+

+ The owner of + the team"{@conn.assigns[:current_team].name}" + has turned off email and password logins, so you can only sign in with your organization's Single Sign-On. +

+

+ To access this team, + <.styled_link href={Routes.auth_path(@conn, :logout)}>sign out + and sign in with SSO. +

+
+ diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index bba161b6039a..5cc44214384f 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -79,9 +79,10 @@ defmodule Plausible.Auth.User do def new(attrs \\ %{}) do %Plausible.Auth.User{} |> cast(attrs, @required) - |> validate_required(@required) + |> validate_required(:name, message: "Please enter your name") + |> validate_required(:email, message: "Please enter your email") + |> validate_required(:password, message: "Please enter a password") |> validate_password_length() - |> validate_confirmation(:password, required: true) |> validate_password_strength() |> hash_password() |> set_email_verification_status() diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 7485c47ca07d..abf62ff21306 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -52,6 +52,17 @@ defmodule PlausibleWeb.Components.Generic do "md" => "btn-md" } + attr(:class, :string, default: "") + slot(:inner_block, required: true) + + def auth_container(assigns) do + ~H""" +
+ {render_slot(@inner_block)} +
+ """ + end + attr(:type, :string, default: "button") attr(:theme, :string, default: "primary") attr(:size, :string, default: "md") diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index 89075d6d1b89..9fc63ab11c5b 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -3,6 +3,28 @@ defmodule PlausibleWeb.Components.Layout do use Phoenix.Component + attr :class, :string, default: "w-24 sm:w-30" + + def logo(assigns) do + ~H""" + Plausible logo + Plausible logo + """ + end + + defp logo_url(filename), + do: PlausibleWeb.Router.Helpers.static_path(PlausibleWeb.Endpoint, logo_path(filename)) + def favicon(assigns) do ~H""" - = 6) document.getElementById('#{@id}').focus()" - else - "this.value=this.value.replace(/[^0-9]/g, '');" - end - } - onclick="this.select();" - oninvalid={@show_button? && "document.getElementById('#{@id}').disabled = false"} - maxlength="6" - placeholder="••••••" - required="required" - /> +
+
+ <.otp_input + field={@field} + length={6} + oninvalid={@show_button? && "document.getElementById('#{@id}').disabled = false"} + autofocus={@autofocus? && "autofocus"} + /> +

+ {@error} +

+
<.button :if={@show_button?} type="submit" id={@id} mt?={false} - class="rounded-l-none [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block" + class="w-full [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block" > - Verify → + Verify diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 49f4137c445f..13ae747473d4 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -64,16 +64,22 @@ defmodule PlausibleWeb.AuthController do user = conn.assigns.current_user flow = params["flow"] || PlausibleWeb.Flows.register() team_identifier = params["team_identifier"] + has_email_code? = Plausible.Users.has_email_code?(user) + has_any_memberships? = Plausible.Teams.Users.has_sites?(user) - render(conn, "activate.html", + render_auth_page(conn, "activate.html", error: nil, - has_email_code?: Plausible.Users.has_email_code?(user), - has_any_memberships?: Plausible.Teams.Users.has_sites?(user), + has_email_code?: has_email_code?, + has_any_memberships?: has_any_memberships?, + heading: activate_heading(has_email_code?), form_submit_url: "/activate?flow=#{flow}", team_identifier: team_identifier ) end + defp activate_heading(true), do: "Check your email" + defp activate_heading(false), do: "Activate your account" + def activate(conn, %{"code" => code}) do user = conn.assigns[:current_user] @@ -82,10 +88,17 @@ defmodule PlausibleWeb.AuthController do do_activate(conn, user, code) else {:error, {:rate_limit, _}} -> - render_error( - conn, - 429, - "Too many activation attempts. Wait a few minutes before trying again." + has_any_memberships? = Plausible.Teams.Users.has_sites?(user, include_pending?: false) + flow = conn.params["flow"] + team_identifier = conn.params["team_identifier"] + + render_auth_page(conn, "activate.html", + error: "Too many attempts. Please wait a few minutes before trying again.", + has_email_code?: true, + has_any_memberships?: has_any_memberships?, + heading: activate_heading(true), + form_submit_url: "/activate?flow=#{flow}", + team_identifier: team_identifier ) end end @@ -116,19 +129,23 @@ defmodule PlausibleWeb.AuthController do end {:error, :incorrect} -> - render(conn, "activate.html", - error: "Incorrect activation code", + render_auth_page(conn, "activate.html", + error: "That code didn't work. Please try again.", has_email_code?: true, has_any_memberships?: has_any_memberships?, - form_submit_url: "/activate?flow=#{flow}" + heading: activate_heading(true), + form_submit_url: "/activate?flow=#{flow}", + team_identifier: team_identifier ) {:error, :expired} -> - render(conn, "activate.html", - error: "Code is expired, please request another one", + render_auth_page(conn, "activate.html", + error: "The code has expired. Please request another one.", has_email_code?: false, has_any_memberships?: has_any_memberships?, - form_submit_url: "/activate?flow=#{flow}" + heading: activate_heading(false), + form_submit_url: "/activate?flow=#{flow}", + team_identifier: team_identifier ) end end @@ -152,11 +169,11 @@ defmodule PlausibleWeb.AuthController do end def password_reset_request_form(conn, _) do - render(conn, "password_reset_request_form.html") + render_password_reset_request_form(conn) end def password_reset_request(conn, %{"email" => ""}) do - render(conn, "password_reset_request_form.html", error: "Please enter an email address") + render_password_reset_request_form(conn, error: "Please enter an email address") end def password_reset_request(conn, %{"email" => email} = params) do @@ -172,38 +189,62 @@ defmodule PlausibleWeb.AuthController do "Password reset e-mail sent. In dev environment GET /sent-emails for details." ) - render(conn, "password_reset_request_success.html", email: email) + render_auth_page(conn, "password_reset_request_success.html", + heading: "Check your email", + email: email + ) {:error, _} -> - render(conn, "password_reset_request_success.html", email: email) + render_auth_page(conn, "password_reset_request_success.html", + heading: "Check your email", + email: email + ) end else - render(conn, "password_reset_request_form.html", - error: "Please complete the captcha to reset your password" + render_password_reset_request_form(conn, + captcha_error: "Please complete the captcha to reset your password" ) end end + defp render_password_reset_request_form(conn, extra_assigns \\ []) do + render_auth_page( + conn, + "password_reset_request_form.html", + Keyword.merge( + [ + heading: "Reset your password", + subtitle: "Enter your email to receive a password reset link." + ], + extra_assigns + ) + ) + end + def password_reset_form(conn, params) do case Auth.Token.verify_password_reset(params["token"]) do {:ok, %{email: email}} -> - render(conn, "password_reset_form.html", + render_auth_page(conn, "password_reset_form.html", connect_live_socket: true, + heading: "Reset your password", + subtitle: "Choose a new password for your account", email: email ) {:error, :expired} -> - render_error( - conn, - 401, - "Your token has expired. Please request another password reset link." + conn + |> put_status(401) + |> render_auth_page("password_reset_error.html", + heading: "Password reset link expired", + subtitle: "Request a new one to reset your password." ) {:error, _} -> - render_error( - conn, - 401, - "Your token is invalid. Please request another password reset link." + conn + |> put_status(401) + |> render_auth_page("password_reset_error.html", + heading: "Password reset link invalid", + subtitle: "Request a new one to reset your password." ) end end @@ -212,7 +253,7 @@ defmodule PlausibleWeb.AuthController do conn |> UserAuth.log_out_user() |> put_flash(:login_title, "Password updated successfully") - |> put_flash(:login_instructions, "Please log in with your new credentials") + |> put_flash(:login_instructions, "Please sign in with your new credentials") |> redirect(to: Routes.auth_path(conn, :login_form)) end @@ -226,15 +267,22 @@ defmodule PlausibleWeb.AuthController do redirect(conn, to: Routes.sso_path(conn, :login_form, return_to: params["return_to"])) _ -> - render(conn, "login_form.html") + render_login_form(conn) end end else def login_form(conn, _params) do - render(conn, "login_form.html") + render_login_form(conn) end end + defp render_login_form(conn) do + heading = Phoenix.Flash.get(conn.assigns.flash, :login_title) || "Sign in to your account" + subtitle = Phoenix.Flash.get(conn.assigns.flash, :login_instructions) + + render_auth_page(conn, "login_form.html", heading: heading, subtitle: subtitle) + end + def login(conn, %{"user" => params}) do login(conn, params) end @@ -280,16 +328,16 @@ defmodule PlausibleWeb.AuthController do Auth.log_failed_login_attempt("wrong password for #{email}") conn - |> put_flash(:login_error, "Wrong email or password. Please try again.") - |> render("login_form.html") + |> put_flash(:login_error, "Incorrect email or password. Please try again.") + |> render_login_form() {:error, :user_not_found} -> Auth.log_failed_login_attempt("user not found for #{email}") Plausible.Auth.Password.dummy_calculation() conn - |> put_flash(:login_error, "Wrong email or password. Please try again.") - |> render("login_form.html") + |> put_flash(:login_error, "Incorrect email or password. Please try again.") + |> render_login_form() {:error, {:rate_limit, _}} -> Auth.log_failed_login_attempt("too many login attempts for #{email}") @@ -439,9 +487,7 @@ defmodule PlausibleWeb.AuthController do case TwoFactor.Session.get_2fa_user(conn) do {:ok, user} -> if Auth.TOTP.enabled?(user) do - render(conn, "verify_2fa.html", - remember_2fa_days: TwoFactor.Session.remember_2fa_days() - ) + render_verify_2fa(conn) else redirect_to_login(conn) end @@ -462,11 +508,7 @@ defmodule PlausibleWeb.AuthController do {:error, :invalid_code} -> Auth.log_failed_login_attempt("wrong 2FA verification code provided for #{user.email}") - conn - |> put_flash(:error, "The provided code is invalid. Please try again") - |> render("verify_2fa.html", - remember_2fa_days: TwoFactor.Session.remember_2fa_days() - ) + render_verify_2fa(conn, "The provided code is invalid. Please try again") {:error, :not_enabled} -> UserAuth.log_in_user(conn, user, params["return_to"]) @@ -478,7 +520,7 @@ defmodule PlausibleWeb.AuthController do case TwoFactor.Session.get_2fa_user(conn) do {:ok, user} -> if Auth.TOTP.enabled?(user) do - render(conn, "verify_2fa_recovery_code.html") + render_verify_2fa_recovery_code(conn) else redirect_to_login(conn) end @@ -497,9 +539,10 @@ defmodule PlausibleWeb.AuthController do {:error, :invalid_code} -> Auth.log_failed_login_attempt("wrong 2FA recovery code provided for #{user.email}") - conn - |> put_flash(:error, "The provided recovery code is invalid. Please try another one") - |> render("verify_2fa_recovery_code.html") + render_verify_2fa_recovery_code( + conn, + "The provided recovery code is invalid. Please try another one" + ) {:error, :not_enabled} -> UserAuth.log_in_user(conn, user) @@ -507,6 +550,23 @@ defmodule PlausibleWeb.AuthController do end end + defp render_verify_2fa(conn, error \\ nil) do + render_auth_page(conn, "verify_2fa.html", + error: error, + heading: "Enter your 2FA code", + subtitle: "Open your authenticator app and enter the 6-digit code.", + remember_2fa_days: TwoFactor.Session.remember_2fa_days() + ) + end + + defp render_verify_2fa_recovery_code(conn, error \\ nil) do + render_auth_page(conn, "verify_2fa_recovery_code.html", + error: error, + heading: "Use a recovery code", + subtitle: "Enter one of your saved recovery codes to sign in." + ) + end + defp get_2fa_user_limited(conn) do case TwoFactor.Session.get_2fa_user(conn) do {:ok, user} -> diff --git a/lib/plausible_web/controllers/helpers.ex b/lib/plausible_web/controllers/helpers.ex index a8ab6922f162..c7e1e33d5806 100644 --- a/lib/plausible_web/controllers/helpers.ex +++ b/lib/plausible_web/controllers/helpers.ex @@ -1,4 +1,6 @@ defmodule PlausibleWeb.ControllerHelpers do + @moduledoc false + import Plug.Conn import Phoenix.Controller @@ -21,6 +23,17 @@ defmodule PlausibleWeb.ControllerHelpers do defp error_layout, do: Application.get_env(:plausible, PlausibleWeb.Endpoint)[:render_errors][:layout] + @auth_layout_assigns [ + layout: {PlausibleWeb.LayoutView, :auth}, + hide_header?: true, + hide_footer?: true, + disable_global_notices?: true + ] + + def render_auth_page(conn, template, extra_assigns \\ []) do + render(conn, template, Keyword.merge(@auth_layout_assigns, extra_assigns)) + end + def debug_metadata(conn) do %{ request_method: conn.method, diff --git a/lib/plausible_web/flows.ex b/lib/plausible_web/flows.ex index 21ec889103e6..0d22dae1e9f9 100644 --- a/lib/plausible_web/flows.ex +++ b/lib/plausible_web/flows.ex @@ -15,8 +15,6 @@ defmodule PlausibleWeb.Flows do "Verify installation" ], register: [ - "Register", - "Activate account", "Add site info", "Install Plausible", "Verify installation" diff --git a/lib/plausible_web/live/auth_layout_context.ex b/lib/plausible_web/live/auth_layout_context.ex new file mode 100644 index 000000000000..9edd326b188f --- /dev/null +++ b/lib/plausible_web/live/auth_layout_context.ex @@ -0,0 +1,16 @@ +defmodule PlausibleWeb.Live.AuthLayoutContext do + @moduledoc false + + import Phoenix.Component + + def on_mount(_arg, _params, _session, socket) do + socket = + assign(socket, + hide_header?: true, + hide_footer?: true, + disable_global_notices?: true + ) + + {:cont, socket, layout: {PlausibleWeb.LayoutView, :auth}} + end +end diff --git a/lib/plausible_web/live/components/form.ex b/lib/plausible_web/live/components/form.ex index 39b92bd18506..b376aacf257b 100644 --- a/lib/plausible_web/live/components/form.ex +++ b/lib/plausible_web/live/components/form.ex @@ -44,8 +44,9 @@ defmodule PlausibleWeb.Live.Components.Form do attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs") attr(:rest, :global, - include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength - multiple pattern placeholder readonly required rows size step x-model) + include: + ~w(accept autocomplete autofocus capture cols disabled form list max maxlength min minlength + multiple pattern placeholder readonly required rows size step x-bind:type x-model) ) attr(:class, :any, default: @default_input_class) @@ -54,6 +55,7 @@ defmodule PlausibleWeb.Live.Components.Form do attr(:max_one_error, :boolean, default: false) slot(:help_content) slot(:inner_block) + slot(:trailing) def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] @@ -193,14 +195,17 @@ defmodule PlausibleWeb.Live.Components.Form do

{@help_text}

- +
+ + {render_slot(@trailing)} +
{render_slot(@inner_block)} <.error :for={msg <- @errors}> {msg} @@ -252,8 +257,109 @@ defmodule PlausibleWeb.Live.Components.Form do """ end + @doc """ + Renders a password input with a show/hide reveal toggle. + """ attr(:id, :any, default: nil) attr(:label, :string, default: nil) + attr(:mt?, :boolean, default: true) + attr(:autocomplete, :string, default: "current-password") + + attr(:field, Phoenix.HTML.FormField, required: true) + + attr(:rest, :global, + include: + ~w(autocomplete autofocus disabled form maxlength minlength placeholder readonly required size) + ) + + slot(:inner_block) + + def password_field(assigns) do + assigns = assign(assigns, :class, [@default_input_class, "pr-10"]) + + ~H""" +
+ <.input + type="password" + x-bind:type="showPassword ? 'text' : 'password'" + field={@field} + label={@label} + id={@id} + autocomplete={@autocomplete} + mt?={@mt?} + class={@class} + {@rest} + > + <:trailing> + + + {render_slot(@inner_block)} + +
+ """ + end + + @doc """ + Renders a one-time-code input (activation code, 2FA code, etc.). + """ + attr(:field, Phoenix.HTML.FormField, required: true) + attr(:length, :integer, default: 6) + + attr(:rest, :global, include: ~w(autofocus oninvalid)) + + def otp_input(assigns) do + oninput_js = + "this.value=this.value.replace(/[^0-9]/g, '');" <> + " if (this.value.length >= #{assigns.length})" <> + " this.form.querySelector('button[type=submit]').focus()" + + input_class = [ + @default_input_class, + "font-mono tracking-[0.5em] font-medium text-center w-full" + ] + + assigns = + assigns + |> assign(:input_class, input_class) + |> assign(:placeholder, String.duplicate("•", assigns.length)) + |> assign(:oninput_js, oninput_js) + + ~H""" + + """ + end + + attr(:id, :any, default: nil) + attr(:label, :string, default: nil) + attr(:mt?, :boolean, default: true) attr(:field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:password]", @@ -263,7 +369,8 @@ defmodule PlausibleWeb.Live.Components.Form do attr(:strength, :any) attr(:rest, :global, - include: ~w(autocomplete disabled form maxlength minlength readonly required size) + include: + ~w(autocomplete autofocus disabled form maxlength minlength placeholder readonly required size) ) def password_input_with_strength(%{field: field} = assigns) do @@ -294,9 +401,16 @@ defmodule PlausibleWeb.Live.Components.Form do ) ~H""" - <.input field={@field} type="password" autocomplete="new-password" label={@label} id={@id} {@rest}> + <.password_field + field={@field} + autocomplete="new-password" + label={@label} + id={@id} + mt?={@mt?} + {@rest} + > <.strength_meter :if={@show_meter?} {@strength} /> - + """ end @@ -305,6 +419,7 @@ defmodule PlausibleWeb.Live.Components.Form do attr(:class, :any) attr(:ok_class, :any) attr(:error_class, :any) + attr(:hide_when_used?, :boolean, default: false) attr(:field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:password]", @@ -314,9 +429,11 @@ defmodule PlausibleWeb.Live.Components.Form do 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"] + hidden? = assigns.hide_when_used? and Phoenix.Component.used_input?(field) + + ok_class = assigns[:ok_class] || "text-gray-500 dark:text-gray-400" + error_class = assigns[:error_class] || "text-red-500 dark:text-red-400" + class = assigns[:class] || ["text-xs"] color = if :length in strength_errors do @@ -327,10 +444,13 @@ defmodule PlausibleWeb.Live.Components.Form do final_class = [color | class] - assigns = assign(assigns, :class, final_class) + assigns = + assigns + |> assign(:class, final_class) + |> assign(:hidden?, hidden?) ~H""" -

Min {@minimum} characters

+

At least {@minimum} characters

""" end @@ -357,14 +477,6 @@ defmodule PlausibleWeb.Live.Components.Form do 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 <> "." @@ -372,23 +484,27 @@ defmodule PlausibleWeb.Live.Components.Form do true -> nil end + strength_label = + cond do + assigns.score == 3 -> "Good" + assigns.score >= 4 -> "Strong" + true -> nil + end + assigns = assigns - |> assign(:color, color) |> assign(:feedback, feedback) + |> assign(:strength_label, strength_label) ~H""" -
-
to_string(@score * 25) <> "%"]} - > -
-
-

- Password is too weak +

+ Password is too weak. +

+

+ Password strength: + {@strength_label}

-

+

{@feedback}

""" @@ -416,7 +532,7 @@ defmodule PlausibleWeb.Live.Components.Form do def error(assigns) do ~H""" -

+

{render_slot(@inner_block)}

""" diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index 5d86ebcb63ad..6d882e92ca21 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -25,7 +25,13 @@ defmodule PlausibleWeb.Live.RegisterForm do if socket.assigns.live_action == :register_from_invitation_form and socket.assigns.invitation == nil do - {:ok, assign(socket, invitation_expired: true)} + {:ok, + assign(socket, + invitation_expired: true, + heading: "Invitation no longer valid", + subtitle: + "This invitation has expired or was revoked. Ask your team admin to send you a new invitation." + )} else changeset = if invitation = socket.assigns.invitation do @@ -34,71 +40,55 @@ defmodule PlausibleWeb.Live.RegisterForm do Auth.User.settings_changeset(%Auth.User{}) end + {heading, subtitle} = heading_and_subtitle(socket.assigns.live_action) + {:ok, assign(socket, form: to_form(changeset), captcha_error: nil, password_strength: Auth.User.password_strength(changeset), disable_submit: false, - trigger_submit: false + trigger_submit: false, + heading: heading, + subtitle: subtitle )} end end - def render(%{invitation_expired: true} = assigns) do - ~H""" -
-

{Plausible.product_name()}

-
Lightweight and privacy-friendly web analytics
-
+ defp heading_and_subtitle(:register_from_invitation_form) do + {"Create your account", "Accept your invitation to join your team."} + end -
-

Invitation expired

+ defp heading_and_subtitle(:register_form) do + if ce?() do + {"Create your #{Plausible.product_name()} account", + "Start tracking privacy-friendly analytics in minutes."} + else + {"Start your 30-day free trial", "No credit card required. Cancel anytime."} + end + end -

- Your invitation has expired or been revoked. Please request fresh one or you can - <.styled_link href={Routes.auth_path(@socket, :register_form)}>sign up - for a 30-day unlimited free trial without an invitation. -

-
+ def render(%{invitation_expired: true} = assigns) do + ~H""" + <.auth_container class="flex gap-3 justify-center"> + <.button_link href="/register" mt?={false}> + Start free trial + + <.button_link href="/login" theme="secondary" mt?={false}> + Sign in + + """ end def render(assigns) do ~H""" -
-

- <%= if ce?() or @live_action == :register_from_invitation_form do %> - Register your {Plausible.product_name()} account - <% else %> - Register your 30-day free trial - <% end %> -

-
- Set up privacy-friendly analytics with just a few clicks -
-
- - - - - <.focus_box> - <:title> - Enter your details - - + <.auth_container> <.form :let={f} for={@form} id="register-form" + class="flex flex-col gap-y-6" action={Routes.auth_path(@socket, :login)} phx-hook="Metrics" phx-change="validate" @@ -121,43 +111,26 @@ defmodule PlausibleWeb.Live.RegisterForm do <.email_input field={f[:email]} for_invitation={false} /> <% end %> -
-
- - <.password_length_hint minimum={12} field={f[:password]} /> -
-
- <.password_input_with_strength - field={f[:password]} - strength={@password_strength} - phx-debounce={200} - class="dark:bg-gray-900 shadow-xs focus:ring-indigo-500 focus:border-indigo-500 block w-full border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" - /> -
-
- -
+
-
- <.input - type="password" - autocomplete="new-password" - field={f[:password_confirmation]} +
+ <.password_input_with_strength + field={f[:password]} + strength={@password_strength} phx-debounce={200} - class="dark:bg-gray-900 shadow-xs focus:ring-indigo-500 focus:border-indigo-500 block w-full border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300" + mt?={false} />
+ <.password_length_hint minimum={12} field={f[:password]} hide_when_used?={true} />
<%= if PlausibleWeb.Captcha.enabled?() do %> -
+
- <%= if @captcha_error do %> -
- {@captcha_error} -
- <% end %> +

+ {@captcha_error} +

<% end %> - <.button class="w-full" type="submit">Send reset link +
+ <.button class="w-full" type="submit" mt?={false}>Send reset link + +

+ <.styled_link href="/login">Back to sign in +

+
- + diff --git a/lib/plausible_web/templates/auth/password_reset_request_success.html.heex b/lib/plausible_web/templates/auth/password_reset_request_success.html.heex index 6a2a75506a1e..48c11ab07eee 100644 --- a/lib/plausible_web/templates/auth/password_reset_request_success.html.heex +++ b/lib/plausible_web/templates/auth/password_reset_request_success.html.heex @@ -1,27 +1,27 @@ -
-

Success!

-
- We've sent an email containing password reset instructions to {@email} - if it's registered in our system. -
-
- Didn't receive the email within a few minutes? -
-
- You might have used an email address that's not registered in our database. Please verify the email address associated with your Plausible account and attempt the password reset once more. -
-
- - Certain that you're using the correct email address but still aren't receiving the password reset email? Please check your spam folder or ask on our community-supported forum. - - - Certain that you're using the correct email address but still aren't receiving the password reset email? Please check your spam folder or + If an account exists for {@email}, we've sent a password reset email. +

+ +<.auth_container class="border-t border-gray-200 dark:border-gray-700 pt-10"> +
+

+ Didn't receive the email? +

+

+ It may take a few minutes to arrive. Check your spam folder or try again with the email address associated with your account. Still no email? <.styled_link href="https://plausible.io/contact" - class="text-indigo-500" - >contact us. - + new_tab={true} + >Contact us. +

+

+ It may take a few minutes to arrive. Check your spam folder or try again with the email address associated with your account. Still no email? Ask on our <.styled_link + href="https://github.com/plausible/analytics/discussions" + new_tab={true} + >community forum. +

-
+ +

+ <.styled_link href="/login">Back to sign in +

+ diff --git a/lib/plausible_web/templates/auth/verify_2fa.html.heex b/lib/plausible_web/templates/auth/verify_2fa.html.heex index 1f049d673f1b..a4f97136d570 100644 --- a/lib/plausible_web/templates/auth/verify_2fa.html.heex +++ b/lib/plausible_web/templates/auth/verify_2fa.html.heex @@ -1,49 +1,45 @@ -<.focus_box> - <:title> - Enter Your 2FA Code - - - <:subtitle> - Enter the code from your authenticator application before it expires or wait for a new one. - - - <:footer> - <.focus_list> - <:item> - Can't access your authenticator app? - <.styled_link href={Routes.auth_path(@conn, :verify_2fa_recovery_code_form)}> - Use recovery code - - - <:item :if={ee?()}> - Lost your recovery codes? - <.styled_link href="https://plausible.io/contact"> - Contact us - - - - - +<.auth_container> <.form :let={f} action={Routes.auth_path(@conn, :verify_2fa, Map.take(@conn.query_params, ["return_to"]))} for={@conn.params} + class="flex flex-col gap-y-6" onsubmit="document.getElementById('verify-button').disabled = true" > -
- + -
- <.input - type="checkbox" - field={f[:remember_2fa]} - value="true" - label={"Trust this device for #{@remember_2fa_days} days"} - class="block h-5 w-5 rounded-sm dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600" - /> -
- - <.input type="hidden" field={f[:return_to]} /> +
+ <.input + type="checkbox" + field={f[:remember_2fa]} + value="true" + label={"Remember this device for #{@remember_2fa_days} days"} + mt?={false} + class="block size-5 rounded-sm dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600" + />
+ + <.input type="hidden" field={f[:return_to]} mt?={false} /> - + +
+

+ Can't access your authenticator app? + <.styled_link href={Routes.auth_path(@conn, :verify_2fa_recovery_code_form)}> + Use a recovery code + +

+

+ Need help? + <.styled_link href="https://plausible.io/contact"> + Contact us + +

+
+ diff --git a/lib/plausible_web/templates/auth/verify_2fa_recovery_code.html.heex b/lib/plausible_web/templates/auth/verify_2fa_recovery_code.html.heex index 0c87266c5d88..99dde815cfdf 100644 --- a/lib/plausible_web/templates/auth/verify_2fa_recovery_code.html.heex +++ b/lib/plausible_web/templates/auth/verify_2fa_recovery_code.html.heex @@ -1,57 +1,54 @@ -<.focus_box> - <:title> - Enter Recovery Code - - - <:subtitle> - Can't access your authenticator application? Enter a recovery code instead. - - - <:footer> - Authenticator application working again? - <.styled_link href={Routes.auth_path(@conn, :verify_2fa)}> - Enter code - - <%= if ee?() do %> -
Lost your recovery codes? - <.styled_link href="https://plausible.io/contact"> - Contact us - - <% end %> - - +<.auth_container> <.form :let={f} for={@conn.params} action={Routes.auth_path(@conn, :verify_2fa_recovery_code)} + class="flex flex-col gap-y-6" onsubmit="document.getElementById('use-code-button').disabled = true" > -
-
- <.input - type="text" - field={f[:recovery_code]} - autocomplete="off" - maxlength="10" - oninvalid="document.getElementById('use-code-button').disabled = false" - required="required" - placeholder="Enter recovery code" - /> -
- <.button - id="use-code-button" - type="submit" - class="w-full mt-4 [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block" - > - - Use Code - - - - <.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" /> - Verifying... - - +
+ <.input + type="text" + field={f[:recovery_code]} + autocomplete="off" + autofocus="autofocus" + maxlength="10" + oninvalid="document.getElementById('use-code-button').disabled = false" + required="required" + placeholder="Recovery code" + mt?={false} + /> +

+ {@error} +

+ + <.button + id="use-code-button" + type="submit" + mt?={false} + class="w-full [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block" + > + + Verify + + + <.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" /> Verifying... + + - + +
+

+ <.styled_link href={Routes.auth_path(@conn, :verify_2fa)}> + Use authenticator app instead + +

+

+ Lost your recovery codes? + <.styled_link href="https://plausible.io/contact"> + Contact us + +

+
+ diff --git a/lib/plausible_web/templates/auth/verify_2fa_setup.html.heex b/lib/plausible_web/templates/auth/verify_2fa_setup.html.heex index 211b562ff199..7c9c35b59e39 100644 --- a/lib/plausible_web/templates/auth/verify_2fa_setup.html.heex +++ b/lib/plausible_web/templates/auth/verify_2fa_setup.html.heex @@ -31,6 +31,11 @@ id="verify-2fa-form" onsubmit="document.getElementById('verify-button').disabled = true" > - + diff --git a/lib/plausible_web/templates/layout/_header.html.heex b/lib/plausible_web/templates/layout/_header.html.heex index 18b6d12c94ce..d1a93f7a3fc3 100644 --- a/lib/plausible_web/templates/layout/_header.html.heex +++ b/lib/plausible_web/templates/layout/_header.html.heex @@ -3,28 +3,7 @@