rework setup wizard into phased flow
All checks were successful
deploy / deploy (push) Successful in 3m30s

phase 1 (no admin): show only the email form
phase 2 (admin created, not logged in): "check your inbox" gate with
  "wrong email? start over" link that deletes the unconfirmed user
phase 3 (logged in via magic link): show provider + stripe steps

removes the confusing redirect to /users/log-in after account creation.
users now stay on /setup throughout the entire setup process.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-20 21:07:07 +00:00
parent 27f4d45416
commit b05b696681
5 changed files with 258 additions and 83 deletions

View File

@ -740,6 +740,35 @@
padding-left: 2.5rem; padding-left: 2.5rem;
} }
/* Setup: check inbox phase */
.setup-check-inbox {
text-align: center;
padding: 1rem 0;
}
.setup-inbox-icon {
color: var(--color-base-content, #171717);
margin: 0 auto 0.75rem;
}
.setup-inbox-heading {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.setup-inbox-detail {
font-size: 0.875rem;
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
margin-bottom: 1rem;
}
.setup-start-over {
font-size: 0.8125rem;
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
margin-top: 1rem;
}
.setup-hint { .setup-hint {
font-size: 0.8125rem; font-size: 0.8125rem;
color: color-mix(in oklch, var(--color-base-content) 60%, transparent); color: color-mix(in oklch, var(--color-base-content) 60%, transparent);

View File

@ -69,6 +69,36 @@ defmodule Berrypod.Accounts do
Repo.exists?(User) Repo.exists?(User)
end end
@doc """
Returns the email of the admin user, or nil if no user exists.
"""
def admin_email do
Repo.one(from u in User, select: u.email, limit: 1)
end
@doc """
Returns the unconfirmed admin user, or nil.
Used by the setup wizard to allow "start over" before the magic link is clicked.
"""
def get_unconfirmed_admin do
Repo.one(from u in User, where: is_nil(u.confirmed_at), limit: 1)
end
@doc """
Deletes an unconfirmed user (confirmed_at is nil).
Returns `{:error, :already_confirmed}` if the user has already confirmed,
preventing accidental deletion of an active admin account.
"""
def delete_unconfirmed_user(%User{confirmed_at: nil} = user) do
Repo.delete(user)
end
def delete_unconfirmed_user(%User{}) do
{:error, :already_confirmed}
end
## User registration ## User registration
@doc """ @doc """

View File

@ -20,10 +20,13 @@ defmodule BerrypodWeb.Setup.Onboarding do
{:ok, push_navigate(socket, to: ~p"/admin")} {:ok, push_navigate(socket, to: ~p"/admin")}
setup.admin_created and is_nil(get_user(socket)) -> setup.admin_created and is_nil(get_user(socket)) ->
{:ok, push_navigate(socket, to: ~p"/users/log-in")} {:ok, mount_check_inbox(socket)}
setup.admin_created ->
{:ok, mount_configure(socket, setup)}
true -> true ->
{:ok, mount_setup(socket, setup)} {:ok, mount_email_form(socket)}
end end
end end
@ -34,15 +37,28 @@ defmodule BerrypodWeb.Setup.Onboarding do
end end
end end
defp mount_setup(socket, setup) do defp mount_email_form(socket) do
socket
|> assign(:page_title, "Set up your shop")
|> assign(:phase, :email_form)
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
end
defp mount_check_inbox(socket) do
socket
|> assign(:page_title, "Set up your shop")
|> assign(:phase, :check_inbox)
|> assign(:admin_email, Accounts.admin_email())
|> assign(:local_mail?, local_mail_adapter?())
end
defp mount_configure(socket, setup) do
provider_conn = Products.get_first_provider_connection() provider_conn = Products.get_first_provider_connection()
socket socket
|> assign(:page_title, "Set up your shop") |> assign(:page_title, "Set up your shop")
|> assign(:phase, :configure)
|> assign(:setup, setup) |> assign(:setup, setup)
# Account
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|> assign(:account_submitted, false)
# Provider # Provider
|> assign(:providers, Provider.all()) |> assign(:providers, Provider.all())
|> assign(:selected_provider, nil) |> assign(:selected_provider, nil)
@ -72,13 +88,11 @@ defmodule BerrypodWeb.Setup.Onboarding do
&url(~p"/users/log-in/#{&1}") &url(~p"/users/log-in/#{&1}")
) )
setup = %{socket.assigns.setup | admin_created: true}
{:noreply, {:noreply,
socket socket
|> assign(:setup, setup) |> assign(:phase, :check_inbox)
|> assign(:account_submitted, true) |> assign(:admin_email, user.email)
|> put_flash(:info, "Check your email for a login link")} |> assign(:local_mail?, local_mail_adapter?())}
{:error, changeset} -> {:error, changeset} ->
{:noreply, {:noreply,
@ -89,6 +103,26 @@ defmodule BerrypodWeb.Setup.Onboarding do
end end
end end
def handle_event("start_over", _params, socket) do
case Accounts.get_unconfirmed_admin() do
%Accounts.User{} = user ->
case Accounts.delete_unconfirmed_user(user) do
{:ok, _} ->
{:noreply,
socket
|> assign(:phase, :email_form)
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|> clear_flash()}
{:error, :already_confirmed} ->
{:noreply, push_navigate(socket, to: ~p"/setup")}
end
nil ->
{:noreply, push_navigate(socket, to: ~p"/setup")}
end
end
# ── Events: Provider ── # ── Events: Provider ──
def handle_event("select_provider", %{"type" => type}, socket) do def handle_event("select_provider", %{"type" => type}, socket) do
@ -205,32 +239,84 @@ defmodule BerrypodWeb.Setup.Onboarding do
<div class="setup-page"> <div class="setup-page">
<div class="setup-header"> <div class="setup-header">
<h1 class="setup-title">Set up your shop</h1> <h1 class="setup-title">Set up your shop</h1>
<p class="setup-subtitle">Three quick steps to get everything connected.</p> <p :if={@phase == :email_form} class="setup-subtitle">
Enter your email to create the admin account.
</p>
<p :if={@phase == :check_inbox} class="setup-subtitle">
Almost there check your inbox.
</p>
<p :if={@phase == :configure} class="setup-subtitle">
Connect your accounts to get going.
</p>
</div> </div>
<div class="setup-sections"> <%!-- Phase 1: email form --%>
<%!-- Section 1: Account --%> <div :if={@phase == :email_form} class="setup-sections">
<.section_card title="Create admin account" number={1} done={false}>
<p class="setup-hint">We'll send a login link to get you started.</p>
<.form for={@account_form} phx-submit="create_account">
<.input
field={@account_form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Creating account...">Create account</.button>
</div>
</.form>
</.section_card>
</div>
<%!-- Phase 2: check inbox --%>
<div :if={@phase == :check_inbox} class="setup-sections">
<div class="setup-card">
<div class="setup-check-inbox">
<.icon name="hero-envelope" class="size-10 setup-inbox-icon" />
<h2 class="setup-inbox-heading">Check your inbox</h2>
<p class="setup-inbox-detail">
We sent a login link to <strong>{@admin_email}</strong>.
Click it to continue setting up your shop.
</p>
<div :if={@local_mail?} class="admin-alert admin-alert-info">
<.icon name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p>
Using local mail adapter.
See sent emails at <a href="/dev/mailbox" class="underline">/dev/mailbox</a>.
</p>
</div>
</div>
<p class="setup-start-over">
Wrong email?
<button type="button" phx-click="start_over" class="setup-link">
Start over
</button>
</p>
</div>
</div>
</div>
<%!-- Phase 3: configure (logged in) --%>
<div :if={@phase == :configure} class="setup-sections">
<.section_card <.section_card
title="Create admin account" title="Create admin account"
number={1} number={1}
done={@setup.admin_created} done={true}
summary={account_summary(assigns)} summary={account_summary(assigns)}
hidden={false}
> >
<.account_section <span />
form={@account_form}
submitted={@account_submitted}
local_mail?={local_mail_adapter?()}
/>
</.section_card> </.section_card>
<%!-- Section 2: Provider --%>
<.section_card <.section_card
title="Connect a print provider" title="Connect a print provider"
number={2} number={2}
done={@setup.provider_connected} done={@setup.provider_connected}
summary={provider_summary(assigns)} summary={provider_summary(assigns)}
hidden={false}
> >
<.provider_section <.provider_section
providers={@providers} providers={@providers}
@ -242,13 +328,11 @@ defmodule BerrypodWeb.Setup.Onboarding do
/> />
</.section_card> </.section_card>
<%!-- Section 3: Payments --%>
<.section_card <.section_card
title="Connect payments" title="Connect payments"
number={3} number={3}
done={@setup.stripe_connected} done={@setup.stripe_connected}
summary={stripe_summary(assigns)} summary={stripe_summary(assigns)}
hidden={false}
> >
<.stripe_section <.stripe_section
form={@stripe_form} form={@stripe_form}
@ -258,7 +342,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
</div> </div>
<%!-- All done --%> <%!-- All done --%>
<div :if={@setup.setup_complete} class="setup-complete"> <div :if={@phase == :configure and @setup.setup_complete} class="setup-complete">
<.icon name="hero-check-badge" class="setup-complete-icon" /> <.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>You're all set</h2> <h2>You're all set</h2>
<p>Head to the dashboard to sync products, customise your theme, and go live.</p> <p>Head to the dashboard to sync products, customise your theme, and go live.</p>
@ -276,13 +360,12 @@ defmodule BerrypodWeb.Setup.Onboarding do
attr :number, :integer, required: true attr :number, :integer, required: true
attr :done, :boolean, required: true attr :done, :boolean, required: true
attr :summary, :string, default: nil attr :summary, :string, default: nil
attr :hidden, :boolean, default: false
slot :inner_block, required: true slot :inner_block, required: true
defp section_card(assigns) do defp section_card(assigns) do
~H""" ~H"""
<div :if={!@hidden} class={["setup-card", @done && "setup-card-done"]}> <div class={["setup-card", @done && "setup-card-done"]}>
<div class="setup-card-header"> <div class="setup-card-header">
<span class={["setup-card-number", @done && "setup-card-number-done"]}> <span class={["setup-card-number", @done && "setup-card-number-done"]}>
<%= if @done do %> <%= if @done do %>
@ -305,52 +388,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
""" """
end end
# ── Account section ──
attr :form, :any, required: true
attr :submitted, :boolean, required: true
attr :local_mail?, :boolean, required: true
defp account_section(assigns) do
~H"""
<div :if={@submitted} class="admin-alert admin-alert-info">
<.icon name="hero-envelope" class="size-5 shrink-0" />
<div>
<p><strong>Check your email</strong></p>
<p>Click the link we sent to finish creating your account.</p>
</div>
</div>
<div :if={@local_mail? and @submitted} class="admin-alert admin-alert-info">
<.icon name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p>
Using local mail adapter.
See sent emails at <a href="/dev/mailbox" class="underline">/dev/mailbox</a>.
</p>
</div>
</div>
<div :if={!@submitted}>
<p class="setup-hint">Enter your email to create the admin account. We'll send a login link.</p>
<.form for={@form} phx-submit="create_account">
<.input
field={@form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Creating account...">Create account</.button>
</div>
</.form>
</div>
"""
end
# ── Provider section ── # ── Provider section ──
attr :providers, :list, required: true attr :providers, :list, required: true

View File

@ -64,6 +64,47 @@ defmodule Berrypod.AccountsTest do
end end
end end
describe "admin_email/0" do
test "returns nil when no users exist" do
assert is_nil(Accounts.admin_email())
end
test "returns the admin email" do
user = user_fixture()
assert Accounts.admin_email() == user.email
end
end
describe "get_unconfirmed_admin/0" do
test "returns nil when no users exist" do
assert is_nil(Accounts.get_unconfirmed_admin())
end
test "returns unconfirmed user" do
user = unconfirmed_user_fixture()
assert Accounts.get_unconfirmed_admin().id == user.id
end
test "returns nil for confirmed user" do
_user = user_fixture()
assert is_nil(Accounts.get_unconfirmed_admin())
end
end
describe "delete_unconfirmed_user/1" do
test "deletes an unconfirmed user" do
user = unconfirmed_user_fixture()
assert {:ok, _} = Accounts.delete_unconfirmed_user(user)
refute Accounts.has_admin?()
end
test "refuses to delete a confirmed user" do
user = user_fixture()
assert {:error, :already_confirmed} = Accounts.delete_unconfirmed_user(user)
assert Accounts.has_admin?()
end
end
describe "register_user/1" do describe "register_user/1" do
test "requires email to be set" do test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{}) {:error, changeset} = Accounts.register_user(%{})

View File

@ -4,7 +4,7 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures import Berrypod.AccountsFixtures
alias Berrypod.{Products, Settings} alias Berrypod.{Accounts, Products, Settings}
describe "access rules" do describe "access rules" do
test "accessible on fresh install (no admin)", %{conn: conn} do test "accessible on fresh install (no admin)", %{conn: conn} do
@ -30,11 +30,12 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
assert {:live_redirect, %{to: "/admin"}} = redirect assert {:live_redirect, %{to: "/admin"}} = redirect
end end
test "redirects to login when admin exists but not logged in", %{conn: conn} do test "shows check inbox when admin exists but not logged in", %{conn: conn} do
_user = user_fixture() _user = unconfirmed_user_fixture()
{:error, redirect} = live(conn, ~p"/setup") {:ok, _view, html} = live(conn, ~p"/setup")
assert {:live_redirect, %{to: "/users/log-in"}} = redirect assert html =~ "Check your inbox"
refute html =~ "Connect a print provider"
end end
test "redirects to / when site is already live", %{conn: conn} do test "redirects to / when site is already live", %{conn: conn} do
@ -56,11 +57,50 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
end end
end end
describe "sections" do describe "phase: email form" do
test "shows all three sections on fresh install", %{conn: conn} do test "shows only email form on fresh install", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup") {:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ "Create admin account" assert html =~ "Create admin account"
refute html =~ "Connect a print provider"
refute html =~ "Connect payments"
end
end
describe "phase: check inbox" do
test "shows check inbox after creating account", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/setup")
html =
view
|> form("form", account: %{email: "admin@example.com"})
|> render_submit()
assert html =~ "Check your inbox"
assert html =~ "admin@example.com"
refute html =~ "Connect a print provider"
end
test "start over resets to email form", %{conn: conn} do
_user = unconfirmed_user_fixture()
{:ok, view, _html} = live(conn, ~p"/setup")
html = render_click(view, "start_over")
assert html =~ "Create admin account"
refute html =~ "Check your inbox"
refute Accounts.has_admin?()
end
end
describe "phase: configure (logged in)" do
setup :register_and_log_in_user
test "shows provider and stripe steps", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ user.email
assert html =~ "Connect a print provider" assert html =~ "Connect a print provider"
assert html =~ "Connect payments" assert html =~ "Connect payments"
end end
@ -85,9 +125,7 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
assert html =~ "API token" assert html =~ "API token"
assert html =~ "Printify" assert html =~ "Printify"
end end
end
describe "stripe section" do
test "shows stripe form", %{conn: conn} do test "shows stripe form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup") {:ok, _view, html} = live(conn, ~p"/setup")