Skip to content

feat(accounts): optional customer accounts with an auth email hook - #20

Merged
kris1027 merged 41 commits into
mainfrom
feat/customer-accounts
Oct 15, 2026
Merged

kris1027 merged 41 commits into
mainfrom
feat/customer-accounts

Conversation

@kris1027

Copy link
Copy Markdown
Owner

What

Customers can now make an optional account: they enter an email, confirm it from a link, and then choose a password. Guest checkout stays the default. An account shows past orders (including earlier guest orders with the same email), keeps an address book that fills in checkout, and lets customers change their profile and password or delete the account. Every auth email, for customers and admins, is now sent by our own Send Email Hook route through the existing email transport.

Why

Implements spec 0012, customer accounts (feature 12, Slice 6 in the scope). It reuses the Supabase Auth user pool the admin panel already uses, so there is no new auth vendor and no migration. Sending auth emails from our code also fixes spec 0004's shared reset template problem: the app now picks the customer or admin wording. The options weighed are in rationale.md.

Changes

Sign up, sign in and passwords (src/features/account/, app/(store)/account/(auth)/)

  • Sign up asks for an email only and always shows the same "Check your email" screen. The Supabase call runs in after() with a random throwaway password, so neither the reply nor its timing reveals whether an account exists.
  • The confirm link only spends its token on a POST ("Continue"), so link scanners cannot use it up. /account/confirm sends Referrer-Policy: no-referrer and noindex (next.config.ts).
  • When a sign up token is spent, confirmEmail swaps in a fresh throwaway password and ends other sessions, so a password planted through the public Auth API never survives a confirm (fix from the review, 6146a05).
  • Set password after confirm or reset, sign in with a safe next, sign out, forgot password. Sign in errors never say which part was wrong.

Send Email Hook (app/api/auth/send-email/route.ts, src/lib/email/, emails/)

  • Verifies the Standard Webhooks signature (standardwebhooks package) before anything else, accepts only signup and recovery, and picks the admin template from the database (an admin_users row or an authenticator), never from redirect_to.
  • Three new React Email templates: confirm sign up, customer reset, admin reset. Resend gets a hashed idempotency key, never the token hash.
  • New mailpit transport so local dev, e2e and CI can read every email. supabase/templates/recovery.html is gone.

Account area (app/(store)/account/(area)/)

  • Order history (paid orders only, 20 per page, dates in the store timezone) and an order page. A missing, foreign, guest or pending order all give the same not found.
  • Address book: up to 10 addresses, a default, dedupe on save.
  • Settings: name and phone, change password (asks for the current one, ends other sessions), delete account (asks for the password, warns about open orders). Admin users are refused on change and delete.
  • Account setup (src/lib/customer/setup.ts) creates the customers row and links earlier guest orders with the same email. One advisory lock per customer guards setup, merge, addresses and deletion.

Cart and checkout

  • Sign in merges the guest cart into the account cart, capped by stock (src/lib/cart/merge.ts). A signed in add with no cookie cart lands in the account cart.
  • Signed in checkout: the email is the account email (read only), the form starts with the pending order's address or the default address, saved addresses can be picked, and "Save this address to my account" is on by default. A blank profile phone or name is filled from the order once.
  • The order's customer_name is the profile name, else the delivery full name.
  • /checkout/complete offers "Create an account to track this order" after a paid guest order.

