From 9d9bd090592e84ed1569a23e0d05cdb2b9b85c56 Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 21 Feb 2026 10:24:26 +0000 Subject: [PATCH] auto-confirm admin during setup, skip email verification 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 --- PROGRESS.md | 7 +- assets/css/admin/components.css | 15 + config/runtime.exs | 37 +-- docs/plans/email-settings.md | 190 ++++++++++++ docs/plans/setup-auto-confirm.md | 200 +++++++++++++ lib/berrypod/accounts.ex | 61 ++-- lib/berrypod/application.ex | 18 +- lib/berrypod/mailer.ex | 11 + lib/berrypod/setup.ex | 41 +++ lib/berrypod_web/admin_layout_hook.ex | 1 + .../components/layouts/admin.html.heex | 9 + .../controllers/setup_controller.ex | 37 +++ lib/berrypod_web/live/setup/onboarding.ex | 283 ++++++++---------- lib/berrypod_web/router.ex | 3 + test/berrypod/accounts_test.exs | 54 ++-- .../controllers/setup_controller_test.exs | 40 +++ .../live/setup/onboarding_test.exs | 60 ++-- 17 files changed, 776 insertions(+), 291 deletions(-) create mode 100644 docs/plans/email-settings.md create mode 100644 docs/plans/setup-auto-confirm.md create mode 100644 lib/berrypod_web/controllers/setup_controller.ex create mode 100644 test/berrypod_web/controllers/setup_controller_test.exs diff --git a/PROGRESS.md b/PROGRESS.md index 08366ce..f57cf39 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -27,7 +27,7 @@ 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 | |---|------|------------|-----|--------| @@ -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 | | ~~39~~ | ~~Phase 7: Remove Tailwind entirely~~ | 38 | 1.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** | | | | | ~~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. diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 9ab3ea0..1bf0630 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -401,6 +401,21 @@ 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 ── */ .admin-modal { diff --git a/config/runtime.exs b/config/runtime.exs index 6803ed9..e2c98c1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -95,33 +95,16 @@ if config_env() == :prod do # # Check `Plug.SSL` for all available options in `force_ssl`. - # ## Configuring the mailer - # - # Uncomment ONE of the blocks below and set the matching env vars. - # The API client is already configured in config/prod.exs (Swoosh.ApiClient.Req). - # - # Postmark (recommended): - # - # config :berrypod, Berrypod.Mailer, - # adapter: Swoosh.Adapters.Postmark, - # api_key: System.get_env("POSTMARK_API_KEY") - # - # 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 + # Mailer — configure via SMTP_HOST env var, or use the admin UI (email-settings plan) + 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 # Stripe and Printify keys are stored encrypted in the database and loaded # at runtime by Berrypod.Secrets. No env vars needed for those. diff --git a/docs/plans/email-settings.md b/docs/plans/email-settings.md new file mode 100644 index 0000000..362dbbb --- /dev/null +++ b/docs/plans/email-settings.md @@ -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 diff --git a/docs/plans/setup-auto-confirm.md b/docs/plans/setup-auto-confirm.md new file mode 100644 index 0000000..65386fa --- /dev/null +++ b/docs/plans/setup-auto-confirm.md @@ -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: — 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 diff --git a/lib/berrypod/accounts.ex b/lib/berrypod/accounts.ex index c3abc08..356ed55 100644 --- a/lib/berrypod/accounts.ex +++ b/lib/berrypod/accounts.ex @@ -69,36 +69,6 @@ defmodule Berrypod.Accounts do Repo.exists?(User) 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 @doc """ @@ -119,6 +89,37 @@ defmodule Berrypod.Accounts do |> Repo.insert() 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 @doc """ diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex index e13cd3a..c8c8bfa 100644 --- a/lib/berrypod/application.ex +++ b/lib/berrypod/application.ex @@ -31,7 +31,23 @@ defmodule Berrypod.Application do # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options 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 # Tell Phoenix to update the endpoint configuration diff --git a/lib/berrypod/mailer.ex b/lib/berrypod/mailer.ex index ca7ceba..d427af3 100644 --- a/lib/berrypod/mailer.ex +++ b/lib/berrypod/mailer.ex @@ -1,3 +1,14 @@ defmodule Berrypod.Mailer do 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 diff --git a/lib/berrypod/setup.ex b/lib/berrypod/setup.ex index 4b91790..0fbc24c 100644 --- a/lib/berrypod/setup.ex +++ b/lib/berrypod/setup.ex @@ -5,6 +5,47 @@ defmodule Berrypod.Setup do 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 """ Returns a map describing the current setup status. diff --git a/lib/berrypod_web/admin_layout_hook.ex b/lib/berrypod_web/admin_layout_hook.ex index f8e0a24..32dd635 100644 --- a/lib/berrypod_web/admin_layout_hook.ex +++ b/lib/berrypod_web/admin_layout_hook.ex @@ -26,6 +26,7 @@ defmodule BerrypodWeb.AdminLayoutHook do socket |> assign(:current_path, "") |> assign(:site_live, Settings.site_live?()) + |> assign(:email_configured, Berrypod.Mailer.email_configured?()) |> assign(:theme_settings, theme_settings) |> assign(:generated_css, generated_css) |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params, diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index 66a3d7f..e50daff 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -18,6 +18,15 @@ + <%!-- email warning banner --%> +
+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> +

+ Email delivery not configured. Customers won't receive order confirmations. + Set SMTP_HOST to enable email. +

+
+ <%!-- page content --%>
diff --git a/lib/berrypod_web/controllers/setup_controller.ex b/lib/berrypod_web/controllers/setup_controller.ex new file mode 100644 index 0000000..6f9439c --- /dev/null +++ b/lib/berrypod_web/controllers/setup_controller.ex @@ -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 diff --git a/lib/berrypod_web/live/setup/onboarding.ex b/lib/berrypod_web/live/setup/onboarding.ex index 93acffe..7e7f3d7 100644 --- a/lib/berrypod_web/live/setup/onboarding.ex +++ b/lib/berrypod_web/live/setup/onboarding.ex @@ -20,13 +20,10 @@ defmodule BerrypodWeb.Setup.Onboarding do {:ok, push_navigate(socket, to: ~p"/admin")} setup.admin_created and is_nil(get_user(socket)) -> - {:ok, mount_check_inbox(socket)} - - setup.admin_created -> - {:ok, mount_configure(socket, setup)} + {:ok, push_navigate(socket, to: ~p"/users/log-in")} true -> - {:ok, mount_email_form(socket)} + {:ok, mount_setup(socket, setup)} end end @@ -37,29 +34,21 @@ defmodule BerrypodWeb.Setup.Onboarding do end end - defp mount_email_form(socket) do - socket - |> 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 + defp mount_setup(socket, setup) do + logged_in? = get_user(socket) != nil provider_conn = Products.get_first_provider_connection() socket |> assign(:page_title, "Set up your shop") - |> assign(:phase, :configure) |> 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(:selected_provider, nil) |> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider)) @@ -68,31 +57,38 @@ defmodule BerrypodWeb.Setup.Onboarding do |> assign(:provider_connecting, false) |> assign(:provider_conn, provider_conn) |> assign(:pending_provider_key, nil) - # Stripe + # Stripe (card 3) |> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe)) |> assign(:stripe_connecting, false) end - # ── Events: Account ── + # ── Events: Secret gate ── @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 if email == "" do {:noreply, put_flash(socket, :error, "Please enter your email address")} else - case Accounts.register_user(%{email: email}) do + case Accounts.register_and_confirm_admin(%{email: email}) do {:ok, user} -> - {:ok, _} = - Accounts.deliver_login_instructions( - user, - &url(~p"/users/log-in/#{&1}") - ) + token = Accounts.generate_login_token(user) + {:noreply, redirect(socket, to: ~p"/setup/login/#{token}")} + {:error, :admin_already_exists} -> {:noreply, socket - |> assign(:phase, :check_inbox) - |> assign(:admin_email, user.email) - |> assign(:local_mail?, local_mail_adapter?())} + |> put_flash(:error, "An admin account already exists") + |> push_navigate(to: ~p"/setup")} {:error, changeset} -> {:noreply, @@ -103,26 +99,6 @@ defmodule BerrypodWeb.Setup.Onboarding do 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 ── def handle_event("select_provider", %{"type" => type}, socket) do @@ -239,117 +215,106 @@ defmodule BerrypodWeb.Setup.Onboarding do

