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.