auto-confirm admin during setup, skip email verification
Some checks failed
deploy / deploy (push) Has been cancelled

Setup wizard no longer requires email delivery. Admin account is
auto-confirmed and auto-logged-in via token redirect. Adds setup
secret gate for prod (logged on boot), SMTP env var config in
runtime.exs, email_configured? helper, and admin warning banner
when email isn't set up. Includes plan files for this task and
the follow-up email settings UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-21 10:24:26 +00:00
parent 8e818da651
commit 9d9bd09059
17 changed files with 776 additions and 291 deletions

View File

@@ -26,6 +26,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
socket
|> assign(:current_path, "")
|> assign(:site_live, Settings.site_live?())
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,

View File

@@ -18,6 +18,15 @@
</.link>
</header>
<%!-- email warning banner --%>
<div :if={!@email_configured} class="admin-banner-warning">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<p>
Email delivery not configured. Customers won't receive order confirmations.
Set <code>SMTP_HOST</code> to enable email.
</p>
</div>
<%!-- page content --%>
<main class="flex-1 p-4 sm:p-6 lg:p-8">
<div class="mx-auto max-w-5xl">

View File

@@ -0,0 +1,37 @@
defmodule BerrypodWeb.SetupController do
use BerrypodWeb, :controller
alias Berrypod.Accounts
alias BerrypodWeb.UserAuth
@doc """
Logs in a user via a setup login token.
The setup wizard generates a token after creating the admin account,
then redirects here to set the session cookie (LiveViews can't do that).
"""
def login(conn, %{"token" => token}) do
# Validate token first — login_user_by_magic_link crashes on invalid base64
if Accounts.get_user_by_magic_link_token(token) do
case Accounts.login_user_by_magic_link(token) do
{:ok, {user, tokens_to_disconnect}} ->
UserAuth.disconnect_sessions(tokens_to_disconnect)
conn
|> put_session(:user_return_to, ~p"/setup")
|> UserAuth.log_in_user(user)
_ ->
login_failed(conn)
end
else
login_failed(conn)
end
end
defp login_failed(conn) do
conn
|> put_flash(:error, "Login failed — please try again.")
|> redirect(to: ~p"/setup")
end
end

View File

@@ -20,13 +20,10 @@ defmodule BerrypodWeb.Setup.Onboarding do
{:ok, push_navigate(socket, to: ~p"/admin")}
setup.admin_created and is_nil(get_user(socket)) ->
{:ok, mount_check_inbox(socket)}
setup.admin_created ->
{:ok, mount_configure(socket, setup)}
{:ok, push_navigate(socket, to: ~p"/users/log-in")}
true ->
{:ok, mount_email_form(socket)}
{:ok, mount_setup(socket, setup)}
end
end
@@ -37,29 +34,21 @@ defmodule BerrypodWeb.Setup.Onboarding do
end
end
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
defp mount_setup(socket, setup) do
logged_in? = get_user(socket) != nil
provider_conn = Products.get_first_provider_connection()
socket
|> assign(:page_title, "Set up your shop")
|> assign(:phase, :configure)
|> assign(:setup, setup)
# Provider
|> assign(:logged_in?, logged_in?)
# Secret gate
|> assign(:require_secret?, Setup.require_setup_secret?())
|> assign(:secret_verified, false)
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
# Account (card 1)
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
# Provider (card 2)
|> assign(:providers, Provider.all())
|> assign(:selected_provider, nil)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
@@ -68,31 +57,38 @@ defmodule BerrypodWeb.Setup.Onboarding do
|> assign(:provider_connecting, false)
|> assign(:provider_conn, provider_conn)
|> assign(:pending_provider_key, nil)
# Stripe
# Stripe (card 3)
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
end
# ── Events: Account ──
# ── Events: Secret gate ──
@impl true
def handle_event("verify_secret", %{"secret" => %{"secret" => secret}}, socket) do
if Plug.Crypto.secure_compare(secret, Setup.setup_secret()) do
{:noreply, assign(socket, secret_verified: true)}
else
{:noreply, put_flash(socket, :error, "Wrong setup secret")}
end
end
# ── Events: Account ──
def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
if email == "" do
{:noreply, put_flash(socket, :error, "Please enter your email address")}
else
case Accounts.register_user(%{email: email}) do
case Accounts.register_and_confirm_admin(%{email: email}) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
token = Accounts.generate_login_token(user)
{:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
{:error, :admin_already_exists} ->
{:noreply,
socket
|> assign(:phase, :check_inbox)
|> assign(:admin_email, user.email)
|> assign(:local_mail?, local_mail_adapter?())}
|> put_flash(:error, "An admin account already exists")
|> push_navigate(to: ~p"/setup")}
{:error, changeset} ->
{:noreply,
@@ -103,26 +99,6 @@ 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
@@ -239,117 +215,106 @@ defmodule BerrypodWeb.Setup.Onboarding do
<div class="setup-page">
<div class="setup-header">
<h1 class="setup-title">Set up your shop</h1>
<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>
<%= if @require_secret? and not @secret_verified do %>
<p class="setup-subtitle">
Enter the setup secret from your server logs to get started.
</p>
<% else %>
<p class="setup-subtitle">
Connect your accounts to get going.
</p>
<% end %>
</div>
<%!-- 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>
<%!-- Secret gate (prod only, before admin exists) --%>
<%= if @require_secret? and not @secret_verified do %>
<div class="setup-sections">
<div class="setup-card">
<div class="setup-card-body">
<p class="setup-hint">
Find the setup secret in your server logs or set the <code>SETUP_SECRET</code>
environment variable.
</p>
<.form for={@secret_form} phx-submit="verify_secret">
<.input
field={@secret_form[:secret]}
type="password"
label="Setup secret"
autocomplete="off"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Checking...">Continue</.button>
</div>
</.form>
</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>
<% else %>
<%!-- All three setup cards --%>
<div class="setup-sections">
<.section_card
title="Create admin account"
number={1}
done={@logged_in?}
summary={account_summary(assigns)}
>
<p class="setup-hint">Enter your email to create the admin account.</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>
<%!-- Phase 3: configure (logged in) --%>
<div :if={@phase == :configure} class="setup-sections">
<.section_card
title="Create admin account"
number={1}
done={true}
summary={account_summary(assigns)}
>
<span />
</.section_card>
<.section_card
title="Connect a print provider"
number={2}
done={@setup.provider_connected}
summary={provider_summary(assigns)}
>
<.provider_section
providers={@providers}
selected={@selected_provider}
form={@provider_form}
testing={@provider_testing}
test_result={@provider_test_result}
connecting={@provider_connecting}
/>
</.section_card>
<.section_card
title="Connect a print provider"
number={2}
done={@setup.provider_connected}
summary={provider_summary(assigns)}
>
<.provider_section
providers={@providers}
selected={@selected_provider}
form={@provider_form}
testing={@provider_testing}
test_result={@provider_test_result}
connecting={@provider_connecting}
/>
</.section_card>
<.section_card
title="Connect payments"
number={3}
done={@setup.stripe_connected}
summary={stripe_summary(assigns)}
>
<.stripe_section
form={@stripe_form}
connecting={@stripe_connecting}
/>
</.section_card>
</div>
<.section_card
title="Connect payments"
number={3}
done={@setup.stripe_connected}
summary={stripe_summary(assigns)}
>
<.stripe_section
form={@stripe_form}
connecting={@stripe_connecting}
/>
</.section_card>
</div>
<%!-- All done --%>
<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>
<.link navigate={~p"/admin"} class="admin-btn admin-btn-primary">
Go to dashboard <span aria-hidden="true">&rarr;</span>
</.link>
</div>
<%!-- All done --%>
<div :if={@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>
<.link navigate={~p"/admin"} class="admin-btn admin-btn-primary">
Go to dashboard <span aria-hidden="true">&rarr;</span>
</.link>
</div>
<% end %>
</div>
"""
end
@@ -589,8 +554,4 @@ defmodule BerrypodWeb.Setup.Onboarding do
defp format_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_error(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed — check your token and try again"
defp local_mail_adapter? do
Application.get_env(:berrypod, Berrypod.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View File

@@ -146,6 +146,9 @@ defmodule BerrypodWeb.Router do
scope "/", BerrypodWeb do
pipe_through [:browser]
# Token-based auto-login after setup account creation
get "/setup/login/:token", SetupController, :login
live_session :setup,
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
live "/setup", Setup.Onboarding, :index