rework setup wizard into phased flow
All checks were successful
deploy / deploy (push) Successful in 3m30s
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:
parent
27f4d45416
commit
b05b696681
@ -740,6 +740,35 @@
|
||||
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 {
|
||||
font-size: 0.8125rem;
|
||||
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
||||
|
||||
@ -69,6 +69,36 @@ defmodule Berrypod.Accounts do
|
||||
Repo.exists?(User)
|
||||
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
|
||||
|
||||
@doc """
|
||||
|
||||
@ -20,10 +20,13 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
{:ok, push_navigate(socket, to: ~p"/admin")}
|
||||
|
||||
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 ->
|
||||
{:ok, mount_setup(socket, setup)}
|
||||
{:ok, mount_email_form(socket)}
|
||||
end
|
||||
end
|
||||
|
||||
@ -34,15 +37,28 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
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()
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Set up your shop")
|
||||
|> assign(:phase, :configure)
|
||||
|> assign(:setup, setup)
|
||||
# Account
|
||||
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|
||||
|> assign(:account_submitted, false)
|
||||
# Provider
|
||||
|> assign(:providers, Provider.all())
|
||||
|> assign(:selected_provider, nil)
|
||||
@ -72,13 +88,11 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
|
||||
setup = %{socket.assigns.setup | admin_created: true}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:setup, setup)
|
||||
|> assign(:account_submitted, true)
|
||||
|> put_flash(:info, "Check your email for a login link")}
|
||||
|> assign(:phase, :check_inbox)
|
||||
|> assign(:admin_email, user.email)
|
||||
|> assign(:local_mail?, local_mail_adapter?())}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply,
|
||||
@ -89,6 +103,26 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
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 ──
|
||||
|
||||
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-header">
|
||||
<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 class="setup-sections">
|
||||
<%!-- Section 1: Account --%>
|
||||
<%!-- Phase 1: email form --%>
|
||||
<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
|
||||
title="Create admin account"
|
||||
number={1}
|
||||
done={@setup.admin_created}
|
||||
done={true}
|
||||
summary={account_summary(assigns)}
|
||||
hidden={false}
|
||||
>
|
||||
<.account_section
|
||||
form={@account_form}
|
||||
submitted={@account_submitted}
|
||||
local_mail?={local_mail_adapter?()}
|
||||
/>
|
||||
<span />
|
||||
</.section_card>
|
||||
|
||||
<%!-- Section 2: Provider --%>
|
||||
<.section_card
|
||||
title="Connect a print provider"
|
||||
number={2}
|
||||
done={@setup.provider_connected}
|
||||
summary={provider_summary(assigns)}
|
||||
hidden={false}
|
||||
>
|
||||
<.provider_section
|
||||
providers={@providers}
|
||||
@ -242,13 +328,11 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
/>
|
||||
</.section_card>
|
||||
|
||||
<%!-- Section 3: Payments --%>
|
||||
<.section_card
|
||||
title="Connect payments"
|
||||
number={3}
|
||||
done={@setup.stripe_connected}
|
||||
summary={stripe_summary(assigns)}
|
||||
hidden={false}
|
||||
>
|
||||
<.stripe_section
|
||||
form={@stripe_form}
|
||||
@ -258,7 +342,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
</div>
|
||||
|
||||
<%!-- 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" />
|
||||
<h2>You're all set</h2>
|
||||
<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 :done, :boolean, required: true
|
||||
attr :summary, :string, default: nil
|
||||
attr :hidden, :boolean, default: false
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp section_card(assigns) do
|
||||
~H"""
|
||||
<div :if={!@hidden} class={["setup-card", @done && "setup-card-done"]}>
|
||||
<div class={["setup-card", @done && "setup-card-done"]}>
|
||||
<div class="setup-card-header">
|
||||
<span class={["setup-card-number", @done && "setup-card-number-done"]}>
|
||||
<%= if @done do %>
|
||||
@ -305,52 +388,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
|
||||
"""
|
||||
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 ──
|
||||
|
||||
attr :providers, :list, required: true
|
||||
|
||||
@ -64,6 +64,47 @@ defmodule Berrypod.AccountsTest do
|
||||
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
|
||||
test "requires email to be set" do
|
||||
{:error, changeset} = Accounts.register_user(%{})
|
||||
|
||||
@ -4,7 +4,7 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
|
||||
alias Berrypod.{Products, Settings}
|
||||
alias Berrypod.{Accounts, Products, Settings}
|
||||
|
||||
describe "access rules" 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
|
||||
end
|
||||
|
||||
test "redirects to login when admin exists but not logged in", %{conn: conn} do
|
||||
_user = user_fixture()
|
||||
test "shows check inbox when admin exists but not logged in", %{conn: conn} do
|
||||
_user = unconfirmed_user_fixture()
|
||||
|
||||
{:error, redirect} = live(conn, ~p"/setup")
|
||||
assert {:live_redirect, %{to: "/users/log-in"}} = redirect
|
||||
{:ok, _view, html} = live(conn, ~p"/setup")
|
||||
assert html =~ "Check your inbox"
|
||||
refute html =~ "Connect a print provider"
|
||||
end
|
||||
|
||||
test "redirects to / when site is already live", %{conn: conn} do
|
||||
@ -56,11 +57,50 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "sections" do
|
||||
test "shows all three sections on fresh install", %{conn: conn} do
|
||||
describe "phase: email form" do
|
||||
test "shows only email form on fresh install", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/setup")
|
||||
|
||||
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 payments"
|
||||
end
|
||||
@ -85,9 +125,7 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
|
||||
assert html =~ "API token"
|
||||
assert html =~ "Printify"
|
||||
end
|
||||
end
|
||||
|
||||
describe "stripe section" do
|
||||
test "shows stripe form", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/setup")
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user