berrypod/docs/plans/setup-auto-confirm.md
jamey 508695b852
All checks were successful
deploy / deploy (push) Successful in 42s
mark setup-auto-confirm plan as complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:24:42 +00:00

8.8 KiB

Setup wizard: auto-confirm admin, setup secret gate

Status: Complete

Companion plan: email-settings.md (admin UI for email config)

Context

The setup wizard requires email verification (magic link) to create the admin account. This doesn't work for self-hosted users without email configured. The fix: auto-confirm and auto-login the first admin during setup, gated by a setup secret in prod. Also adds env var SMTP support and a warning banner when email isn't configured.

UX flow

Screen states

Context What they see
Prod, no admin, no secret entered Secret gate only (single input + submit)
Prod, no admin, secret verified All 3 cards (card 1 active with email field)
Dev, no admin All 3 cards (card 1 active, no secret gate)
Admin exists, not logged in Redirect to /users/log-in
Logged in, setup incomplete All 3 cards (card 1 done with checkmark, cards 2+3 active)
Setup complete Redirect to /admin
Site live Redirect to /

Prod first-run flow

  1. Server logs: Setup secret: abc123-def456 — visit /setup to create your admin account
  2. Visit /setup -> see only a setup secret input
  3. Enter correct secret -> LiveView assigns secret_verified: true -> template reveals all 3 cards
  4. Card 1 "Create admin account" has email field -> submit -> account created, auto-confirmed, token generated, redirect to /setup/login/:token -> controller sets session cookie -> redirect back to /setup -> card 1 shows done, cards 2+3 active

Dev first-run flow

  1. Visit /setup -> all 3 cards visible immediately (no secret gate)
  2. Card 1 has email field -> submit -> same auto-confirm + redirect flow

Changes

1. Setup secret (lib/berrypod/setup.ex)

Add to existing file:

  • setup_secret/0 — returns SETUP_SECRET env var, or auto-generates via :crypto.strong_rand_bytes/1 on first call and stores in :persistent_term. Not stored in DB (bootstrap credential needed before admin exists).
  • require_setup_secret?/0true in prod when no admin exists. false in dev.

2. Log setup secret on boot (lib/berrypod/application.ex)

In start/2, after children are started: if no admin exists, call Setup.setup_secret/0 and log it:

[info] Setup secret: <token> — visit /setup to create your admin account

3. Auto-confirm admin registration (lib/berrypod/accounts.ex)

Add register_and_confirm_admin/1:

  • Takes %{email: email} attrs
  • Inside Repo.transaction:
    1. Check Setup.has_admin?() — if true, rollback with :admin_already_exists
    2. Build user changeset with confirmed_at: DateTime.utc_now()
    3. Insert user
    4. Return {:ok, user}

Remove dead functions (only used by old check_inbox flow): admin_email/0, get_unconfirmed_admin/0, delete_unconfirmed_user/1

4. Setup login controller (lib/berrypod_web/controllers/setup_controller.ex — new)

Needed because LiveViews can't set session cookies.

  • login/2 action:
    1. Decode token, call Accounts.login_user_by_magic_link/1 to consume it
    2. On success: UserAuth.log_in_user(conn, user) (creates session, redirects to /setup)
    3. On failure: redirect to /setup with error flash

5. Route (lib/berrypod_web/router.ex)

Add in a :browser scope (outside any live_session):

get "/setup/login/:token", SetupController, :login

6. Rewrite onboarding LiveView (lib/berrypod_web/live/setup/onboarding.ex)

Mount logic:

cond do
  setup.site_live -> redirect /
  setup.setup_complete -> redirect /admin
  setup.admin_created and not logged_in -> redirect /users/log-in
  true -> mount setup page
end

Assigns on mount:

  • require_secret? from Setup.require_setup_secret?/0
  • secret_verified: false
  • secret_form (simple form for the gate)
  • email_form (for card 1)
  • All existing configure-phase assigns

Events:

  • verify_secret — validates against Setup.setup_secret/0, sets secret_verified: true or flashes error
  • create_account — validates email, calls register_and_confirm_admin/1, generates login token (reuses UserToken.build_email_token/2 pattern but without sending email), redirects to /setup/login/:token

Template structure:

  • If require_secret? and not secret_verified: show secret gate only
  • Otherwise: show all 3 cards. Card 1 state depends on logged_in?

