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:
190
docs/plans/email-settings.md
Normal file
190
docs/plans/email-settings.md
Normal 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
|
||||
200
docs/plans/setup-auto-confirm.md
Normal file
200
docs/plans/setup-auto-confirm.md
Normal 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
|
||||
Reference in New Issue
Block a user