auto-confirm admin during setup, skip email verification
Some checks failed
deploy / deploy (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
37
lib/berrypod_web/controllers/setup_controller.ex
Normal file
37
lib/berrypod_web/controllers/setup_controller.ex
Normal 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
|
||||
@@ -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">→</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">→</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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user