Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
- Server logs:
Setup secret: abc123-def456 — visit /setup to create your admin account - Visit
/setup-> see only a setup secret input - Enter correct secret -> LiveView assigns
secret_verified: true-> template reveals all 3 cards - 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
- Visit
/setup-> all 3 cards visible immediately (no secret gate) - 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— returnsSETUP_SECRETenv var, or auto-generates via:crypto.strong_rand_bytes/1on first call and stores in:persistent_term. Not stored in DB (bootstrap credential needed before admin exists).require_setup_secret?/0—truein prod when no admin exists.falsein 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:- Check
Setup.has_admin?()— if true, rollback with:admin_already_exists - Build user changeset with
confirmed_at: DateTime.utc_now() - Insert user
- Return
{:ok, user}
- Check
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/2action:- Decode token, call
Accounts.login_user_by_magic_link/1to consume it - On success:
UserAuth.log_in_user(conn, user)(creates session, redirects to/setup) - On failure: redirect to
/setupwith error flash
- Decode token, call
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?fromSetup.require_setup_secret?/0secret_verified: falsesecret_form(simple form for the gate)email_form(for card 1)- All existing configure-phase assigns
Events:
verify_secret— validates againstSetup.setup_secret/0, setssecret_verified: trueor flashes errorcreate_account— validates email, callsregister_and_confirm_admin/1, generates login token (reusesUserToken.build_email_token/2pattern but without sending email), redirects to/setup/login/:token
Template structure:
- If
require_secret?and notsecret_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_configuredassign viaBerrypod.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-warningstyle (amber background, icon)
10. Verify /dev/mailbox link (lib/berrypod_web/live/auth/login.ex)
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_SECRETenv var can proceed. - Not stored in DB — it's a bootstrap credential needed before the admin exists.
:persistent_termfor 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-safe —
register_and_confirm_admin/1checkshas_admin?()inside a DB transaction. - Removes info leak — old check_inbox phase exposed admin email to unauthenticated visitors.
Verification
mix precommitpasses- Dev fresh install: visit
/setup, enter email only, immediately on configure phase - Prod fresh install: secret gate shown first, wrong secret rejected, correct secret reveals email form, submit -> auto-logged in -> configure phase
- Admin pages: warning banner shows when SMTP not configured
- With
SMTP_HOSTset: banner hidden,Mailer.email_configured?()returns true - Login page:
/dev/mailboxlink shows only with Local adapter - All setup tests pass