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

@ -27,7 +27,7 @@
Ordered by dependency level — admin shell chain first (unblocks most downstream work). Ordered by dependency level — admin shell chain first (unblocks most downstream work).
Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md)
| # | Task | Depends on | Est | Status | | # | Task | Depends on | Est | Status |
|---|------|------------|-----|--------| |---|------|------------|-----|--------|
@ -79,10 +79,13 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~38~~ | ~~Phase 6: Replace DaisyUI (admin)~~ | 37 | 3h | done | | ~~38~~ | ~~Phase 6: Replace DaisyUI (admin)~~ | 37 | 3h | done |
| ~~39~~ | ~~Phase 7: Remove Tailwind entirely~~ | 38 | 1.5h | done | | ~~39~~ | ~~Phase 7: Remove Tailwind entirely~~ | 38 | 1.5h | done |
| ~~40~~ | ~~Phase 8: Unified CSS — admin inherits shop theme system~~ | 39 | 12.5h | done | | ~~40~~ | ~~Phase 8: Unified CSS — admin inherits shop theme system~~ | 39 | 12.5h | done |
| | **Self-hosted email + setup** ([auto-confirm plan](docs/plans/setup-auto-confirm.md), [email settings plan](docs/plans/email-settings.md)) | | | |
| 50 | Setup auto-confirm: secret gate, auto-login, remove check_inbox | — | 2h | |
| 51 | Email settings admin UI with multi-adapter support | 50 | 3h | |
| | **Bugs / polish** | | | | | | **Bugs / polish** | | | |
| ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done | | ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done |
**Total remaining: ~4.5 hours across ~2 sessions** (Printful tests) **Total remaining: ~9.5 hours across ~4-5 sessions** (Printful tests, setup auto-confirm, email settings)
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase. See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.

View File