Admin and shared code

  • /admin/orders and the order page show "Account" or "Guest".
  • The proxy now also covers /account/* and /checkout/* to refresh the session; catalog pages still never touch Supabase. Signed out visitors on account pages get a 307 to sign in.
  • Session, form error and auth error helpers moved from src/features/admin-auth/ to src/lib/ so both features can use them.

Config, CI and docs

  • supabase/config.toml: sign up and email confirmations on, /account/** redirect URLs, the Send Email Hook.
  • New env: SEND_EMAIL_HOOK_SECRET, MAILPIT_URL, and EMAIL_TRANSPORT=mailpit; SEND_EMAIL_HOOK_URI is read only by the Supabase CLI. CI and Playwright set them.
  • Spec 0012 with rationale and verify list, the review record, a new runbook customer-accounts.md, and the scope updated.

How to test / verify

  • pnpm test: schemas, password checks and errors, paths and next safety, proxy decisions, the hook (signature, types, template choice), the actions and log redaction.
  • pnpm test:db (needs pnpm db:start): account setup and order linking, orders, addresses, settings and deletion, sign up from an order, cart merge and signed in checkout.
  • pnpm test:e2e tests/e2e/account tests/e2e/admin/password-reset.spec.ts: every account flow plus the admin reset through the hook, with axe on every account page. Locally, set SEND_EMAIL_HOOK_URI to the port your dev server uses and restart the stack.
  • The manual pass is in verify.md. Four items there are still open: the leaked and reused password messages on set password, the Resend idempotency key format, setup using the user Supabase returned when the browser holds a stale cookie, and a tampered signUpFromOrder request.

Risk & rollout

  • No migration. The tables from spec 0002 carry the feature.
  • Auth emails now depend on the app. If the hook route or Resend is down, nobody can sign up or reset a password, admins included. Supabase's own mailer is no longer a fallback, and the failure shows only in logs.
  • Production setup is in the runbook and lands with deploy (feature 19): sign up and confirmations on in Supabase, /account/** redirect URLs, the hook URL and secret in Supabase and Vercel, and a Firewall rate limit on /account/* and /checkout/complete. Not deploying yet, so none of it is part of this PR.
  • Shared session: signing out on the storefront also signs an admin out of /admin in that browser.
  • Review (record): the major finding (planted password) is fixed. The minors are still open:
    • A leftover cart cookie can let a signed out visitor on the same browser see and add to a customer's cart (hasLiveCart does not check ownership).
    • A guest cart merged on sign in can leave its pending order's Stripe session open, so paying both gives two orders (accepted in spec 0012, Consequences).
    • Set password accepts any customer session without the current password (the documented design).
    • Account deletion holds a transaction open across the deleteUser call, capped at 15 seconds.
  • Other accepted tradeoffs (spec 0012, Consequences): paid orders keep their PII after deletion with no retention limit yet, and there is no email change or data export.

Notes for reviewers

  • Worth a close look: the hook route and src/lib/email/auth-hook.ts (signature first, template choice), src/features/account/actions/confirm.ts (the planted password swap), src/lib/cart/merge.ts and src/lib/customer/setup.ts (lock order and linking), and src/features/checkout/actions/start-checkout.ts (the account email and address save).
  • Many src/features/admin-auth/ diffs are just imports, from the helpers moving to src/lib/.
  • The SEND_EMAIL_HOOK_SECRET in ci.yml is a fixed test value for the CI stack only, like the Stripe webhook secret there.
  • Spec 0004 still says public sign up is off; amending it is a listed follow up.

🤖 Generated with Claude Code

kris1027 and others added 18 commits October 14, 2026 17:06
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… production runbook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fill a blank profile name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onfirmed

Supabase sign up is public, so anyone could create the unconfirmed user with a
password they know. Confirming made that password work, and a customer who left
set password without choosing one let the attacker sign in and see their linked
guest orders. confirmEmail now swaps in a throwaway password and ends other
sessions as soon as a signup token is spent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kris1027 and others added 10 commits October 15, 2026 16:48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… guard the admin password on confirm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nticator

deleteAccount checked only admin_users, while change password and the account
AGENTS.md treat a verified TOTP factor as running the store too. Both now use
runsTheStore; spec 0012 AC-20 amended to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kris1027 and others added 12 commits October 15, 2026 17:21
… error name on the throw

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndingOrder

The cart choice becomes checkoutCartId and the account lock, order name and
profile fills move to checkout/account.ts, so createPendingOrder freezes the
cart and nothing else, and its locals are const.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n src/lib/orders

The admin badge, the confirmation page and the account pages each kept their
own status labels, and four parsers repeated the Postgres int ceiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… feature

The route stays thin and now calls a feature, like every other route; the
shared email pieces stay in src/lib/email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n next path

/account/../admin and /account/sign-in/ passed the prefix check and then
landed outside /account or on an auth page (spec 0012, AC-4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dd locks it

addToCart skipped getCustomerId() whenever the cookie named a live cart, so a
cart that expired before the lock fell back to a guest cart for a signed in
customer (spec 0012, AC-7). The add now retries the create branch with the
session read outside the transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bad_payload

A signed payload missing user.email or the token hash logged
auth_email.unexpected_type, hiding a contract break as a refused type.
Spec 0012 AC-8 and the log list amended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the AC-22 link's form

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A customer session gets a 404 on /admin, Back after sign out shows no account
data, and a deleted account's session on another device writes nothing and
recreates no row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kris1027 kris1027 self-assigned this Oct 15, 2026
@kris1027 kris1027 added docs Documentation additions and updates feature New user-facing functionality improvement Enhancements to existing functionality test Automated tests and test infrastructure ui Layouts, styling, and shared interface components auth Sign-in, sign-out, sessions, and account recovery security Authorization, secret protection, and vulnerability fixes labels Oct 15, 2026
@kris1027 kris1027 moved this from Backlog to In review in BeStore Oct 15, 2026
… closes

The edit dialog's description repeats the address label, so getByText('Gdańsk')
matched twice while the dialog was closing and failed strict mode in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kris1027
kris1027 merged commit 9959cf2 into main Oct 15, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from In review to Done in BeStore Oct 15, 2026
@kris1027
kris1027 deleted the feat/customer-accounts branch October 15, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Sign-in, sign-out, sessions, and account recovery docs Documentation additions and updates feature New user-facing functionality improvement Enhancements to existing functionality security Authorization, secret protection, and vulnerability fixes test Automated tests and test infrastructure ui Layouts, styling, and shared interface components

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant