From b05b6966817a46d6db2adaf8d1daf5b8c7101f47 Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 20 Feb 2026 21:07:07 +0000 Subject: [PATCH] rework setup wizard into phased flow 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 --- assets/css/admin/components.css | 29 +++ lib/berrypod/accounts.ex | 30 +++ lib/berrypod_web/live/setup/onboarding.ex | 185 +++++++++++------- test/berrypod/accounts_test.exs | 41 ++++ .../live/setup/onboarding_test.exs | 56 +++++- 5 files changed, 258 insertions(+), 83 deletions(-) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index b8297e1..9adbc42 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -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); diff --git a/lib/berrypod/accounts.ex b/lib/berrypod/accounts.ex index 9cacfef..c3abc08 100644 --- a/lib/berrypod/accounts.ex +++ b/lib/berrypod/accounts.ex @@ -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 """ diff --git a/lib/berrypod_web/live/setup/onboarding.ex b/lib/berrypod_web/live/setup/onboarding.ex index 3a0ba4f..93acffe 100644 --- a/lib/berrypod_web/live/setup/onboarding.ex +++ b/lib/berrypod_web/live/setup/onboarding.ex @@ -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

Set up your shop

-

Three quick steps to get everything connected.

+

+ Enter your email to create the admin account. +

+

+ Almost there — check your inbox. +

+

+ Connect your accounts to get going. +

-
- <%!-- Section 1: Account --%> + <%!-- Phase 1: email form --%> +
+ <.section_card title="Create admin account" number={1} done={false}> +

We'll send a login link to get you started.

+ <.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()} + /> +
+ <.button phx-disable-with="Creating account...">Create account +
+ + +
+ + <%!-- Phase 2: check inbox --%> +
+
+
+ <.icon name="hero-envelope" class="size-10 setup-inbox-icon" /> +

Check your inbox

+

+ We sent a login link to {@admin_email}. + Click it to continue setting up your shop. +

+ +
+ <.icon name="hero-information-circle" class="size-5 shrink-0" /> +
+

+ Using local mail adapter. + See sent emails at /dev/mailbox. +

+
+
+ +

+ Wrong email? + +

+
+
+
+ + <%!-- Phase 3: configure (logged in) --%> +
<.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?()} - /> + - <%!-- 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 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
<%!-- All done --%> -
+
<.icon name="hero-check-badge" class="setup-complete-icon" />

You're all set

Head to the dashboard to sync products, customise your theme, and go live.

@@ -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""" -
+
<%= 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""" -
- <.icon name="hero-envelope" class="size-5 shrink-0" /> -
-

Check your email

-

Click the link we sent to finish creating your account.

-
-
- -
- <.icon name="hero-information-circle" class="size-5 shrink-0" /> -
-

- Using local mail adapter. - See sent emails at /dev/mailbox. -

-
-
- -
-

Enter your email to create the admin account. We'll send a login link.

- - <.form for={@form} phx-submit="create_account"> - <.input - field={@form[:email]} - type="email" - label="Email address" - autocomplete="email" - required - phx-mounted={JS.focus()} - /> -
- <.button phx-disable-with="Creating account...">Create account -
- -
- """ - end - # ── Provider section ── attr :providers, :list, required: true diff --git a/test/berrypod/accounts_test.exs b/test/berrypod/accounts_test.exs index 603cb50..eee9605 100644 --- a/test/berrypod/accounts_test.exs +++ b/test/berrypod/accounts_test.exs @@ -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(%{}) diff --git a/test/berrypod_web/live/setup/onboarding_test.exs b/test/berrypod_web/live/setup/onboarding_test.exs index 68ccc22..a8ed861 100644 --- a/test/berrypod_web/live/setup/onboarding_test.exs +++ b/test/berrypod_web/live/setup/onboarding_test.exs @@ -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")