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:
parent
8e818da651
commit
9d9bd09059
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
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
|
||||||
@ -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 """
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
37
lib/berrypod_web/controllers/setup_controller.ex
Normal file
37
lib/berrypod_web/controllers/setup_controller.ex
Normal 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
|
||||||
@ -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">→</span>
|
Go to dashboard <span aria-hidden="true">→</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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
40
test/berrypod_web/controllers/setup_controller_test.exs
Normal file
40
test/berrypod_web/controllers/setup_controller_test.exs
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user