Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions e2e/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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()
}
Expand Down
17 changes: 13 additions & 4 deletions extra/lib/plausible_web/controllers/sso_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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, _}} ->
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion extra/lib/plausible_web/sso/fake_saml_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
)
Expand Down
11 changes: 9 additions & 2 deletions extra/lib/plausible_web/sso/real_saml_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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
Expand Down
74 changes: 38 additions & 36 deletions extra/lib/plausible_web/templates/sso/login_form.html.heex
Original file line number Diff line number Diff line change
@@ -1,52 +1,54 @@
<.focus_box>
<:title>
{Phoenix.Flash.get(@flash, :login_title) || "Enter your Single Sign-On email"}
</:title>
<:subtitle>
<%= if Phoenix.Flash.get(@flash, :login_instructions) do %>
<p class="text-gray-500 mt-1 mb-2">
{Phoenix.Flash.get(@flash, :login_instructions)}
</p>
<% end %>
</:subtitle>
<.auth_container>
<script :if={@autosubmit}>
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("sso-login-form").submit();
});
</script>
<.form :let={f} id="sso-login-form" for={@conn} action={Routes.sso_path(@conn, :login)}>
<div class="my-4 mt-8">
<.input
type="email"
autocomplete="username"
placeholder="user@example.com"
field={f[:email]}
/>
</div>

<%= if login_error = Phoenix.Flash.get(@flash, :login_error) do %>
<div class="text-red-500 mt-4">{login_error}</div>
<% end %>
<.form
:let={f}
id="sso-login-form"
for={@conn}
action={Routes.sso_path(@conn, :login)}
class="flex flex-col gap-y-6"
>
<div class="flex flex-col gap-y-2">
<label for={f[:email].id} class="text-sm font-semibold text-gray-800 dark:text-gray-200">
Email
</label>
<div>
<.input
type="email"
autocomplete="username"
placeholder="example@email.com"
field={f[:email]}
mt?={false}
autofocus="autofocus"
/>
</div>
<p
:if={login_error = Phoenix.Flash.get(@flash, :login_error)}
class="text-xs text-red-500"
>
{login_error}
</p>
</div>

<.input type="hidden" field={f[:return_to]} />
<input type="hidden" name="return_to" value={@conn.params["return_to"]} />

<.button class="w-full" type="submit">Sign In</.button>
</.form>
<div class="flex flex-col gap-y-4">
<.button class="w-full" type="submit" mt?={false}>Sign in</.button>

<:footer>
<.focus_list>
<:item>
Have a standard account?
<p class="text-sm text-center text-gray-500 dark:text-gray-400">
<.styled_link href={
Routes.auth_path(@conn, :login_form,
return_to: @conn.params["return_to"],
prefer: "manual"
)
}>
Log in here
Use password instead
</.styled_link>
instead.
</:item>
</.focus_list>
</:footer>
</.focus_box>
</p>
</div>
</.form>
</.auth_container>
79 changes: 33 additions & 46 deletions extra/lib/plausible_web/templates/sso/provision_issue.html.heex
Original file line number Diff line number Diff line change
@@ -1,51 +1,38 @@
<.focus_box>
<:title>
Single Sign-On enforcement
</:title>
<:subtitle>
The owner of <span :if={is_nil(@conn.assigns[:current_team])}>the team</span>
<span :if={@conn.assigns[:current_team]}>"{@conn.assigns[:current_team].name}"</span>
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.
</:subtitle>

<p :if={@issue == :multiple_memberships} class="text-sm">
To access this team, you must first leave all other teams.
</p>

<p :if={@issue == :multiple_memberships_noforce} class="text-sm">
To log in as an SSO user, you must first leave all other teams.
</p>