@ -401,6 +401,21 @@
border: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base)); border: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base));
} }
.admin-banner-warning {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
line-height: 1.5;
background-color: color-mix(in oklch, var(--t-status-warning) 12%, var(--t-surface-base));
color: var(--t-text-body);
border-bottom: 1px solid color-mix(in oklch, var(--t-status-warning) 30%, var(--t-surface-base));
& .size-5 { color: var(--t-status-warning); }
& code { font-size: 0.8125rem; }
}
/* ── Modal ── */ /* ── Modal ── */
.admin-modal { .admin-modal {

View File

@ -95,33 +95,16 @@ if config_env() == :prod do
# #
# Check `Plug.SSL` for all available options in `force_ssl`. # Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer # Mailer — configure via SMTP_HOST env var, or use the admin UI (email-settings plan)
# if smtp_host = System.get_env("SMTP_HOST") do
# Uncomment ONE of the blocks below and set the matching env vars. config :berrypod, Berrypod.Mailer,
# The API client is already configured in config/prod.exs (Swoosh.ApiClient.Req). adapter: Swoosh.Adapters.SMTP,
# relay: smtp_host,
# Postmark (recommended): port: String.to_integer(System.get_env("SMTP_PORT") || "587"),
# username: System.get_env("SMTP_USERNAME"),
# config :berrypod, Berrypod.Mailer, password: System.get_env("SMTP_PASSWORD"),
# adapter: Swoosh.Adapters.Postmark, tls: :if_available
# api_key: System.get_env("POSTMARK_API_KEY") end
#
# Mailgun:
#
# config :berrypod, Berrypod.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# SMTP (any provider):
#
# config :berrypod, Berrypod.Mailer,
# adapter: Swoosh.Adapters.SMTP,
# relay: System.get_env("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
# Stripe and Printify keys are stored encrypted in the database and loaded # Stripe and Printify keys are stored encrypted in the database and loaded
# at runtime by Berrypod.Secrets. No env vars needed for those. # at runtime by Berrypod.Secrets. No env vars needed for those.

View File

@ -0,0 +1,190 @@
# Email settings: admin UI with multi-adapter support
Status: Planned
Depends on: [setup-auto-confirm.md](setup-auto-confirm.md) (provides `email_configured?/0` and warning banner)
## Context
After the setup wizard auto-confirm work, SMTP is configurable via env vars only. Self-hosted users shouldn't need to restart the server or touch env vars to configure email. This plan adds an admin settings page for email delivery with support for popular Swoosh adapters, stored in the Settings table with encrypted secrets.
## Design
### Precedence chain
```
Env vars (SMTP_HOST etc.) > DB settings > Local adapter (dev default)
```
If `SMTP_HOST` is set, env vars win — the admin UI shows the config as read-only with a note that it's controlled by environment variables. If no env vars, the admin can configure everything from the UI. If nothing is configured, falls back to `Swoosh.Adapters.Local`.
### Supported adapters
Start with the popular ones that cover 95%+ of use cases. Each has a simple config shape:
| Adapter | Module | Fields |
|---------|--------|--------|
| SMTP | `Swoosh.Adapters.SMTP` | relay (string), port (integer), username (string), password (secret) |
| Postmark | `Swoosh.Adapters.Postmark` | api_key (secret) |
| Resend | `Swoosh.Adapters.Resend` | api_key (secret) |
| SendGrid | `Swoosh.Adapters.Sendgrid` | api_key (secret) |
| Mailgun | `Swoosh.Adapters.Mailgun` | api_key (secret), domain (string) |
| Brevo | `Swoosh.Adapters.Brevo` | api_key (secret) |
Adding more later is just adding an entry to the adapter registry — no code changes needed.
### Adapter registry
A simple data structure defining each adapter's config shape:
```elixir
defmodule Berrypod.Mailer.Adapters do
def all do
[
%{
key: "smtp",
name: "SMTP",
module: Swoosh.Adapters.SMTP,
fields: [
%{key: "relay", label: "Server host", type: :string, required: true},
%{key: "port", label: "Port", type: :integer, default: 587},
%{key: "username", label: "Username", type: :string},
%{key: "password", label: "Password", type: :secret}
]
},
%{
key: "postmark",
name: "Postmark",
module: Swoosh.Adapters.Postmark,
fields: [
%{key: "api_key", label: "API key", type: :secret, required: true}
]
},
# ... etc
]
end
end
```
The UI renders dynamically from this registry. Secret fields use `put_secret/2` and show masked hints via `secret_hint/1`. Non-secret fields use `put_setting/3`.
### Storage in Settings table
Uses the existing key-value Settings table:
| Key | Type | Example |
|-----|------|---------|
| `email_adapter` | string | `"postmark"` |
| `email_relay` | string | `"smtp.example.com"` |
| `email_port` | integer | `587` |
| `email_api_key` | encrypted | (encrypted via Vault) |
| `email_password` | encrypted | (encrypted via Vault) |
| `email_username` | string | `"user@example.com"` |
| `email_domain` | string | `"mg.example.com"` |
All keys are prefixed with `email_`. Only the fields relevant to the selected adapter are written; others are cleared on adapter change.
### Runtime config loading
On app startup and on settings save:
1. Read `email_adapter` from Settings
2. Look up adapter in registry
3. Read all config fields for that adapter from Settings
4. Build Swoosh config keyword list
5. Call `Application.put_env(:berrypod, Berrypod.Mailer, config)`
This is wrapped in a `Berrypod.Mailer.load_config/0` function, called from:
- `Application.start/2` (boot)
- The admin settings save handler (runtime update without restart)
### Updating `email_configured?/0`
The helper from setup-auto-confirm.md already checks if the adapter is not `Swoosh.Adapters.Local`. No changes needed — once the admin configures an adapter via the UI, `Application.get_env` returns the new adapter and the function returns `true`.
## Changes
### 1. Adapter registry (`lib/berrypod/mailer/adapters.ex` — new)
Module with `all/0`, `get/1`, `field_keys/1` functions. Pure data, no side effects.
### 2. Config loader (`lib/berrypod/mailer.ex`)
Add to existing module:
- `load_config/0` — reads from Settings, builds Swoosh config, calls `Application.put_env/3`
- `env_var_configured?/0` — returns true if `SMTP_HOST` is set (used by UI to show read-only state)
- `current_config/0` — returns the active adapter name + config for display in the UI
### 3. Load on boot (`lib/berrypod/application.ex`)
Call `Mailer.load_config/0` after Repo is started (needs DB access for Settings reads and secret decryption).
### 4. Admin email settings page (`lib/berrypod_web/live/admin/email_settings.ex` — new)
LiveView at `/admin/settings/email`:
- Adapter dropdown (from registry)
- Dynamic form fields based on selected adapter
- Secret fields show masked hints when a value exists, clear button, new value input
- "Send test email" button — sends a test email to the admin's address
- Read-only mode when env vars are active (show values from env, disable form)
- Save persists to Settings table, calls `Mailer.load_config/0` to apply immediately
### 5. Route (`lib/berrypod_web/router.ex`)
Add in the authenticated admin live_session:
```elixir
live "/admin/settings/email", Admin.EmailSettings
```
### 6. Link from warning banner
Update the warning banner from setup-auto-confirm to link to `/admin/settings/email`:
"Email delivery not configured. [Configure email ->](/admin/settings/email)"
### 7. Admin nav
Add "Email" under settings in the admin sidebar, or as a tab on the existing settings page.
### 8. Test email function (`lib/berrypod/mailer.ex`)
- `send_test_email/1` — sends a simple test email to the given address using current config. Returns `{:ok, _}` or `{:error, reason}`.
### 9. Tests
**`test/berrypod/mailer_test.exs` (new):**
- `load_config/0` reads from Settings and applies to Application env
- `load_config/0` with no settings keeps Local adapter
- `env_var_configured?/0` checks SMTP_HOST
**`test/berrypod/mailer/adapters_test.exs` (new):**
- Registry returns all adapters
- Each adapter has required fields
**`test/berrypod_web/live/admin/email_settings_test.exs` (new):**
- Page renders adapter dropdown
- Changing adapter shows correct fields
- Saving config persists to Settings
- Secret fields show masked hints
- Read-only when env vars are set
## Files to modify
| File | Change |
|------|--------|
| `lib/berrypod/mailer/adapters.ex` | New — adapter registry |
| `lib/berrypod/mailer.ex` | Add `load_config/0`, `env_var_configured?/0`, `current_config/0`, `send_test_email/1` |
| `lib/berrypod/application.ex` | Call `Mailer.load_config/0` on boot |
| `lib/berrypod_web/live/admin/email_settings.ex` | New — admin settings page |
| `lib/berrypod_web/router.ex` | Add email settings route |
| `lib/berrypod_web/components/layouts/admin.html.heex` | Link banner to settings page, add nav entry |
| `assets/css/admin/components.css` | Styles for email settings form |
| Tests (3 new files) | Mailer, adapters, LiveView |
## Verification
1. `mix precommit` passes
2. No env vars set: admin UI at `/admin/settings/email` shows adapter dropdown, can configure Postmark with API key, save, `email_configured?()` returns true, warning banner disappears
3. With `SMTP_HOST` set: admin UI shows SMTP config read-only with "controlled by environment variables" note
4. "Send test email" delivers to admin's address
5. Server restart: config reloaded from DB, email still works
6. Adapter change: old config cleared, new adapter's fields shown, save applies immediately

View File

@ -0,0 +1,200 @@
# Setup wizard: auto-confirm admin, setup secret gate
Status: Planned
Companion plan: [email-settings.md](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?/0``true` 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):
```elixir
get "/setup/login/:token", SetupController, :login
```
### 6. Rewrite onboarding LiveView (`lib/berrypod_web/live/setup/onboarding.ex`)
**Mount logic:**
```elixir
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):
```elixir
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)
### 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_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-safe**`register_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

View File

@ -69,36 +69,6 @@ defmodule Berrypod.Accounts do
Repo.exists?(User) Repo.exists?(User)
end 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 ## User registration
@doc """ @doc """
@ -119,6 +89,37 @@ defmodule Berrypod.Accounts do
|> Repo.insert() |> Repo.insert()
end 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 ## Settings
@doc """ @doc """

View File

@ -31,7 +31,23 @@ defmodule Berrypod.Application do
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options # for other strategies and supported options
opts = [strategy: :one_for_one, name: Berrypod.Supervisor] 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 end
# Tell Phoenix to update the endpoint configuration # Tell Phoenix to update the endpoint configuration

View File

@ -1,3 +1,14 @@
defmodule Berrypod.Mailer do defmodule Berrypod.Mailer do
use Swoosh.Mailer, otp_app: :berrypod 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 end

View File

@ -5,6 +5,47 @@ defmodule Berrypod.Setup do
alias Berrypod.{Accounts, Orders, Products, Settings} 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 """ @doc """
Returns a map describing the current setup status. Returns a map describing the current setup status.

View File

@ -26,6 +26,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
socket socket
|> assign(:current_path, "") |> assign(:current_path, "")
|> assign(:site_live, Settings.site_live?()) |> assign(:site_live, Settings.site_live?())
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|> assign(:theme_settings, theme_settings) |> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css) |> assign(:generated_css, generated_css)
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params, |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,

View File

@ -18,6 +18,15 @@
</.link> </.link>
</header> </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 --%> <%!-- page content --%>
<main class="flex-1 p-4 sm:p-6 lg:p-8"> <main class="flex-1 p-4 sm:p-6 lg:p-8">
<div class="mx-auto max-w-5xl"> <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")} {:ok, push_navigate(socket, to: ~p"/admin")}
setup.admin_created and is_nil(get_user(socket)) -> setup.admin_created and is_nil(get_user(socket)) ->
{:ok, mount_check_inbox(socket)} {:ok, push_navigate(socket, to: ~p"/users/log-in")}
setup.admin_created ->
{:ok, mount_configure(socket, setup)}
true -> true ->
{:ok, mount_email_form(socket)} {:ok, mount_setup(socket, setup)}
end end
end end
@ -37,29 +34,21 @@ defmodule BerrypodWeb.Setup.Onboarding do
end end
end end
defp mount_email_form(socket) do defp mount_setup(socket, setup) do
socket logged_in? = get_user(socket) != nil
|> 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
provider_conn = Products.get_first_provider_connection() provider_conn = Products.get_first_provider_connection()
socket socket
|> assign(:page_title, "Set up your shop") |> assign(:page_title, "Set up your shop")
|> assign(:phase, :configure)
|> assign(:setup, setup) |> 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(:providers, Provider.all())
|> assign(:selected_provider, nil) |> assign(:selected_provider, nil)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider)) |> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
@ -68,31 +57,38 @@ defmodule BerrypodWeb.Setup.Onboarding do
|> assign(:provider_connecting, false) |> assign(:provider_connecting, false)
|> assign(:provider_conn, provider_conn) |> assign(:provider_conn, provider_conn)
|> assign(:pending_provider_key, nil) |> assign(:pending_provider_key, nil)
# Stripe # Stripe (card 3)
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe)) |> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false) |> assign(:stripe_connecting, false)
end end
# ── Events: Account ── # ── Events: Secret gate ──
@impl true @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 def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
if email == "" do if email == "" do
{:noreply, put_flash(socket, :error, "Please enter your email address")} {:noreply, put_flash(socket, :error, "Please enter your email address")}
else else
case Accounts.register_user(%{email: email}) do case Accounts.register_and_confirm_admin(%{email: email}) do
{:ok, user} -> {:ok, user} ->
{:ok, _} = token = Accounts.generate_login_token(user)
Accounts.deliver_login_instructions( {:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
user,
&url(~p"/users/log-in/#{&1}")
)
{:error, :admin_already_exists} ->
{:noreply, {:noreply,
socket socket
|> assign(:phase, :check_inbox) |> put_flash(:error, "An admin account already exists")
|> assign(:admin_email, user.email) |> push_navigate(to: ~p"/setup")}
|> assign(:local_mail?, local_mail_adapter?())}
{:error, changeset} -> {:error, changeset} ->
{:noreply, {:noreply,
@ -103,26 +99,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
end end
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 ── # ── Events: Provider ──
def handle_event("select_provider", %{"type" => type}, socket) do def handle_event("select_provider", %{"type" => type}, socket) do
@ -239,21 +215,52 @@ defmodule BerrypodWeb.Setup.Onboarding do
<div class="setup-page"> <div class="setup-page">
<div class="setup-header"> <div class="setup-header">
<h1 class="setup-title">Set up your shop</h1> <h1 class="setup-title">Set up your shop</h1>
<p :if={@phase == :email_form} class="setup-subtitle"> <%= if @require_secret? and not @secret_verified do %>
Enter your email to create the admin account. <p class="setup-subtitle">
Enter the setup secret from your server logs to get started.
</p> </p>
<p :if={@phase == :check_inbox} class="setup-subtitle"> <% else %>
Almost there check your inbox. <p class="setup-subtitle">
</p>
<p :if={@phase == :configure} class="setup-subtitle">
Connect your accounts to get going. Connect your accounts to get going.
</p> </p>
<% end %>
</div> </div>
<%!-- Phase 1: email form --%> <%!-- Secret gate (prod only, before admin exists) --%>
<div :if={@phase == :email_form} class="setup-sections"> <%= if @require_secret? and not @secret_verified do %>
<.section_card title="Create admin account" number={1} done={false}> <div class="setup-sections">
<p class="setup-hint">We'll send a login link to get you started.</p> <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>
</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"> <.form for={@account_form} phx-submit="create_account">
<.input <.input
field={@account_form[:email]} field={@account_form[:email]}
@ -268,49 +275,6 @@ defmodule BerrypodWeb.Setup.Onboarding do
</div> </div>
</.form> </.form>
</.section_card> </.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>
<%!-- 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 <.section_card
title="Connect a print provider" title="Connect a print provider"
@ -342,7 +306,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
</div> </div>
<%!-- All done --%> <%!-- All done --%>
<div :if={@phase == :configure and @setup.setup_complete} class="setup-complete"> <div :if={@setup.setup_complete} class="setup-complete">
<.icon name="hero-check-badge" class="setup-complete-icon" /> <.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>You're all set</h2> <h2>You're all set</h2>
<p>Head to the dashboard to sync products, customise your theme, and go live.</p> <p>Head to the dashboard to sync products, customise your theme, and go live.</p>
@ -350,6 +314,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
Go to dashboard <span aria-hidden="true">&rarr;</span> Go to dashboard <span aria-hidden="true">&rarr;</span>
</.link> </.link>
</div> </div>
<% end %>
</div> </div>
""" """
end 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({:http_error, _code}), do: "Something went wrong — try again"
defp format_error(error) when is_binary(error), do: error defp format_error(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed — check your token and try again" 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 end

View File

@ -146,6 +146,9 @@ defmodule BerrypodWeb.Router do
scope "/", BerrypodWeb do scope "/", BerrypodWeb do
pipe_through [:browser] pipe_through [:browser]
# Token-based auto-login after setup account creation
get "/setup/login/:token", SetupController, :login
live_session :setup, live_session :setup,
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
live "/setup", Setup.Onboarding, :index live "/setup", Setup.Onboarding, :index

View File

@ -64,44 +64,34 @@ defmodule Berrypod.AccountsTest do
end end
end end
describe "admin_email/0" do describe "register_and_confirm_admin/1" do
test "returns nil when no users exist" do test "creates a confirmed admin user" do
assert is_nil(Accounts.admin_email()) email = unique_user_email()
assert {:ok, user} = Accounts.register_and_confirm_admin(%{email: email})
assert user.email == email
assert user.confirmed_at
end end
test "returns the admin email" do test "fails if admin already exists" do
user_fixture()
assert {:error, :admin_already_exists} =
Accounts.register_and_confirm_admin(%{email: unique_user_email()})
end
test "validates email" do
assert {:error, changeset} = Accounts.register_and_confirm_admin(%{email: "bad"})
assert errors_on(changeset).email
end
end
describe "generate_login_token/1" do
test "creates a login token for the user" do
user = user_fixture() user = user_fixture()
assert Accounts.admin_email() == user.email token = Accounts.generate_login_token(user)
end assert is_binary(token)
end assert found_user = Accounts.get_user_by_magic_link_token(token)
assert found_user.id == user.id
describe "get_unconfirmed_admin/0" do
test "returns nil when no users exist" do
assert is_nil(Accounts.get_unconfirmed_admin())
end
test "returns unconfirmed user" do
user = unconfirmed_user_fixture()
assert Accounts.get_unconfirmed_admin().id == user.id
end
test "returns nil for confirmed user" do
_user = user_fixture()
assert is_nil(Accounts.get_unconfirmed_admin())
end
end
describe "delete_unconfirmed_user/1" do
test "deletes an unconfirmed user" do
user = unconfirmed_user_fixture()
assert {:ok, _} = Accounts.delete_unconfirmed_user(user)
refute Accounts.has_admin?()
end
test "refuses to delete a confirmed user" do
user = user_fixture()
assert {:error, :already_confirmed} = Accounts.delete_unconfirmed_user(user)
assert Accounts.has_admin?()
end end
end end

View File

@ -0,0 +1,40 @@
defmodule BerrypodWeb.SetupControllerTest do
use BerrypodWeb.ConnCase, async: true
import Berrypod.AccountsFixtures
alias Berrypod.Accounts
describe "GET /setup/login/:token" do
test "logs in with a valid token and redirects to /setup", %{conn: conn} do
user = user_fixture()
token = Accounts.generate_login_token(user)
conn = get(conn, ~p"/setup/login/#{token}")
assert redirected_to(conn) == ~p"/setup"
assert get_session(conn, :user_token)
end
test "redirects to /setup with error for invalid token", %{conn: conn} do
conn = get(conn, ~p"/setup/login/invalid-token")
assert redirected_to(conn) == ~p"/setup"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Login failed"
end
test "token is consumed after use", %{conn: conn} do
user = user_fixture()
token = Accounts.generate_login_token(user)
# First use succeeds
conn1 = get(conn, ~p"/setup/login/#{token}")
assert redirected_to(conn1) == ~p"/setup"
# Second use fails (token consumed)
conn2 = get(conn, ~p"/setup/login/#{token}")
assert redirected_to(conn2) == ~p"/setup"
assert Phoenix.Flash.get(conn2.assigns.flash, :error) =~ "Login failed"
end
end
end

View File

@ -4,7 +4,7 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures import Berrypod.AccountsFixtures
alias Berrypod.{Accounts, Products, Settings} alias Berrypod.{Products, Settings}
describe "access rules" do describe "access rules" do
test "accessible on fresh install (no admin)", %{conn: conn} do test "accessible on fresh install (no admin)", %{conn: conn} do
@ -30,12 +30,11 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
assert {:live_redirect, %{to: "/admin"}} = redirect assert {:live_redirect, %{to: "/admin"}} = redirect
end end
test "shows check inbox when admin exists but not logged in", %{conn: conn} do test "redirects to login when admin exists but not logged in", %{conn: conn} do
_user = unconfirmed_user_fixture() _user = user_fixture()
{:ok, _view, html} = live(conn, ~p"/setup") {:error, redirect} = live(conn, ~p"/setup")
assert html =~ "Check your inbox" assert {:live_redirect, %{to: "/users/log-in"}} = redirect
refute html =~ "Connect a print provider"
end end
test "redirects to / when site is already live", %{conn: conn} do test "redirects to / when site is already live", %{conn: conn} do
@ -57,44 +56,29 @@ defmodule BerrypodWeb.Setup.OnboardingTest do
end end
end end
describe "phase: email form" do describe "fresh install (no admin)" do
test "shows only email form on fresh install", %{conn: conn} do test "shows all three cards with email form active", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/setup") {:ok, _view, html} = live(conn, ~p"/setup")
assert html =~ "Create admin account" assert html =~ "Create admin account"
refute html =~ "Connect a print provider" assert html =~ "Connect a print provider"
refute html =~ "Connect payments" assert html =~ "Connect payments"
end
end end
describe "phase: check inbox" do test "creating account auto-confirms and redirects to login", %{conn: conn} do
test "shows check inbox after creating account", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/setup") {:ok, view, _html} = live(conn, ~p"/setup")
html =
view view
|> form("form", account: %{email: "admin@example.com"}) |> form(~s(form[phx-submit="create_account"]), account: %{email: "admin@example.com"})
|> render_submit() |> render_submit()
assert html =~ "Check your inbox" # The LiveView redirects to /setup/login/:token
assert html =~ "admin@example.com" {path, _flash} = assert_redirect(view)
refute html =~ "Connect a print provider" assert path =~ ~r"/setup/login/.+"
end
test "start over resets to email form", %{conn: conn} do
_user = unconfirmed_user_fixture()
{:ok, view, _html} = live(conn, ~p"/setup")
html = render_click(view, "start_over")
assert html =~ "Create admin account"
refute html =~ "Check your inbox"
refute Accounts.has_admin?()
end end
end end
describe "phase: configure (logged in)" do describe "configure (logged in)" do
setup :register_and_log_in_user setup :register_and_log_in_user
test "shows provider and stripe steps", %{conn: conn, user: user} do test "shows provider and stripe steps", %{conn: conn, user: user} do