Set up your shop

-

- Enter your email to create the admin account. -

-

- Almost there — check your inbox. -

-

- Connect your accounts to get going. -

+ <%= if @require_secret? and not @secret_verified do %> +

+ Enter the setup secret from your server logs to get started. +

+ <% else %> +

+ Connect your accounts to get going. +

+ <% end %>
- <%!-- Phase 1: email form --%> -
- <.section_card title="Create admin account" number={1} done={false}> -

We'll send a login link to get you started.

- <.form for={@account_form} phx-submit="create_account"> - <.input - field={@account_form[:email]} - type="email" - label="Email address" - autocomplete="email" - required - phx-mounted={JS.focus()} - /> -
- <.button phx-disable-with="Creating account...">Create account + <%!-- Secret gate (prod only, before admin exists) --%> + <%= if @require_secret? and not @secret_verified do %> +
+
+
+

+ Find the setup secret in your server logs or set the SETUP_SECRET + environment variable. +

+ <.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()} + /> +
+ <.button phx-disable-with="Checking...">Continue +
+
- - -
- - <%!-- Phase 2: check inbox --%> -
-
-
- <.icon name="hero-envelope" class="size-10 setup-inbox-icon" /> -

Check your inbox

-

- We sent a login link to {@admin_email}. - Click it to continue setting up your shop. -

