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

@@ -69,36 +69,6 @@ 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 """
@@ -119,6 +89,37 @@ defmodule Berrypod.Accounts do
|> Repo.insert()
end
@doc """
Registers and immediately confirms the admin user during setup.
Checks `has_admin?/0` inside a transaction to prevent race conditions.
Returns `{:error, :admin_already_exists}` if an admin already exists.
"""
def register_and_confirm_admin(attrs) do
Repo.transact(fn ->
if has_admin?() do
{:error, :admin_already_exists}
else
%User{}
|> User.email_changeset(attrs)
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|> Repo.insert()
end
end)
end
@doc """
Generates a login token for a user without sending an email.
Used by the setup flow to create a token for the auto-login redirect.
Returns the URL-safe encoded token.
"""
def generate_login_token(%User{} = user) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
encoded_token
end
## Settings
@doc """

View File

@@ -31,7 +31,23 @@ defmodule Berrypod.Application do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Berrypod.Supervisor]
Supervisor.start_link(children, opts)
result = Supervisor.start_link(children, opts)
log_setup_secret_if_needed()
result
end
defp log_setup_secret_if_needed do
if Application.get_env(:berrypod, :env) != :test do
unless Berrypod.Accounts.has_admin?() do
secret = Berrypod.Setup.setup_secret()
require Logger
Logger.info("Setup secret: #{secret} — visit /setup to create your admin account")
end
end
end
# Tell Phoenix to update the endpoint configuration

View File

@@ -1,3 +1,14 @@
defmodule Berrypod.Mailer do
use Swoosh.Mailer, otp_app: :berrypod
@doc """
Returns whether a real email adapter is configured.
True when the adapter is anything other than `Swoosh.Adapters.Local`
(which just stores emails in memory for dev use).
"""
def email_configured? do
adapter = Application.get_env(:berrypod, __MODULE__)[:adapter]
adapter != nil and adapter != Swoosh.Adapters.Local
end
end

View File

@@ -5,6 +5,47 @@ defmodule Berrypod.Setup do
alias Berrypod.{Accounts, Orders, Products, Settings}
@setup_secret_key :berrypod_setup_secret
@doc """
Returns the setup secret for first-run admin creation.
In prod, gates access to the setup form so only someone with server access
(log output or SETUP_SECRET env var) can create the admin account.
Reads from SETUP_SECRET env var first, otherwise auto-generates a random
token and stores it in :persistent_term for the lifetime of the node.
"""
def setup_secret do
case System.get_env("SETUP_SECRET") do
nil ->
case :persistent_term.get(@setup_secret_key, nil) do
nil ->
secret =
:crypto.strong_rand_bytes(16)
|> Base.url_encode64(padding: false)
:persistent_term.put(@setup_secret_key, secret)
secret
secret ->
secret
end
secret ->
secret
end
end
@doc """
Returns whether the setup secret gate should be shown.
True in prod when no admin exists. False in dev/test.
"""
def require_setup_secret? do
Application.get_env(:berrypod, :env, :dev) == :prod and not Accounts.has_admin?()
end
@doc """
Returns a map describing the current setup status.

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