Remove: :email_form and :check_inbox phases, mount_email_form/1, mount_check_inbox/1, start_over event, local_mail_adapter?/0

7. SMTP env var support (config/runtime.exs)

Conditional SMTP adapter config (consumed at boot):

if smtp_host = System.get_env("SMTP_HOST") do
  config :berrypod, Berrypod.Mailer,
    adapter: Swoosh.Adapters.SMTP,
    relay: smtp_host,
    port: String.to_integer(System.get_env("SMTP_PORT") || "587"),
    username: System.get_env("SMTP_USERNAME"),
    password: System.get_env("SMTP_PASSWORD"),
    tls: :if_available
end

This is a simple fallback for users who want env-based config. The companion plan (email-settings.md) adds a full admin UI with multi-adapter support.

8. Email configured helper (lib/berrypod/mailer.ex)

Add email_configured?/0 — returns true if the adapter is anything other than Swoosh.Adapters.Local.

9. Admin warning banner

lib/berrypod_web/admin_layout_hook.ex:

  • Add email_configured assign via Berrypod.Mailer.email_configured?/0

lib/berrypod_web/components/layouts/admin.html.heex:

  • Warning banner above {@inner_content} when !@email_configured: "Email delivery not configured. Customers won't receive order confirmations. Set SMTP_HOST to enable email."
  • Later (after email-settings plan): this links to the email settings page.

assets/css/admin/components.css:

  • .admin-banner-warning style (amber background, icon)

Check that the /dev/mailbox link is already conditional on Swoosh.Adapters.Local. No changes expected — just verify.

11. Tests

test/berrypod/accounts_test.exs:

  • Add tests for register_and_confirm_admin/1 (success, admin already exists)
  • Remove tests for deleted functions

test/berrypod_web/live/setup/onboarding_test.exs:

  • Remove: check_inbox tests, start_over test
  • Add: secret gate rejects wrong secret
  • Add: secret gate accepts correct secret
  • Add: creating account auto-confirms and redirects
  • Add: admin exists but not logged in redirects to login page

test/berrypod_web/controllers/setup_controller_test.exs (new):

  • Valid token logs in and redirects to /setup
  • Invalid/expired token redirects with error flash

Files to modify

File Change
lib/berrypod/setup.ex Add setup_secret/0, require_setup_secret?/0
lib/berrypod/application.ex Log setup secret on boot
lib/berrypod/accounts.ex Add register_and_confirm_admin/1, remove 3 dead fns
lib/berrypod/mailer.ex Add email_configured?/0
lib/berrypod_web/live/setup/onboarding.ex Remove check_inbox, add secret gate + auto-confirm
lib/berrypod_web/controllers/setup_controller.ex New — session creation endpoint
lib/berrypod_web/router.ex Add setup login route
lib/berrypod_web/admin_layout_hook.ex Add email_configured assign
lib/berrypod_web/components/layouts/admin.html.heex Warning banner
assets/css/admin/components.css .admin-banner-warning style
config/runtime.exs Conditional SMTP config
Tests (3 files) Update for new flow

Security notes

  • Setup secret prevents unauthorized admin creation on fresh instances. Only someone with server log access or the SETUP_SECRET env var can proceed.
  • Not stored in DB — it's a bootstrap credential needed before the admin exists. :persistent_term for auto-generated, env var for explicit.
  • Skipped in dev — no friction during local development.
  • Skipped when logged in — hosted platform users are already authenticated.
  • Auto-login token is same-origin (never emailed), single-use (consumed immediately by controller), with 15-minute expiry as safety net.
  • Race-saferegister_and_confirm_admin/1 checks has_admin?() inside a DB transaction.
  • Removes info leak — old check_inbox phase exposed admin email to unauthenticated visitors.

Verification

  1. mix precommit passes
  2. Dev fresh install: visit /setup, enter email only, immediately on configure phase
  3. Prod fresh install: secret gate shown first, wrong secret rejected, correct secret reveals email form, submit -> auto-logged in -> configure phase
  4. Admin pages: warning banner shows when SMTP not configured
  5. With SMTP_HOST set: banner hidden, Mailer.email_configured?() returns true
  6. Login page: /dev/mailbox link shows only with Local adapter
  7. All setup tests pass