- -
- <.icon name="hero-information-circle" class="size-5 shrink-0" /> -
-

- Using local mail adapter. - See sent emails at /dev/mailbox. -

-
-
- -

- Wrong email? - -

-
+ <% else %> + <%!-- All three setup cards --%> +
+ <.section_card + title="Create admin account" + number={1} + done={@logged_in?} + summary={account_summary(assigns)} + > +

Enter your email to create the admin account.

+ <.form for={@account_form} phx-submit="create_account"> + <.input + field={@account_form[:email]} + type="email" + label="Email address" + autocomplete="email" + required + phx-mounted={JS.focus()} + /> +
+ <.button phx-disable-with="Creating account...">Create account +
+ + - <%!-- Phase 3: configure (logged in) --%> -
- <.section_card - title="Create admin account" - number={1} - done={true} - summary={account_summary(assigns)} - > - - + <.section_card + title="Connect a print provider" + number={2} + done={@setup.provider_connected} + summary={provider_summary(assigns)} + > + <.provider_section + providers={@providers} + selected={@selected_provider} + form={@provider_form} + testing={@provider_testing} + test_result={@provider_test_result} + connecting={@provider_connecting} + /> + - <.section_card - title="Connect a print provider" - number={2} - done={@setup.provider_connected} - summary={provider_summary(assigns)} - > - <.provider_section - providers={@providers} - selected={@selected_provider} - form={@provider_form} - testing={@provider_testing} - test_result={@provider_test_result} - connecting={@provider_connecting} - /> - + <.section_card + title="Connect payments" + number={3} + done={@setup.stripe_connected} + summary={stripe_summary(assigns)} + > + <.stripe_section + form={@stripe_form} + connecting={@stripe_connecting} + /> + +
- <.section_card - title="Connect payments" - number={3} - done={@setup.stripe_connected} - summary={stripe_summary(assigns)} - > - <.stripe_section - form={@stripe_form} - connecting={@stripe_connecting} - /> - -
- - <%!-- All done --%> -
- <.icon name="hero-check-badge" class="setup-complete-icon" /> -

You're all set

-

Head to the dashboard to sync products, customise your theme, and go live.

- <.link navigate={~p"/admin"} class="admin-btn admin-btn-primary"> - Go to dashboard - -
+ <%!-- All done --%> +
+ <.icon name="hero-check-badge" class="setup-complete-icon" /> +

You're all set

+

Head to the dashboard to sync products, customise your theme, and go live.