<div :if={@issue == :active_personal_team} class="text-sm flex flex-col space-y-2">
<.auth_container>
<div class="flex flex-col items-center gap-y-4 text-center text-base text-gray-500 dark:text-gray-400 text-pretty">
<p>
To access this team, you must either remove or transfer all sites you own under "My Personal Sites".
The owner of
<span :if={is_nil(@conn.assigns[:current_team])}>the team</span><span
:if={@conn.assigns[:current_team]}
class="font-semibold"
>"{@conn.assigns[:current_team].name}"</span>
has turned off email and password logins, so you can only sign in with your organization's Single Sign-On.
<span :if={@issue == :not_a_member}>
To access this team, you must be added as a team member first.
</span>
<span :if={@issue == :multiple_memberships}>
To access this team, you must leave all other teams first.
</span>
<span :if={@issue == :multiple_memberships_noforce}>
To sign in with SSO, you must leave all other teams first.
</span>
<span :if={@issue == :active_personal_team}>
To access this team, remove or transfer all sites under
<span class="font-semibold">"My Personal Sites"</span>
and cancel its subscription if active.
</span>
<span :if={@issue == :active_personal_team_noforce}>
To sign in with SSO, remove or transfer all sites under
<span class="font-semibold">"My Personal Sites"</span>
and cancel its subscription if active.
</span>
</p>

<p>
You also have to cancel subscription for "My Personal Sites" if there is an active one.
<p :if={!@conn.assigns[:current_user]}>
<.styled_link href={Routes.auth_path(@conn, :login_form, prefer: "manual")}>
Sign in with email and password
</.styled_link>
to resolve the issue.
</p>
</div>

<div :if={@issue == :active_personal_team_noforce} class="text-sm flex flex-col space-y-2">
<p>
To log in as an SSO user, you must either remove or transfer all sites you own under "My Personal Sites".
</p>

<p>
You also have to cancel subscription on "My Personal Sites" if there is an active one.
</p>
</div>

<p :if={@issue == :not_a_member} class="text-sm">
To access this team, you must join as a team member first.
</p>

<p :if={!@conn.assigns[:current_user]} class="text-sm mt-4">
<.styled_link href={Routes.auth_path(@conn, :login_form, prefer: "manual")}>
Log in
</.styled_link>
with your email and password to resolve the issue.
</p>
</.focus_box>
</.auth_container>
34 changes: 17 additions & 17 deletions extra/lib/plausible_web/templates/sso/provision_notice.html.heex
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<.focus_box>
<:title>
Single Sign-On enforcement
</:title>
<:subtitle>
The owner of <span :if={is_nil(@conn.assigns[:current_team])}>the team</span>
<span :if={@conn.assigns[:current_team]}>"{@conn.assigns[:current_team].name}"</span>
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.
</:subtitle>

<p class="text-sm">
To access this team, you must first <.styled_link href="/logout">log out</.styled_link>
and log in as SSO user.
</p>
</.focus_box>
<.auth_container>
<div class="flex flex-col items-center gap-y-4 text-center text-base text-gray-500 dark:text-gray-400 text-pretty">
<p>
The owner of
<span :if={is_nil(@conn.assigns[:current_team])}>the team</span><span
:if={@conn.assigns[:current_team]}
class="font-semibold"
>"{@conn.assigns[:current_team].name}"</span>
has turned off email and password logins, so you can only sign in with your organization's Single Sign-On.
</p>
<p>
To access this team,
<.styled_link href={Routes.auth_path(@conn, :logout)}>sign out</.styled_link>
and sign in with SSO.
</p>
</div>
</.auth_container>
5 changes: 3 additions & 2 deletions lib/plausible/auth/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions lib/plausible_web/components/generic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<div class={["w-full max-w-md mx-auto mt-10 pb-16 px-4", @class]}>
{render_slot(@inner_block)}
</div>
"""
end

attr(:type, :string, default: "button")
attr(:theme, :string, default: "primary")
attr(:size, :string, default: "md")
Expand Down
22 changes: 22 additions & 0 deletions lib/plausible_web/components/layout.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<img
src={logo_url("logo_dark.svg")}
class={[@class, "hidden dark:inline"]}
alt="Plausible logo"
loading="lazy"
/>
<img
src={logo_url("logo_light.svg")}
class={[@class, "inline dark:hidden"]}
alt="Plausible logo"
loading="lazy"
/>
"""
end

defp logo_url(filename),
do: PlausibleWeb.Router.Helpers.static_path(PlausibleWeb.Endpoint, logo_path(filename))

def favicon(assigns) do
~H"""
<link
Expand Down
Loading
Loading