+ <.link navigate={~p"/admin"} class="admin-btn admin-btn-primary"> + Go to dashboard + +
+ <% 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(error) when is_binary(error), do: error 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 diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 776dc95..e653505 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -146,6 +146,9 @@ defmodule BerrypodWeb.Router do scope "/", BerrypodWeb do pipe_through [:browser] + # Token-based auto-login after setup account creation + get "/setup/login/:token", SetupController, :login + live_session :setup, on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do live "/setup", Setup.Onboarding, :index diff --git a/test/berrypod/accounts_test.exs b/test/berrypod/accounts_test.exs index eee9605..4db14ae 100644 --- a/test/berrypod/accounts_test.exs +++ b/test/berrypod/accounts_test.exs @@ -64,44 +64,34 @@ defmodule Berrypod.AccountsTest do end end - describe "admin_email/0" do - test "returns nil when no users exist" do - assert is_nil(Accounts.admin_email()) + describe "register_and_confirm_admin/1" do + test "creates a confirmed admin user" do + email = unique_user_email() + assert {:ok, user} = Accounts.register_and_confirm_admin(%{email: email}) + assert user.email == email + assert user.confirmed_at end - test "returns the admin email" do - user = user_fixture() - assert Accounts.admin_email() == user.email + 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 "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 + describe "generate_login_token/1" do + test "creates a login token for the user" do user = user_fixture() - assert {:error, :already_confirmed} = Accounts.delete_unconfirmed_user(user) - assert Accounts.has_admin?() + token = Accounts.generate_login_token(user) + assert is_binary(token) + assert found_user = Accounts.get_user_by_magic_link_token(token) + assert found_user.id == user.id end end diff --git a/test/berrypod_web/controllers/setup_controller_test.exs b/test/berrypod_web/controllers/setup_controller_test.exs new file mode 100644 index 0000000..97cc7eb --- /dev/null +++ b/test/berrypod_web/controllers/setup_controller_test.exs @@ -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 diff --git a/test/berrypod_web/live/setup/onboarding_test.exs b/test/berrypod_web/live/setup/onboarding_test.exs index a8ed861..43d80e1 100644 --- a/test/berrypod_web/live/setup/onboarding_test.exs +++ b/test/berrypod_web/live/setup/onboarding_test.exs @@ -4,7 +4,7 @@ defmodule BerrypodWeb.Setup.OnboardingTest do import Phoenix.LiveViewTest import Berrypod.AccountsFixtures - alias Berrypod.{Accounts, Products, Settings} + alias Berrypod.{Products, Settings} describe "access rules" 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 end - test "shows check inbox when admin exists but not logged in", %{conn: conn} do - _user = unconfirmed_user_fixture() + test "redirects to login when admin exists but not logged in", %{conn: conn} do + _user = user_fixture() - {:ok, _view, html} = live(conn, ~p"/setup") - assert html =~ "Check your inbox" - refute html =~ "Connect a print provider" + {:error, redirect} = live(conn, ~p"/setup") + assert {:live_redirect, %{to: "/users/log-in"}} = redirect end test "redirects to / when site is already live", %{conn: conn} do @@ -57,44 +56,29 @@ defmodule BerrypodWeb.Setup.OnboardingTest do end end - describe "phase: email form" do - test "shows only email form on fresh install", %{conn: conn} do + describe "fresh install (no admin)" do + test "shows all three cards with email form active", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/setup") assert html =~ "Create admin account" - refute html =~ "Connect a print provider" - refute html =~ "Connect payments" + assert html =~ "Connect a print provider" + assert html =~ "Connect payments" + end + + test "creating account auto-confirms and redirects to login", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/setup") + + view + |> form(~s(form[phx-submit="create_account"]), account: %{email: "admin@example.com"}) + |> render_submit() + + # The LiveView redirects to /setup/login/:token + {path, _flash} = assert_redirect(view) + assert path =~ ~r"/setup/login/.+" end end - describe "phase: check inbox" do - test "shows check inbox after creating account", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/setup") - - html = - view - |> form("form", account: %{email: "admin@example.com"}) - |> render_submit() - - assert html =~ "Check your inbox" - assert html =~ "admin@example.com" - refute html =~ "Connect a print provider" - 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 - - describe "phase: configure (logged in)" do + describe "configure (logged in)" do setup :register_and_log_in_user test "shows provider and stripe steps", %{conn: conn, user: user} do