From e55c3f9718a5a3b316202bf1f3aa51c30d49cba5 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Wed, 14 Oct 2026 17:06:25 +0200 Subject: [PATCH 01/41] docs(accounts): add spec 0012 customer accounts Co-Authored-By: Claude Opus 5.5 --- docs/scope/scope.md | 20 +- docs/specs/0012-customer-accounts/index.md | 304 ++++++++++++++++++ .../specs/0012-customer-accounts/rationale.md | 69 ++++ 3 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 docs/specs/0012-customer-accounts/index.md create mode 100644 docs/specs/0012-customer-accounts/rationale.md diff --git a/docs/scope/scope.md b/docs/scope/scope.md index fd90efc..fed6eb4 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -25,7 +25,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 9 | Admin catalog management | Slice 3 | done | | 10 | Admin order management | Slice 4 | done | | 11 | Order emails | Slice 5 | done | -| 12 | Customer accounts | Slice 6 | planned | +| 12 | Customer accounts | Slice 6 | in-progress | | 13 | Categories, search & filters | Slice 7 | planned | | 14 | Discounts & promo codes | Slice 8 | planned | | 15 | Admin sales dashboard | Slice 9 | planned | @@ -229,10 +229,21 @@ spec [0011](../specs/0011-order-emails/index.md) · code in [src/lib/email/](../ ## Slice 6: Accounts -### 12. Customer accounts · needs a decision · GA +### 12. Customer accounts · in-progress · GA Optional accounts: guest checkout stays the default, customers who sign up see their order history and saved address. **Done when:** a customer can sign up, sign in, reset a password, and see past orders; guest checkout still works; a guest can later create an account with the same email and see earlier orders. -- [ ] Design it (spec): `/architect customer accounts` +spec [0012](../specs/0012-customer-accounts/index.md) +- [x] Design it (spec): `/architect customer accounts` +- [ ] Build it: `/develop customer accounts` + - [ ] Thin thread: config, the Send Email Hook, Mailpit transport, sign up, confirm, choose password, account setup and a bare order list (AC-1 to AC-3, AC-10, AC-11, AC-13, AC-22, AC-23) + - [ ] Sign in, sign out, cart merge and password reset through the hook (AC-4 to AC-9) + - [ ] Account area: order history and detail, address book (AC-11 to AC-14) + - [ ] Checkout integration, settings, delete account and the admin badge (AC-15 to AC-21) + - [ ] Safety net: logs, axe, header link, production runbook (AC-22, AC-24 to AC-26) +- [ ] Verify it: `/check verify customer accounts` +- [ ] Test it: `/test customer accounts` +- [ ] Review it (fresh model): `/check review customer accounts` +- [ ] Document it: `/document customer accounts` ## Slice 7: Discovery @@ -294,6 +305,9 @@ Out of scope for the current build pass, kept so the plan stays honest. - **Redact customer data at Stripe**: remove the name, email and address Stripe keeps on expired Checkout Sessions, if its redaction tooling fits · needs a decision · from spec 0008 - **Resend bounce and complaint webhooks**: record bounced and complained on `email_sends` and show "Bounced" on the order page, once live · from spec 0011 - **Email sweep on its own 10 minute cron**: when the project moves to Vercel Pro (feature 19) · from spec 0011 +- **Customer email change**: Supabase secure email change, keeping `customers.email` and order linking in step · needs a decision · from spec 0012 +- **Order data retention and export**: set the accounting retention period, extend the spec 0008 purge to settled orders past it, and offer account holders a data export · needs a decision · GA · from spec 0012 +- **Admin customers page**: search customers and see their orders, if support needs it · needs a decision · from spec 0012 ## Legend diff --git a/docs/specs/0012-customer-accounts/index.md b/docs/specs/0012-customer-accounts/index.md new file mode 100644 index 0000000..2d53627 --- /dev/null +++ b/docs/specs/0012-customer-accounts/index.md @@ -0,0 +1,304 @@ +# 0012. Customer accounts + +**Date**: 2026-10-14 (amended the same day after a cross check: AC-1 to AC-5, AC-7 to AC-11, AC-13 to AC-15, AC-17, AC-19, AC-20, AC-23, AC-24, AC-26) +**Status**: Proposed + +## Summary + +Customers can make an optional account. They give their email, click the link we send, and then choose a password. Guest checkout stays the default. The account shows past orders (including earlier guest orders with the same email). It keeps an address book that fills in checkout, and lets customers change their profile and password or delete the account. It runs on the Supabase Auth the admin panel already uses, sharing one user pool with admins. Supabase hands every auth email (confirm sign up, reset password) to a small route of ours, which sends it through Resend with React Email, so customer and admin emails each get their own words. + +## Requirements + +**User stories**: +- As a shopper, I want to keep buying as a guest, so that an account is never in my way. +- As a returning customer, I want to sign in and see every order I placed with my email, so that I can check what I bought and where it is. +- As a returning customer, I want checkout to fill in my saved address, so that I can pay faster. +- As a customer who forgot my password, I want a reset link by email, so that I can get back in. +- As a customer, I want to delete my account, so that the store stops keeping my profile and addresses. +- As an admin, I want to see whether an order belongs to an account, so that I know whether the customer can look it up themselves. + +**Acceptance criteria**: + +*Sign up and confirm* +- **AC-1**: `/account/sign-up` asks for an email only. On submit the page always shows the same "Check your email" screen, with a "Send the link again" button. This holds for a new email, an existing confirmed or unconfirmed account, and an admin's email. The Supabase `signUp` call (and the resend) run in `after()`, so neither the reply nor its timing reveals whether an account exists, and no Supabase error (rate limit, send failure) ever reaches the screen; failures are only logged. `signUp` is given a random throwaway password: 32 random bytes in base64url, plus a suffix that meets the character classes. It is never shown, stored or logged. The customer chooses the real password after confirming (AC-3), so a password set by whoever signed up first never survives a confirm. Emails are stored in lower case. +- **AC-2**: The confirm email (AC-8) links to `/account/confirm?token_hash=&type=signup`. Opening the link changes nothing. The page accepts only `type` `signup` or `recovery` (any other value, or a missing hash, shows the invalid link message). It shows a "Continue" button whose form carries the hash and type as hidden inputs, and only that POST (`confirmEmail`) spends the token with `verifyOtp`. This is the scanner safe pattern of spec 0004 AC-9. The page sends `Referrer-Policy: no-referrer`. On success the visitor is signed in and goes to `/account/set-password`. An expired, used or bad link shows "This link has expired or was already used." with the AC-1 resend form (for `signup`) or a link to forgot password (for `recovery`). +- **AC-3**: `/account/set-password` needs a session (from AC-2 or AC-9) and asks for a new password. The rule is spec 0004 AC-10's project wide Supabase policy: 8 or more characters with lower case, upper case, digits and symbols, and at most 72 bytes. The shared Zod schema checks it and names each missing part. Supabase's `weak_password` answer for a leaked password shows "This password has appeared in a data leak. Choose another." and `same_password` shows "Choose a password you have not used here before." On success: `updateUser({ password })`, every other session of the user is signed out (`scope: 'others'`), AC-10 runs, and the customer lands on `/account` with "Your account is ready." (after sign up) or "Your password is changed." (after reset). A visitor who leaves without choosing stays signed in. Their account works, and they can set a password later through forgot password. + +*Sign in and sign out* +- **AC-4**: `/account/sign-in` takes email and password. A wrong password, an unknown email and an unconfirmed account all show "That email and password do not match." Supabase's per IP limit shows "Too many attempts. Wait a minute and try again." On success AC-10 runs, then the customer goes to `next` when it is safe, else to `/account`. Safe means: decoded, starts with exactly one `/`, contains no `\`, no `//` and no scheme, and is exactly `/checkout` or a path under `/account` that is not an auth page (AC-13). A visitor who already has any session (a customer, or an admin who signed in at `/admin`) and opens the sign in or sign up page goes to `/account`, so a customer sign in never replaces a live admin session. +- **AC-5**: Cart merge (`src/lib/cart/merge.ts`), used by sign in, `addToCart` and `startCheckout`. It runs in one transaction that first takes the customer lock (`pg_advisory_xact_lock` keyed on the customer id; the same lock serializes AC-10, AC-14 and the AC-15 save), then locks the carts involved (`SELECT ... FOR UPDATE`). + * If the cookie names a guest cart and the customer has no cart, the guest cart gets the customer's id. + * If both exist, each guest line is added to the account cart. Quantities of the same variant are summed and capped by `lineCap`, which is the variant's stock or 10, read under the lock. Lines for archived or sold out variants move as they are, and their flags are computed at read time as today. The guest cart is then deleted. + * The cookie ends on the account cart, or on the guest cart when that one became the account cart. With no guest cart, the cookie is set to the account cart if there is one. + * A cookie naming another customer's cart is ignored and replaced. + * Every merge renews the cookie and `expires_at` (30 days). + * A pending order on a deleted guest cart keeps its row with `cart_id` null, as when the expired cart cron deletes a cart. +- **AC-6**: "Sign out" (in the account area) signs out this browser only (`scope: 'local'`), clears the cart cookie, and goes to the home page with `RedirectType.replace`. The account cart stays in the database for the next sign in. Admins and customers share one session per browser, so this also ends an admin session in the same browser. +- **AC-7**: When `addToCart` must create a new cart (no valid cookie cart), it calls `getCustomerId()` (AC-10). With a customer, it takes the customer lock and uses their existing cart if there is one, setting the cookie to it. Otherwise it creates the cart with `customer_id` set. A visitor with no valid session gets a guest cart exactly as today. No other cart action reads the session. + +*Auth emails and password reset* +- **AC-8**: Send Email Hook. `supabase/config.toml` sets `[auth.hook.send_email]` with `enabled = true`, `uri = "env(SEND_EMAIL_HOOK_URI)"` and `secrets = "env(SEND_EMAIL_HOOK_SECRET)"`. Supabase then calls `POST /api/auth/send-email` for every auth email. + * The route verifies the Standard Webhooks signature over the raw body (`await request.text()`, parsed only after). The `standardwebhooks` package gets the secret with its `v1,whsec_` prefix stripped. A bad or stale signature answers 401 and sends nothing. + * It picks the template from the payload. `signup` sends "Confirm your email" (customer), linking to `/account/confirm?token_hash=&type=signup`. `recovery` sends the admin reset email when the user has an `admin_users` row (any state) or a verified TOTP factor, read only from `auth.mfa_factors` with `$queryRaw` as spec 0004 does. That email keeps spec 0004's wording, now as a React Email template, and links to `/auth/confirm?token_hash=&type=recovery`. It is sent whatever `redirect_to` says, so an inbox alone never resets a password that guards `/admin`. Any other `recovery` sends "Reset your password" (customer), linking to `/account/confirm?token_hash=&type=recovery`. + * Any other action type (`magiclink`, `invite`, `email_change`, `reauthentication`) is never used by this store. It answers 400, sends nothing and logs `auth_email.unexpected_type`. + * Each email has the brand wordmark and a plain text version, has no tracking, and goes through the current transport with the idempotency key `auth::`. There is one try with a 4 second timeout, to fit Supabase's 5 second hook limit. + * A failure answers 500 with `{ "error": { "http_code": 500, "message": "send failed" } }`. Supabase then fails its request with `hook_failed` or `hook_timeout`. Because sign up, resend and forgot password run in `after()`, that only shows in the logs (`auth_email.failed`). + * `supabase/templates/recovery.html` and its `config.toml` entry are removed. Spec 0004's admin reset e2e passes unchanged through the new route. + * The hook serves local, CI and production only. Supabase allows one hook URL per project, and preview deploys sit behind Deployment Protection. +- **AC-9**: Forgot password. `/account/forgot-password` shows "If there is an account for that email, a reset link is on its way." for any email. The `resetPasswordForEmail` call, with `redirectTo` set to `${NEXT_PUBLIC_SITE_URL}/account/set-password`, runs in `after()`, so the reply and its timing are the same either way. After Continue on the recovery link (AC-2), the customer sets a new password on `/account/set-password` (AC-3). For an admin or a user with an authenticator, AC-8 sends the admin email instead, and the reset happens on `/admin` with the code. + +*Account linking* +- **AC-10**: Account session and setup. + * `getCustomerId()` (in `src/lib/customer/session.ts`) reads the user with `getUser()`, which Supabase checks on its server. It never uses `getClaims()`, which trusts a token for up to an hour after the account is deleted. When the user is valid and confirmed, it upserts the `customers` row (id, lower cased email) under the customer lock and returns the id. Otherwise it returns null. + * `requireCustomer()` wraps `getCustomerId()` and answers the signed out case. + * "Account setup" runs after AC-3 and every sign in, in one transaction under the customer lock: (1) upsert the `customers` row; (2) link guest orders with `UPDATE orders SET customer_id = WHERE email = AND customer_id IS NULL` (checkout already stores `orders.email` in lower case, so an equality match is enough; linked `pending_payment` orders stay hidden until paid); (3) the AC-5 merge. The id and email come from the `data.user` that `verifyOtp`, `signInWithPassword` or `updateUser` returned, not from a cookie read in the same request. + * If another `customers` row already holds the email, it is deleted first only when no `auth.users` row exists for its id (read only with `$queryRaw`). Otherwise setup stops, logs `account.email_conflict` (error), and the page shows "Something went wrong. Try again later." + +*Account area* +- **AC-11**: `/account` lists the customer's orders that have `paid_at` set and status `paid`, `shipped`, `delivered` or `cancelled`. Newest `paid_at` comes first (ties by id), 20 per page with Previous and Next (`?page=N`). Each row shows the order number, the paid date (`formatDate`, `STORE_TIMEZONE`), the status in words (Paid, Shipped, Delivered, Cancelled), the item count and the total (`formatMoney`). With none it shows "No orders yet." and a link to the shop. Unpaid orders (`pending_payment`, `expired`, cancelled before payment) never show. +- **AC-12**: `/account/orders/[number]` shows one order: number, paid date, status, and every line from the order's snapshot (product name, variant label, quantity, unit price, line total). Then the subtotal, the discount when above 0, the delivery fee ("Free" when 0), the total, "Refunded " when `refunded_cents` is above 0, the delivery address (left out when the order has none), and carrier and tracking number when set. Orders shown here are never PII purged (spec 0008 purges only expired orders). +- **AC-13**: The auth pages are `/account/sign-in`, `/account/sign-up`, `/account/forgot-password` and `/account/confirm`, and they are public. Every other page and action under `/account` calls `requireCustomer()` itself, `/account/set-password` included (any valid session). The proxy decides before rendering: it sends a signed out visitor from a non auth account page to `/account/sign-in?next=`. As in spec 0004, it skips redirects for server action requests (`Next-Action` header) and sets `Cache-Control: no-store` on account pages and their redirects. An order number that does not exist, belongs to another customer or to no one, or has a hidden status (AC-11) renders the same not found content, with no hint which. + +*Address book* +- **AC-14**: `/account/addresses` lists the saved addresses (default first, then newest by `created_at`, then id) with Add, Edit, Delete and "Make default". + * Fields and rules are `shippingAddressSchema` from spec 0007. The country is the store's one country and cannot be edited. + * At most 10 addresses. At 10, Add is replaced by "You can save up to 10 addresses." and the action refuses an 11th. + * The first address saved becomes the default. Deleting the default makes the newest remaining address the default. + * Every write runs under the customer lock. A change of default clears the old one before setting the new one, so the partial unique index never fails. + * A saved address that no longer passes the current `shippingAddressSchema` shows "Check this address" and must be edited before checkout uses it. + +*Checkout* +- **AC-15**: Signed in checkout (`getCustomerId()` returns an id). + * The email shows read only as the account email. `startCheckout` still validates the typed email field but uses `customers.email`. + * The address form starts with the pending order's address for this cart when there is one (spec 0007 prefill), else with the default address. A list of saved addresses lets the customer swap, and they can still type a new one. + * "Save this address to my account" is ticked by default. When ticked, the save runs in the order's transaction under the customer lock. Fields are trimmed and an empty `line2` or `phone` counts as null. An address equal to a saved one, field by field, adds nothing. Otherwise a new row is added, and it is the default if it is the first. At 10 saved, the save is skipped without a message. + * A blank `customers.name` is filled from the order's `customer_name` and a blank `phone` from its `phone`. Values already set are never overwritten. + * The order gets `customer_id`. + * Before the order is created, a guest cookie cart goes through the AC-5 merge. If the merge added lines from the account cart, `startCheckout` returns `cart_changed`. The form then reloads with "Your cart was updated with items saved in your account. Check it and pay again." +- **AC-16**: A signed out visitor on `/checkout` sees "Have an account? Sign in", linking to `/account/sign-in?next=/checkout`. After sign in they are back on `/checkout` with the merged cart and their default address filled in. +- **AC-17**: `/checkout/complete` offers "Create an account to track this order" (a button, no fields) only when all of these hold: + * the order named by the session id exists, has status `paid`, `shipped` or `delivered`, has no `customer_id`, and has a non null email; + * the visitor has no session. + + `signUpFromOrder` checks the same conditions on the server and otherwise returns `not_eligible`. It reads the email from the order, never from the browser, and runs the AC-1 sign up. The page shows "Check your email." If an account already exists for that email, nothing is sent, and the order links at that customer's next sign in (AC-10). + +*Profile and security* +- **AC-18**: `/account/settings` edits name and phone (both optional, the same field rules as checkout) and shows the email read only with the note "To change your email, contact us." +- **AC-19**: Change password asks for the current password and a new one under the AC-3 rule. A user with an `admin_users` row or a verified factor is refused: "This account also runs the store. Use Forgot password on the admin sign in page." A wrong current password shows "Your current password is not right." On success every other session of the user is signed out and this one stays. +- **AC-20**: "Delete my account" asks for the password. + * If the customer has orders with status `paid`, the dialog first says "You have order(s) not shipped yet. They will still be delivered, and emails about them still go to ." + * A user with an `admin_users` row (any state) is refused: "This account also runs the store, so it cannot be deleted here." + * Otherwise, in one transaction under the customer lock: the `customers` row is deleted (its addresses and cart cascade, and its orders keep their snapshot with `customer_id` null, spec 0002 AC-7). Then `auth.admin.deleteUser(id)` runs through the service role client, then the transaction commits. If the API call fails, the transaction rolls back and the form shows "We could not delete your account. Try again." In the rare case where the commit fails after the API call, `account.delete_incomplete` is logged (error). The leftover row has no login, and AC-10 removes it if the email signs up again. + * The auth and cart cookies are cleared, and the visitor lands on the home page with "Your account is deleted." + +*Admin* +- **AC-21**: The admin order list and order detail select `customer_id` and show "Account" when it is set and "Guest" when it is null. No other admin change. + +*Platform, logs, accessibility* +- **AC-22**: The store header has a plain "Account" link to `/account`, the same for everyone. Catalog pages stay cached and never call Supabase. The proxy matcher becomes `/admin/:path*`, `/auth/:path*`, `/account/:path*`, `/checkout/:path*`. On `/account` and `/checkout` the proxy refreshes the session, and the admin check stays independent of it. +- **AC-23**: `EMAIL_TRANSPORT` gains `mailpit`, which posts every email to Mailpit's send API at `MAILPIT_URL` (with no idempotency; every call lands). The env schema refuses `mailpit` when `VERCEL_ENV` is `production` and requires `MAILPIT_URL` with it. Playwright and CI use `mailpit` (Vitest stays on `log`), so e2e reads the confirm and reset emails from Mailpit as the admin reset test does today. `supabase/config.toml` changes: + * public sign up on (`[auth] enable_signup = true`); + * `[auth.email] enable_confirmations = true` kept; + * `${site}/account/**` added to `additional_redirect_urls`; + * the AC-8 hook block; + * spec 0004's raised local rate limits kept, plus `[auth.email] max_frequency = "1s"`, so e2e can resend at once. + + Production keeps Supabase's default limits and link expiry (AC-26). +- **AC-24**: Logs carry the customer id, order ids and numbers, counts and error codes. They never carry an email, name, phone, address, password, token, token hash or idempotency key. Events: + * `account.sign_up`, `account.sign_in`, `account.sign_in_failed` (reason code), `account.confirmed`, `account.orders_linked` (count), `account.cart_merged` (lines moved); + * `account.address_saved`, `account.password_changed`, `account.password_reset`, `account.deleted`, `account.delete_refused_admin`; + * `account.email_conflict` (error), `account.delete_incomplete` (error); + * `auth_email.sent` (kind), `auth_email.failed` (kind, error name, error level, so it can alert), `auth_email.bad_signature` (warn), `auth_email.unexpected_type` (warn). +- **AC-25**: Every account page, form and dialog works by keyboard alone, labels every field, announces errors, moves focus to the first invalid field, and passes axe with zero violations on desktop and phone (same tags as spec 0003). Every account page has static `robots: noindex` metadata and reads the session only inside Suspense. +- **AC-26**: Sign in, sign up, resend and reset rely on Supabase's built in limits. The production runbook adds a Vercel Firewall rate limit rule for `POST` to `/account/*` and `/checkout/complete`. It also lists the production Supabase settings: sign up on, confirmations on, `/account/**` in the redirect URLs, and the hook URL and secret. These are applied at deploy (scope feature 19). + +## Decision + +**Chosen option**: Option 1: Supabase Auth email and password accounts in the user pool admins already use, with every auth email sent by our own Send Email Hook route through Resend and React Email. + +Customers get accounts with no new auth vendor, and the shared reset template problem from spec 0004 is solved by choosing the email in our code instead of in a Supabase template. + +**Implementation skills**: `supabase` (`supabase/agent-skills`, `.agents/skills/supabase/`) · `supabase-postgres-best-practices` (`supabase/agent-skills`, `.agents/skills/supabase-postgres-best-practices/`) · `prisma-client-api` (`prisma/skills`, `.agents/skills/prisma-client-api/`) · `react-email` (`resend/resend-skills`, `.agents/skills/react-email/`) · `resend` (`resend/resend-skills`, `.agents/skills/resend/`) · `shadcn` (`shadcn-ui/ui`, `.agents/skills/shadcn/`) + +**Decisions made in design (recommended picks, with runner up)**: +- **Code layout.** A new `src/features/account/` holds the account pages' components, actions, queries, schemas and logs. Pieces another feature also calls go to `src/lib/`: + * `src/lib/customer/session.ts`: `getCustomerId()` and `requireCustomer()`; + * `src/lib/customer/lock.ts`: the advisory lock helper; + * `src/lib/customer/setup.ts`: AC-10's upsert and order linking; + * `src/lib/customer/addresses.ts`: the save with dedupe and cap, used by checkout and the address book; + * `src/lib/cart/merge.ts`: AC-5. + + Auth email code lives in `src/lib/email/auth-hook.ts` (payload schema, template choice, link building) beside the existing transport. The templates are `emails/confirm-signup.tsx`, `emails/reset-password.tsx` and `emails/admin-reset-password.tsx`. Routes: `app/(store)/account/...` and `app/api/auth/send-email/route.ts`. The service role client stays in the existing `src/lib/supabase/admin.ts` (`server-only`). Runner up: session and linking inside `src/features/account/`, which checkout could then not import. +- **Password chosen after the confirm link**, not at sign up. GoTrue overwrites the password when someone signs up again with an unconfirmed email. So anyone, through our form or straight against the public API, could plant a password on someone else's email before that person confirms. When the inbox owner chooses the password after confirming, any planted one is gone. Runner up: password at sign up, which keeps one screen but leaves that hole. +- **`getUser()` for every account check**, a call to Supabase's server per account request. Runner up: `getClaims()` (local and fast), which keeps a deleted user working for up to an hour and would recreate their row. +- **One advisory lock per customer** (`pg_advisory_xact_lock(hashtext('customer:' || id))`) instead of row locks across `customers`, `carts` and `customer_addresses`. "No cart yet" or "no address yet" have no row to lock, and one mutex makes the lock order question go away. Runner up: lock the `customers` row first, which needs the row to exist before every path. +- **Signature check with the `standardwebhooks` package**, the reference implementation Supabase documents for its hooks, including the timestamp tolerance. Runner up: a hand written HMAC check, about 20 lines but one more thing to get subtly wrong. +- **Auth emails are not written to `email_sends`.** The hook is synchronous with one try, and the customer can press "Send the link again". The outbox in spec 0011 exists for emails owed after a commit, which these are not. Runner up: an outbox row per auth email, which would add a table kind with no retry to drive. +- **Template choice: admin first, then `redirect_to`.** Anyone with the public anon key can call Supabase's recover endpoint with any allowed `redirect_to`, so `redirect_to` is a hint, not a trust boundary. The admin check (an `admin_users` row or a TOTP factor) comes first and cannot be steered. A wrong customer or admin pick for a plain customer only changes the wording. Runner up: `redirect_to` alone, which would let an inbox owner reset an admin password with no code. +- **Password checks for change and delete** call `signInWithPassword` on the stateless client (`createSupabaseStatelessClient`, no cookies) with the session's email. The throwaway session it returns is then signed out with its own access token (a failure there is logged, not shown). Guessing is limited by Supabase's per IP sign in limits and the Firewall rule. Runner up: Supabase `reauthenticate`, which sends a code by email instead of asking for the password the customer just typed. +- **Delete: database rows and auth user in one transaction**, the API call made before commit, so a failed API call rolls back and leaves nothing half done. Runner up: auth first and then the rows, which can strand PII for good when the second step fails. +- **Not found for orders renders inside the page** (`notFound()`), which answers 200 under Cache Components. The page is private and `noindex`, so the status does not matter, and only the signed out redirect is decided in the proxy. Runner up: an ownership check in the proxy, which would put a DB query on every account request. +- **No order events for linking.** Linking only changes who can view an order, and `account.orders_linked` records it. Runner up: a `note` event per linked order, which would flood the admin timeline for a frequent guest. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +**Data model sketch** (no migration; every table below exists since spec 0002): + +| Table | Key | Relationships | Used for | +|---|---|---|---| +| `customers` | `id` uuid = Supabase auth user id (no FK into `auth`) | 1:N `customer_addresses` (Cascade), 1:1 `carts` (Cascade), 1:N `orders` (SetNull) | Created or refreshed by AC-10. `email` text unique, lower case, equal to the auth email. `name`, `phone` nullable. | +| `customer_addresses` | `id` uuid v7 | N:1 `customers` | Address book (AC-14, AC-15). `full_name`, `line1`, `line2?`, `city`, `postal_code`, `country_code` char(2), `phone?`, `is_default`. Partial unique index: one default per customer. | +| `carts` | `id` uuid v7 | `customer_id` unique nullable → `customers` | The account cart (AC-5, AC-7, AC-15). | +| `orders` | `id` uuid v7 | `customer_id` nullable → `customers` SetNull | Linked by AC-10, listed by AC-11, shown by AC-12. Existing indexes on `email` and `customer_id` serve linking and history; `email` is already lower case from checkout. | +| `admin_users` | `id` = auth user id | none to `customers` | Read by AC-8, AC-19 and AC-20. | +| `auth.users`, `auth.mfa_factors` (Supabase) | `id` | none (spec 0001) | Password, confirmation, sessions; read only through `$queryRaw` in AC-8 and AC-10. | + +**State transitions** (an account, as seen by this feature): + +`no account` → sign up → `unconfirmed` (auth user with a throwaway password, no `customers` row, cannot sign in) → Continue on the confirm link → `confirmed` (signed in, password still unknown to the customer) → choose password → `active` (`customers` row, orders linked). A `confirmed` customer who leaves before choosing can still use the session, and later reaches `active` through forgot password. `active` → Delete → `deleted` (auth user and rows gone, orders unlinked). Sessions: `signed out` ⇄ `signed in`. Choosing or changing a password ends the other sessions. + +**API surface** (server actions unless a method is given; all return `{ ok: true, data } | { ok: false, error }`): + +| Action / route | Method | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| `signUp` | action | `email` (req) | `check_email` (always) | public | `invalid` (email format only) | +| `resendConfirmation` | action | `email` (req) | `check_email` (always) | public | `invalid` | +| `confirmEmail` | action (POST from `/account/confirm`) | `tokenHash`, `type` `signup` or `recovery` (hidden inputs, req) | redirect `/account/set-password` | public, token | `link_invalid` | +| `setPassword` | action | `password` (req) | redirect `/account` | any session | `weak_password`, `leaked_password`, `same_password`, `session_missing` | +| `signInCustomer` | action | `email`, `password` (req), `next` (opt) | redirect to safe `next` or `/account` | public | `invalid_credentials`, `rate_limited`, `setup_failed` | +| `signOutCustomer` | action | none | redirect `/` | signed in | none | +| `requestPasswordReset` | action | `email` (req) | `sent` (always) | public | `invalid` | +| `updateProfile` | action | `name?`, `phone?` | profile | customer | `invalid` | +| `changePassword` | action | `currentPassword`, `newPassword` (req) | `changed` | customer | `wrong_password`, `weak_password`, `leaked_password`, `same_password`, `is_admin` | +| `deleteAccount` | action | `password` (req) | redirect `/` | customer | `wrong_password`, `is_admin`, `delete_failed` | +| `createAddress`, `updateAddress` | action | address fields (req), `id` (update) | address | customer | `invalid`, `limit_reached`, `not_found` | +| `deleteAddress`, `setDefaultAddress` | action | `id` (req) | addresses | customer | `not_found` | +| `signUpFromOrder` | action | `sessionId` (req) | `check_email` | public, session id | `not_eligible` | +| `startCheckout` (changed) | action | as today, plus `saveAddress: boolean` | as today | public or customer | as today, plus `cart_changed` | +| `addToCart` (changed) | action | as today | as today | public or customer | as today | +| `/api/auth/send-email` | POST | Standard Webhooks headers, raw body `{ user, email_data }` | `{}` 200 | signature | 401 bad signature, 400 unused type, 500 send failed | +| Pages | GET | auth pages (public): `/account/sign-in`, `/account/sign-up`, `/account/forgot-password`, `/account/confirm`; account pages (session): `/account`, `/account/orders/[number]`, `/account/addresses`, `/account/settings`, `/account/set-password` | | see AC-13 | proxy redirect when signed out | + +**Value sourcing**: + +| Action | Value produced / displayed | Source | +|---|---|---| +| `signUp` | throwaway password | 32 bytes from `crypto.randomBytes`, base64url, plus a fixed class suffix (AC-1) | +| hook | confirm or reset link | `NEXT_PUBLIC_SITE_URL` + path chosen by AC-8 + `email_data.token_hash` + type | +| hook | recipient | `user.email` in the signed payload | +| hook | which template | `email_action_type`, then `admin_users` by `user.id` and verified rows in `auth.mfa_factors`, then `email_data.redirect_to` parsed with `URL` (same origin, pathname `/account/set-password`) | +| hook | brand name, sender | `src/lib/brand`, `EMAIL_FROM` (spec 0011) | +| hook | idempotency key | `auth:` + `email_action_type` + `:` + SHA 256 of `token_hash` | +| `setPassword`, `signInCustomer` | customer id and email for AC-10 | `data.user` returned by `updateUser` or `signInWithPassword` | +| account pages and writes | customer id | `getCustomerId()` (`getUser()`) | +| `signInCustomer` | redirect target | `next` input, accepted only per AC-4 | +| AC-5 merge | line cap | `lineCap(variant.stock_quantity)` in `src/lib/availability.ts`, read under the lock | +| `/account` | paid date, total, item count, status words, page | `orders.paid_at`, `total_cents` and `currency`, sum of `order_lines.quantity`, `status` mapped in AC-11, `page` search param | +| `/account/orders/[number]` | lines, totals, address, tracking, refunded | order snapshot columns, `order_lines`, `refunded_cents`, `carrier`, `tracking_number` | +| dates | timezone and locale | `STORE_TIMEZONE`, `STORE_LOCALE` (existing env) | +| `startCheckout` signed in | email, customer id | `customers.email` of `getCustomerId()` | +| `startCheckout` | save address flag; profile fill | `saveAddress` input; order `customer_name` and `phone` | +| checkout page | prefill | pending order for this cart (spec 0007), else default `customer_addresses` row | +| address book | country | the store's country from `src/lib/shipping` (spec 0007) | +| `signUpFromOrder` | email, eligibility | the order found by `stripe_checkout_session_id`: `email`, `status`, `customer_id`; the visitor's session | +| AC-20 | open order count, admin refusal | count of the customer's `orders` with status `paid`; `admin_users` row by id | +| AC-21 | Account or Guest | `orders.customer_id` null or not | + +**Key invariants**: +- `customers.id` always equals a live or just deleted Supabase auth user id, and `customers.email` the lower cased auth email. +- A customer reads and writes only rows with their own `customer_id`; the id always comes from `getUser()` or a Supabase answer, never from input. +- An order joins an account only through AC-10 linking (verified email on an unlinked order) or a signed in checkout. Nothing ever moves an order from one customer to another. +- At most one default address and at most 10 addresses per customer. At most one cart per customer, and a merge never pushes a line above `lineCap`. +- Every write that touches one customer's cart, addresses or order links holds that customer's advisory lock. +- Account history never shows an order without `paid_at`. +- Prices and totals on account pages come from the order snapshot, never the live catalog. +- Only someone with the inbox chooses an account's password, and a password that guards `/admin` is never reset without the authenticator. +- No auth email leaves without a valid hook signature, and no token, hash or key appears in a log. + +**Security model**: +- Roles: anonymous visitor, customer (any confirmed Supabase user; the `customers` row is created on demand), admin (spec 0004, unchanged). One auth user may be both. The admin gate (`requireAdmin()`, TOTP at `aal2`, `admin_users`, 12 hour limit) never reads customer state, and a customer session at `aal1` never passes it. A customer sign in never replaces an existing session (AC-4). +- Ownership: every query in `src/features/account/` filters by `getCustomerId()`. Order detail looks up by number and customer id together. +- Enumeration: sign up, resend and forgot password answer the same, at the same speed, for every email (Supabase runs in `after()`). Sign in shows one message for a wrong password, an unknown email and an unconfirmed account. +- Takeover: the password is chosen only after the confirm link (AC-1, AC-3). Admin and authenticator users always get the admin reset flow (AC-8), and change password refuses them (AC-19). +- Re-authentication: change password and delete account need the current password. +- Sessions: other sessions end when a password is chosen or changed. Sign out is local. Account pages are `no-store`, so Back after sign out shows nothing. +- Compliance: GDPR (EU store). PII is the profile (`name`, `phone`, `email`) and addresses. Deletion erases the profile, addresses, cart and login. Paid orders keep their snapshot (name, email, phone, address) unlinked, under the accounting retention duty (spec 0002); no retention period is set yet (Follow-up). Logs never carry PII (AC-24). +- Hook route: a public URL, protected by the Standard Webhooks signature and timestamp tolerance. It only sends to the address in a signed payload. + +**Configuration required**: +- `SEND_EMAIL_HOOK_SECRET`: the Standard Webhooks secret (`v1,whsec_...`) shared by Supabase and the app, added to `src/lib/env.ts` and `.env.example`. +- `EMAIL_TRANSPORT`: gains the value `mailpit` (AC-23). +- `MAILPIT_URL`: Mailpit's base URL (local `http://127.0.0.1:55324`), required only with `mailpit`. +- `SEND_EMAIL_HOOK_URI`: read only by the Supabase CLI through `env()` in `config.toml` (local `http://host.docker.internal:/api/auth/send-email`). It is documented in `.env.example` as a CLI value, not in the app schema. +- Production (at deploy, feature 19), in the Supabase dashboard: sign up on, confirmations on, `/account/**` in the redirect URLs, and the Send Email Hook pointing at the production URL with the same secret. In Vercel: the Firewall rule (AC-26). + +**Critical test scenarios**: +- Happy path: sign up with an email, read the confirm email in Mailpit, Continue, choose a password, land on `/account` and see an earlier guest order placed with that email, verifies **AC-1**, **AC-2**, **AC-3**, **AC-8**, **AC-10**, **AC-11** +- Happy path: signed in checkout with a saved default address, ticked save, order shows in history and as "Account" in admin, verifies **AC-15**, **AC-21** +- Failure case: someone signs up with your unconfirmed email using a password they know (through the Supabase API); you confirm and choose your own; their password no longer works, verifies **AC-1**, **AC-3** +- Failure case: sign up with an existing email and with a new one give identical replies; a hook failure shows nothing on screen and logs `auth_email.failed`, verifies **AC-1**, **AC-8** +- Failure case: forgot password for an admin's email sends the admin email, and spec 0004's admin reset e2e passes through the hook, verifies **AC-8**, **AC-9** +- Failure case: guest cart and account cart with the same variant merge to the summed quantity capped at stock; two browsers signing in to one account at once leave one account cart; two address saves at once keep one default, verifies **AC-5**, **AC-14** +- Failure case: a deleted account's session from another device cannot read or write anything and does not recreate the row; delete refused for an admin; a failed admin API call leaves the account intact, verifies **AC-10**, **AC-20** +- Auth/permission: customer A opening customer B's order number sees not found; a signed out visitor on `/account` is redirected to sign in; Back after sign out shows no account data; a customer session cannot open `/admin`, verifies **AC-13**, **AC-22** + +## Build plan + +Tracer Bullet: the first task drives one real customer from sign up to their account page through every layer: Supabase config, the hook, the transport via Mailpit, the confirm and set password pages, the session, the DB and the proxy. Each later task thickens one segment end to end, with its own Vitest and Playwright coverage. + +1. [ ] Thin thread: + * `supabase/config.toml`: sign up on, confirmations on, redirect URLs, the hook block via `env()`, `max_frequency`. + * `SEND_EMAIL_HOOK_SECRET` and `MAILPIT_URL` in `src/lib/env.ts` and `.env.example`; the `mailpit` transport and its env rules; Playwright and CI switched to `mailpit`. + * `standardwebhooks`, and the hook route with the confirm template only. + * `signUp` and `/account/sign-up`; `/account/confirm` with `confirmEmail`; `/account/set-password` with `setPassword`. + * `src/lib/customer/`: lock, session with `getUser()`, setup with upsert, stale row guard and order linking. + * The proxy matcher and account branch (`no-store`, `Next-Action` skip), and a bare `/account` order list. + + Check early that the Supabase CLI substitutes `env()` in the hook `uri` and reaches the app from Docker in CI. E2E: sign up, confirm from Mailpit, choose a password, see an earlier guest order. Satisfies **AC-1**, **AC-2**, **AC-3**, **AC-10**, **AC-11**, **AC-13**, **AC-22**, **AC-23** +2. [ ] Sign in and out: `signInCustomer` with safe `next`, the existing session redirect and the generic messages; `resendConfirmation`; `signOutCustomer` clearing the cart cookie; `src/lib/cart/merge.ts` with DB tests for every merge case and concurrent sign ins; `addToCart` attaching new carts. Satisfies **AC-4**, **AC-5**, **AC-6**, **AC-7** +3. [ ] Password reset through the hook: customer and admin reset templates, the admin first template choice (`admin_users`, `auth.mfa_factors`), the URL parsed `redirect_to`, the unused type answer, `requestPasswordReset`, and the recovery Continue into `/account/set-password`. Then remove `supabase/templates/recovery.html` and keep the spec 0004 admin reset e2e green. Satisfies **AC-8**, **AC-9** +4. [ ] Order history in full: pagination, status words, the `paid_at` filter, the order detail page, not found for every hidden case, DB tests for ownership. Satisfies **AC-11**, **AC-12**, **AC-13** +5. [ ] Address book: `src/lib/customer/addresses.ts` (save, dedupe with null and empty treated alike, cap, default rules under the lock, "Check this address"), the page and its actions, DB tests for the default and cap races. Satisfies **AC-14** +6. [ ] Checkout: signed in prefill (pending order first) and address list, read only email, `saveAddress` and profile fill in `startCheckout`, the merge with `cart_changed`, the sign in link, and the confirmation page button with `signUpFromOrder` and its eligibility rules. Satisfies **AC-15**, **AC-16**, **AC-17** +7. [ ] Settings: profile, change password with the admin refusal, delete account with the admin refusal, the open order warning and the single transaction delete. Satisfies **AC-18**, **AC-19**, **AC-20** +8. [ ] Admin badge on the order list and detail. Satisfies **AC-21** +9. [ ] Safety net: the `account.*` and `auth_email.*` log events with a redaction test, axe on every account page (desktop and phone), the header Account link, the runbook additions for production (Supabase settings, redirect URLs, hook URL and secret, Firewall rule), and the account `AGENTS.md` facts handed to `/sync`. Satisfies **AC-22**, **AC-24**, **AC-25**, **AC-26** + +## Consequences + +**Positive**: +- No new auth vendor and no migration: the tables from spec 0002 and the session code from spec 0004 carry the feature. +- All mail (order and auth) shares one look, one sender and one transport, and e2e can now read every email from Mailpit. +- The shared reset template problem from spec 0004 is fixed in code, and admin passwords keep needing the authenticator to reset. +- Sign up reveals nothing about which emails have accounts, and a planted password never survives a confirm. +- Guests lose nothing: checkout is unchanged for them, and their earlier orders appear the moment they make an account. + +**Negative / tradeoffs**: +- Auth email delivery now depends on our app being up. If the hook route or Resend is down, nobody can sign up or reset a password (admins included) until it recovers. Supabase's own mailer is no longer a fallback, and the failure shows only in logs, not to the customer. +- Sign up takes three steps (email, link, password) instead of two. +- Every account request makes one `getUser()` call to Supabase. +- Local and CI setups gain a moving part: the Supabase container must reach the app at `SEND_EMAIL_HOOK_URI`, and the port must match the dev server (3000 or 3100). +- Admins and customers share one session per browser: signing out on the storefront signs an admin out of `/admin` in that browser. +- Someone who places a guest order with your email puts that order, with their delivery address, into your history once linked. They already sent the confirmation to your inbox, so nothing new is revealed, but it can confuse. +- After deletion, paid orders keep their PII for accounting with no retention limit yet, and signing up again with the same email re-links them. +- A guest cart merged away on sign in may leave its pending checkout order with `cart_id` null. That Stripe session can still be paid until it expires, and a customer who pays both it and a new checkout gets two orders. +- No email change and no data export in this feature. + +**Neutral**: +- `/checkout` now passes through the proxy for session refresh. +- `addToCart` reads the session only when it creates a cart. +- Spec 0004's AC-10 note "Public sign up is off" and its recovery template no longer hold; spec 0004 needs an amendment line pointing here. + +## Follow-up + +- [ ] Amend spec 0004: public sign up is on (spec 0004 AC-10), and the admin reset email is sent by the hook from this spec, not `supabase/templates/recovery.html`. +- [ ] Customer email change through Supabase secure email change, keeping `customers.email` and order linking in step (deferred by decision). +- [ ] Data retention for paid orders: set the accounting retention period, then extend the spec 0008 purge to settled orders past it. Add a data export (GDPR access and portability) for account holders. +- [ ] Spec 0011 follow-up: order emails can now link to `/account/orders/[number]` for account orders. +- [ ] Production (feature 19): Supabase sign up and confirmations on, `/account/**` redirect URLs, Send Email Hook URL and secret, Firewall rule for `/account/*` and `/checkout/complete`. +- [ ] An admin customers page (search customers, see their orders) as its own feature if support needs it. +- [ ] `/sync`: add `src/features/account/AGENTS.md`, record `src/lib/customer/` and the hook route in root `AGENTS.md`, and add the hook to the route handler list in the Rules (Stripe webhook, auth callback, cron, and now the auth email hook). diff --git a/docs/specs/0012-customer-accounts/rationale.md b/docs/specs/0012-customer-accounts/rationale.md new file mode 100644 index 0000000..4187843 --- /dev/null +++ b/docs/specs/0012-customer-accounts/rationale.md @@ -0,0 +1,69 @@ +# 0012. Customer accounts: rationale + +## Context + +Every order so far is a guest order. A customer who wants to know where an order is has only the confirmation email and a confirmation URL keyed by the Stripe session id. They retype their address at every checkout. Scope feature 12 asks for optional accounts: sign up, sign in, password reset, past orders, a saved address, guest checkout unchanged, and earlier guest orders visible once a guest makes an account with the same email. + +Much of the ground is already laid. Spec 0001 chose Supabase Auth through `@supabase/ssr` for both admins and customers. Spec 0002 created `customers`, `customer_addresses` and a nullable `carts.customer_id`, and planned guest order linking by verified email. Spec 0004 built the admin side on the same Supabase project: public sign up off, a password policy that applies to every user in the project, one recovery email template titled for admins, the scanner safe confirm page, and the rule that one auth user may be both an admin and a customer. Its follow up says this feature must turn sign up back on, widen the proxy, give customer reset its own email, and keep the admin check independent of customer state. + +The forces: the store is small and EU based (GDPR applies to profiles and addresses), there are no deploys yet, and spec 0004 decided storefront pages never call Supabase so the catalog stays cached. Sign in is money adjacent (saved addresses, order history), so the feature carries the GA tier. Supabase Auth email templates are configured per project, not per audience, so admins and customers would receive the same reset email unless the store takes over sending. Supabase only accepts one Send Email Hook per project, and when it is on, Supabase sends nothing itself, which removes the Mailpit capture the admin reset e2e uses today. + +## Options considered + +### Option 1: Supabase Auth, shared user pool, our own Send Email Hook + +Customers sign up with email and password in the same Supabase project as admins. Supabase calls our route for every auth email; the route picks the customer or admin template and sends it through the existing Resend transport with React Email. + +**Pros**: +- No new vendor; reuses the session plumbing, proxy and confirm pattern from spec 0004 and the email stack from spec 0011. +- Admin and customer emails differ, and every email shares one look and one sender. +- E2E reads auth emails from Mailpit through the new transport, which also makes order emails checkable. + +**Cons**: +- Auth emails stop when the app or Resend is down; no Supabase fallback. +- The hook needs a signature check, a route, three templates, and the CLI must reach the app from Docker. + +### Option 2: Supabase Auth, shared user pool, Supabase SMTP templates + +Same accounts, but keep Supabase's mailer: reword the recovery template to neutral wording and let the link's `next` decide where it lands. + +**Pros**: +- Least code: two template files and config. +- Supabase keeps sending even if the app is down. + +**Cons**: +- Admins and customers get the same reset email; the copy can't speak to either. +- Templates are Go HTML outside React Email, so two template systems and two looks. +- Production needs Resend SMTP credentials configured in Supabase separately from the app's key. + +### Option 3: A separate auth system for customers + +A second Supabase project, or a hosted provider, only for customers, so admin and customer identities never mix. + +**Pros**: +- Full isolation: customer sign up can never touch an admin user, sessions are separate. +- Each pool gets its own password policy and templates. + +**Cons**: +- Two sets of keys, cookies, proxies and session code; spec 0001 picked one platform to avoid exactly this. +- A hosted provider is a new paid vendor for a feature Supabase already covers. + +### Option 4: Passwordless email codes + +Customers sign in with a 6 digit code by email, no password at all. + +**Pros**: +- No password storage, no reset flow, no password change or re-authentication screens. + +**Cons**: +- Every sign in needs the inbox, which adds friction at checkout, the moment it matters most. +- The scope asks for password reset, so it changes the requirement rather than meeting it. +- Shares the project wide email template problem of Option 2. + +## Rationale + +The project already runs on one Supabase project with a working session layer, so accounts belong there (Options 1 and 2), not in a second identity system (Option 3) that doubles the auth surface for a store with a few admins. Passwords stay (not Option 4) because the scope asks for reset and because checkout is the worst place to make someone fetch a code. + +Between 1 and 2, the deciding force is that Supabase templates are per project. With Option 2 an admin and a shopper get the same reset email, which spec 0004 already flagged as a problem, and the store would run two template systems. The hook moves that choice into code we test, reuses the Resend transport and React Email from spec 0011, and gives e2e real emails through Mailpit. Its real cost is that auth mail depends on the app being up; for a monolith whose checkout already depends on the same app, that adds little new risk, and the route is small. + +The rest of the design follows the same instinct to reuse: the account cart is the existing `carts` row, the address book is the existing table, linking is one `UPDATE` on an indexed column, and the header stays a static link so the catalog keeps its cache and spec 0004's promise that storefront pages never call Supabase. From 0b3f74665b8a804d013664351fe384adac3c976c Mon Sep 17 00:00:00 2001 From: kris1027 Date: Wed, 14 Oct 2026 17:29:43 +0200 Subject: [PATCH 02/41] feat(accounts): sign up through the Send Email Hook to the account page Co-Authored-By: Claude Opus 5.5 --- .env.example | 20 +- .github/workflows/ci.yml | 15 +- app/(store)/account/(area)/layout.tsx | 25 ++ app/(store)/account/(area)/page.tsx | 22 ++ app/(store)/account/(auth)/confirm/page.tsx | 21 ++ .../account/(auth)/forgot-password/page.tsx | 27 ++ app/(store)/account/(auth)/layout.tsx | 11 + .../account/(auth)/set-password/page.tsx | 18 + app/(store)/account/(auth)/sign-in/page.tsx | 50 +++ app/(store)/account/(auth)/sign-up/page.tsx | 40 +++ app/(store)/layout.tsx | 10 +- app/api/auth/send-email/route.ts | 10 + docs/scope/scope.md | 2 +- docs/specs/0012-customer-accounts/index.md | 4 +- emails/admin-reset-password.tsx | 44 +++ emails/components/action-link.tsx | 34 ++ emails/confirm-signup.tsx | 39 +++ emails/reset-password.tsx | 39 +++ next.config.test.ts | 24 +- next.config.ts | 8 + package.json | 1 + playwright.config.ts | 7 +- pnpm-lock.yaml | 3 + proxy.ts | 17 +- scripts/admin/create.ts | 2 +- src/features/account/actions/confirm.ts | 40 +++ .../account/actions/password-reset.ts | 38 ++ src/features/account/actions/set-password.ts | 70 ++++ src/features/account/actions/sign-in.ts | 71 ++++ src/features/account/actions/sign-up.ts | 48 +++ .../account/components/account-link.tsx | 21 ++ .../account/components/account-nav.tsx | 47 +++ src/features/account/components/auth-card.tsx | 44 +++ .../account/components/confirm-link-form.tsx | 23 ++ .../account/components/confirm-link.tsx | 76 ++++ .../account/components/email-field.tsx | 31 ++ .../components/forgot-password-form.tsx | 46 +++ .../components/new-password-fields.tsx | 66 ++++ .../account/components/orders-list.tsx | 125 +++++++ .../account/components/resend-link-form.tsx | 56 +++ .../account/components/set-password-form.tsx | 76 ++++ .../account/components/set-password.tsx | 27 ++ .../account/components/sign-in-form.tsx | 84 +++++ .../account/components/sign-out-button.tsx | 17 + .../account/components/sign-up-form.tsx | 67 ++++ .../account/components/signed-out-only.tsx | 8 + src/features/account/guards.ts | 18 + src/features/account/messages.ts | 56 +++ src/features/account/order-view.test.ts | 44 +++ src/features/account/order-view.ts | 34 ++ src/features/account/password-errors.test.ts | 26 ++ src/features/account/password-errors.ts | 31 ++ src/features/account/paths.test.ts | 59 ++++ src/features/account/paths.ts | 59 ++++ src/features/account/proxy-decision.test.ts | 47 +++ src/features/account/proxy-decision.ts | 29 ++ src/features/account/proxy.ts | 53 +++ src/features/account/queries.ts | 147 ++++++++ src/features/account/schemas.ts | 47 +++ src/features/account/settle.ts | 14 + src/features/account/sign-up-email.ts | 17 + .../account/throwaway-password.test.ts | 19 + src/features/account/throwaway-password.ts | 11 + src/features/admin-auth/actions/mfa.ts | 5 +- .../admin-auth/actions/password-reset.ts | 4 +- src/features/admin-auth/actions/sign-in.ts | 5 +- .../components/forgot-password-form.tsx | 2 +- .../components/reset-password-form.tsx | 11 +- .../admin-auth/components/sign-in-form.tsx | 2 +- .../admin-auth/components/totp-code-form.tsx | 2 +- src/features/admin-auth/factors.ts | 2 +- src/features/admin-auth/messages.ts | 10 - .../admin-auth/proxy-decision.test.ts | 11 +- src/features/admin-auth/schemas.test.ts | 31 +- src/features/admin-auth/schemas.ts | 27 +- src/features/admin-auth/sign-out.ts | 2 +- .../auth/errors.test.ts} | 2 +- .../auth-errors.ts => lib/auth/errors.ts} | 4 +- src/lib/auth/password.test.ts | 32 ++ src/lib/auth/password.ts | 51 +++ src/lib/cart/cookie.ts | 6 + src/lib/cart/merge.ts | 113 ++++++ src/lib/customer/lock.ts | 10 + src/lib/customer/log.ts | 57 +++ src/lib/customer/session.ts | 58 ++++ src/lib/customer/setup.ts | 93 +++++ src/lib/customer/store-admin.ts | 18 + src/lib/email/auth-hook.test.ts | 86 +++++ src/lib/email/auth-hook.ts | 70 ++++ src/lib/email/log.ts | 4 +- src/lib/email/render-auth.tsx | 42 +++ src/lib/email/send-auth-email.test.ts | 223 ++++++++++++ src/lib/email/send-auth-email.ts | 100 ++++++ src/lib/email/transport.test.ts | 52 ++- src/lib/email/transport.ts | 67 +++- src/lib/env.test.ts | 35 ++ src/lib/env.ts | 18 +- src/lib/form-errors.test.ts | 30 ++ .../components => lib}/form-errors.ts | 11 +- .../supabase}/session.test.ts | 18 +- .../admin-auth => lib/supabase}/session.ts | 12 +- supabase/config.toml | 27 +- supabase/templates/recovery.html | 16 - tests/db/account-setup.db.test.ts | 324 ++++++++++++++++++ tests/db/auth-stub.ts | 41 +++ tests/e2e/account/sign-up.spec.ts | 113 ++++++ tests/e2e/account/support.ts | 98 ++++++ tests/valid-env.ts | 2 + 108 files changed, 4046 insertions(+), 186 deletions(-) create mode 100644 app/(store)/account/(area)/layout.tsx create mode 100644 app/(store)/account/(area)/page.tsx create mode 100644 app/(store)/account/(auth)/confirm/page.tsx create mode 100644 app/(store)/account/(auth)/forgot-password/page.tsx create mode 100644 app/(store)/account/(auth)/layout.tsx create mode 100644 app/(store)/account/(auth)/set-password/page.tsx create mode 100644 app/(store)/account/(auth)/sign-in/page.tsx create mode 100644 app/(store)/account/(auth)/sign-up/page.tsx create mode 100644 app/api/auth/send-email/route.ts create mode 100644 emails/admin-reset-password.tsx create mode 100644 emails/components/action-link.tsx create mode 100644 emails/confirm-signup.tsx create mode 100644 emails/reset-password.tsx create mode 100644 src/features/account/actions/confirm.ts create mode 100644 src/features/account/actions/password-reset.ts create mode 100644 src/features/account/actions/set-password.ts create mode 100644 src/features/account/actions/sign-in.ts create mode 100644 src/features/account/actions/sign-up.ts create mode 100644 src/features/account/components/account-link.tsx create mode 100644 src/features/account/components/account-nav.tsx create mode 100644 src/features/account/components/auth-card.tsx create mode 100644 src/features/account/components/confirm-link-form.tsx create mode 100644 src/features/account/components/confirm-link.tsx create mode 100644 src/features/account/components/email-field.tsx create mode 100644 src/features/account/components/forgot-password-form.tsx create mode 100644 src/features/account/components/new-password-fields.tsx create mode 100644 src/features/account/components/orders-list.tsx create mode 100644 src/features/account/components/resend-link-form.tsx create mode 100644 src/features/account/components/set-password-form.tsx create mode 100644 src/features/account/components/set-password.tsx create mode 100644 src/features/account/components/sign-in-form.tsx create mode 100644 src/features/account/components/sign-out-button.tsx create mode 100644 src/features/account/components/sign-up-form.tsx create mode 100644 src/features/account/components/signed-out-only.tsx create mode 100644 src/features/account/guards.ts create mode 100644 src/features/account/messages.ts create mode 100644 src/features/account/order-view.test.ts create mode 100644 src/features/account/order-view.ts create mode 100644 src/features/account/password-errors.test.ts create mode 100644 src/features/account/password-errors.ts create mode 100644 src/features/account/paths.test.ts create mode 100644 src/features/account/paths.ts create mode 100644 src/features/account/proxy-decision.test.ts create mode 100644 src/features/account/proxy-decision.ts create mode 100644 src/features/account/proxy.ts create mode 100644 src/features/account/queries.ts create mode 100644 src/features/account/schemas.ts create mode 100644 src/features/account/settle.ts create mode 100644 src/features/account/sign-up-email.ts create mode 100644 src/features/account/throwaway-password.test.ts create mode 100644 src/features/account/throwaway-password.ts rename src/{features/admin-auth/auth-errors.test.ts => lib/auth/errors.test.ts} (94%) rename src/{features/admin-auth/auth-errors.ts => lib/auth/errors.ts} (90%) create mode 100644 src/lib/auth/password.test.ts create mode 100644 src/lib/auth/password.ts create mode 100644 src/lib/cart/merge.ts create mode 100644 src/lib/customer/lock.ts create mode 100644 src/lib/customer/log.ts create mode 100644 src/lib/customer/session.ts create mode 100644 src/lib/customer/setup.ts create mode 100644 src/lib/customer/store-admin.ts create mode 100644 src/lib/email/auth-hook.test.ts create mode 100644 src/lib/email/auth-hook.ts create mode 100644 src/lib/email/render-auth.tsx create mode 100644 src/lib/email/send-auth-email.test.ts create mode 100644 src/lib/email/send-auth-email.ts create mode 100644 src/lib/form-errors.test.ts rename src/{features/admin-auth/components => lib}/form-errors.ts (61%) rename src/{features/admin-auth => lib/supabase}/session.test.ts (63%) rename src/{features/admin-auth => lib/supabase}/session.ts (52%) delete mode 100644 supabase/templates/recovery.html create mode 100644 tests/db/account-setup.db.test.ts create mode 100644 tests/db/auth-stub.ts create mode 100644 tests/e2e/account/sign-up.spec.ts create mode 100644 tests/e2e/account/support.ts diff --git a/.env.example b/.env.example index 89b3a84..81ec48b 100644 --- a/.env.example +++ b/.env.example @@ -38,10 +38,14 @@ VERCEL_ENV= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -# Email (spec 0011). `log` sends nothing and logs email.sent instead (local, tests, CI); -# `resend` sends through Resend and needs RESEND_API_KEY (a sending only key, re_...). -# The server refuses `log` when VERCEL_ENV is production. -EMAIL_TRANSPORT=log +# Email (spec 0011). `log` sends nothing and logs email.sent instead (unit tests); +# `mailpit` (spec 0012) hands every email to the local stack's Mailpit, so you can read it at +# MAILPIT_URL (local dev, e2e, CI); `resend` sends through Resend and needs RESEND_API_KEY +# (a sending only key, re_...). The server refuses anything but `resend` when VERCEL_ENV is +# production. +EMAIL_TRANSPORT=mailpit +# Mailpit's base URL, required with `mailpit`. Locally: http://127.0.0.1:55324 +MAILPIT_URL=http://127.0.0.1:55324 RESEND_API_KEY= # Sender and reply mailbox, `Name
` or a bare address, on a domain verified in Resend. # Replies land here, so it must be read. @@ -49,3 +53,11 @@ EMAIL_FROM=BeStore # Vercel Cron: at least 32 characters (openssl rand -hex 32). Vercel sends it to /api/cron/*. CRON_SECRET= + +# Send Email Hook (spec 0012, AC-8): Supabase hands every auth email (confirm sign up, reset +# password) to POST /api/auth/send-email, signed with this Standard Webhooks secret. Generate one +# with: echo "v1,whsec_$(openssl rand -base64 32)". The app and the Supabase CLI both read it. +SEND_EMAIL_HOOK_SECRET= +# Read only by the Supabase CLI (env() in supabase/config.toml), not by the app: where the auth +# container reaches the app. Match the port `pnpm dev` runs on; restart the stack after a change. +SEND_EMAIL_HOOK_URI=http://host.docker.internal:3000/api/auth/send-email diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 572e8aa..2ca3d10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,14 @@ jobs: env: # Hooks are for local commits only. SKIP_INSTALL_SIMPLE_GIT_HOOKS: "1" - # spec 0011, AC-16: nothing in CI sends a real email. + # spec 0011, AC-16: nothing in CI sends a real email. Unit and db tests log them; the e2e app + # hands them to the stack's Mailpit (spec 0012, AC-23, set in playwright.config.ts). EMAIL_TRANSPORT: log + MAILPIT_URL: http://127.0.0.1:55324 + # spec 0012, AC-8: the Send Email Hook. `supabase start` reads both through env() in + # supabase/config.toml, and the app checks the signature with the secret. A CI only value. + SEND_EMAIL_HOOK_SECRET: v1,whsec_Y2ktb25seS1zZW5kLWVtYWlsLWhvb2stc2VjcmV0LTAx + SEND_EMAIL_HOOK_URI: http://host.docker.internal:3000/api/auth/send-email # Only the db suites use a database. DIRECT_URL stays unset on purpose: the test:db # guard refuses a TEST_DATABASE_URL that matches it, and the runner migrates the test DB itself. TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/bestore_test @@ -49,8 +55,9 @@ jobs: - run: pnpm test:db - run: pnpm exec playwright install --with-deps chromium # Admin sign in e2e runs against a real local Supabase stack (spec 0004), never a mock. - # Services the app does not use stay off; Mailpit stays on for the password reset test and - # Storage for the product image upload (spec 0005). + # Services the app does not use stay off; Mailpit stays on for the password reset and + # account tests (spec 0012) and Storage for the product image upload (spec 0005). On Linux + # the CLI maps host.docker.internal to the runner, so the auth container reaches the hook. - name: Start local Supabase run: pnpm exec supabase start -x studio,realtime,imgproxy,edge-runtime,logflare,vector,supavisor # `supabase start` does not apply Prisma migrations. NEXT_PUBLIC_* values are baked in at @@ -78,7 +85,7 @@ jobs: # signed in the tests with this fixed webhook secret. echo "STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}" echo "STRIPE_WEBHOOK_SECRET=whsec_ci_webhook_secret" - # spec 0011, AC-16: emails are logged, never sent. + # spec 0011, AC-16: emails are logged, never sent (the e2e app uses Mailpit instead). echo "EMAIL_TRANSPORT=log" echo "EMAIL_FROM=BeStore " } >> "$GITHUB_ENV" diff --git a/app/(store)/account/(area)/layout.tsx b/app/(store)/account/(area)/layout.tsx new file mode 100644 index 0000000..dcc7041 --- /dev/null +++ b/app/(store)/account/(area)/layout.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; + +import { storeContainer } from "@/components/layout/container"; +import { AccountNav, AccountNavList } from "@/features/account/components/account-nav"; +import { SignOutButton } from "@/features/account/components/sign-out-button"; +import { cn } from "@/lib/utils"; + +// The signed in account area (spec 0012, AC-11 to AC-20). Static: each page reads the session +// itself, inside its own Suspense boundary (AC-25). +export default function AccountAreaLayout({ children }: LayoutProps<"/account">) { + return ( +
+
+

Your account

+ +
+
+ }> + + +
+ {children} +
+ ); +} diff --git a/app/(store)/account/(area)/page.tsx b/app/(store)/account/(area)/page.tsx new file mode 100644 index 0000000..39e414b --- /dev/null +++ b/app/(store)/account/(area)/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { OrdersList, OrdersListSkeleton } from "@/features/account/components/orders-list"; + +export const metadata: Metadata = { + title: "Your orders", + robots: { index: false, follow: false }, +}; + +export default function AccountOrdersPage({ searchParams }: PageProps<"/account">) { + return ( +
+

+ Orders +

+ }> + + +
+ ); +} diff --git a/app/(store)/account/(auth)/confirm/page.tsx b/app/(store)/account/(auth)/confirm/page.tsx new file mode 100644 index 0000000..56191c9 --- /dev/null +++ b/app/(store)/account/(auth)/confirm/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { AuthFormSkeleton } from "@/features/account/components/auth-card"; +import { ConfirmLink } from "@/features/account/components/confirm-link"; + +// The link carries a one time token: never indexed, never sent onward in a Referer (the header +// itself is set in next.config.ts). +export const metadata: Metadata = { + title: "Confirm your link", + robots: { index: false, follow: false }, + referrer: "no-referrer", +}; + +export default function ConfirmPage({ searchParams }: PageProps<"/account/confirm">) { + return ( + }> + + + ); +} diff --git a/app/(store)/account/(auth)/forgot-password/page.tsx b/app/(store)/account/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..560a6e9 --- /dev/null +++ b/app/(store)/account/(auth)/forgot-password/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { AccountAuthCard, footerLinkClass } from "@/features/account/components/auth-card"; +import { ForgotPasswordForm } from "@/features/account/components/forgot-password-form"; +import { accountPaths } from "@/features/account/paths"; + +export const metadata: Metadata = { + title: "Reset your password", + robots: { index: false, follow: false }, +}; + +export default function ForgotPasswordPage() { + return ( + + Back to sign in + + } + > + + + ); +} diff --git a/app/(store)/account/(auth)/layout.tsx b/app/(store)/account/(auth)/layout.tsx new file mode 100644 index 0000000..415e458 --- /dev/null +++ b/app/(store)/account/(auth)/layout.tsx @@ -0,0 +1,11 @@ +import { storeContainer } from "@/components/layout/container"; +import { cn } from "@/lib/utils"; + +// The account auth pages: one narrow card in the store's frame. +export default function AccountAuthLayout({ children }: LayoutProps<"/account">) { + return ( +
+
{children}
+
+ ); +} diff --git a/app/(store)/account/(auth)/set-password/page.tsx b/app/(store)/account/(auth)/set-password/page.tsx new file mode 100644 index 0000000..3b4006d --- /dev/null +++ b/app/(store)/account/(auth)/set-password/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { AuthFormSkeleton } from "@/features/account/components/auth-card"; +import { SetPassword } from "@/features/account/components/set-password"; + +export const metadata: Metadata = { + title: "Choose your password", + robots: { index: false, follow: false }, +}; + +export default function SetPasswordPage({ searchParams }: PageProps<"/account/set-password">) { + return ( + }> + + + ); +} diff --git a/app/(store)/account/(auth)/sign-in/page.tsx b/app/(store)/account/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..b6ec90f --- /dev/null +++ b/app/(store)/account/(auth)/sign-in/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Suspense } from "react"; + +import { + AccountAuthCard, + AuthFormSkeleton, + footerLinkClass, +} from "@/features/account/components/auth-card"; +import { SignInForm } from "@/features/account/components/sign-in-form"; +import { SignedOutOnly } from "@/features/account/components/signed-out-only"; +import { accountPaths } from "@/features/account/paths"; + +export const metadata: Metadata = { + title: "Sign in", + robots: { index: false, follow: false }, +}; + +export default function SignInPage({ searchParams }: PageProps<"/account/sign-in">) { + return ( + + New here? + + Create an account + + + } + > + }> + + + + + + ); +} + +// `next` is passed on as typed; the action accepts it only when it is safe (AC-4). +async function SignInWithNext({ + searchParams, +}: { + readonly searchParams: PageProps<"/account/sign-in">["searchParams"]; +}) { + const { next } = await searchParams; + return ; +} diff --git a/app/(store)/account/(auth)/sign-up/page.tsx b/app/(store)/account/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..393ac40 --- /dev/null +++ b/app/(store)/account/(auth)/sign-up/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Suspense } from "react"; + +import { + AccountAuthCard, + AuthFormSkeleton, + footerLinkClass, +} from "@/features/account/components/auth-card"; +import { SignedOutOnly } from "@/features/account/components/signed-out-only"; +import { SignUpForm } from "@/features/account/components/sign-up-form"; +import { accountPaths } from "@/features/account/paths"; + +export const metadata: Metadata = { + title: "Create an account", + robots: { index: false, follow: false }, +}; + +export default function SignUpPage() { + return ( + + Already have an account? + + Sign in + + + } + > + }> + + + + + + ); +} diff --git a/app/(store)/layout.tsx b/app/(store)/layout.tsx index 77b2f9d..e1e2560 100644 --- a/app/(store)/layout.tsx +++ b/app/(store)/layout.tsx @@ -1,15 +1,19 @@ import { Suspense } from "react"; import { StoreShell } from "@/components/layout/store-shell"; +import { AccountLink } from "@/features/account/components/account-link"; import { CartLink, CartLinkView } from "@/features/cart/components/cart-link"; export default function StoreLayout({ children }: LayoutProps<"/">) { return ( }> - - + <> + + }> + + + } > {children} diff --git a/app/api/auth/send-email/route.ts b/app/api/auth/send-email/route.ts new file mode 100644 index 0000000..9d4cebf --- /dev/null +++ b/app/api/auth/send-email/route.ts @@ -0,0 +1,10 @@ +import { connection } from "next/server"; + +import { handleSendEmailHook } from "@/lib/email/send-auth-email"; + +// Supabase's Send Email Hook (spec 0012, AC-8): every auth email goes out from here. +export async function POST(request: Request) { + // Request time only: never prerendered or cached. + await connection(); + return handleSendEmailHook(request); +} diff --git a/docs/scope/scope.md b/docs/scope/scope.md index fed6eb4..fca7b44 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -235,7 +235,7 @@ Optional accounts: guest checkout stays the default, customers who sign up see t spec [0012](../specs/0012-customer-accounts/index.md) - [x] Design it (spec): `/architect customer accounts` - [ ] Build it: `/develop customer accounts` - - [ ] Thin thread: config, the Send Email Hook, Mailpit transport, sign up, confirm, choose password, account setup and a bare order list (AC-1 to AC-3, AC-10, AC-11, AC-13, AC-22, AC-23) + - [x] Thin thread: config, the Send Email Hook, Mailpit transport, sign up, confirm, choose password, account setup and a bare order list (AC-1 to AC-3, AC-10, AC-11, AC-13, AC-22, AC-23) - [ ] Sign in, sign out, cart merge and password reset through the hook (AC-4 to AC-9) - [ ] Account area: order history and detail, address book (AC-11 to AC-14) - [ ] Checkout integration, settings, delete account and the admin badge (AC-15 to AC-21) diff --git a/docs/specs/0012-customer-accounts/index.md b/docs/specs/0012-customer-accounts/index.md index 2d53627..99e715f 100644 --- a/docs/specs/0012-customer-accounts/index.md +++ b/docs/specs/0012-customer-accounts/index.md @@ -1,7 +1,7 @@ # 0012. Customer accounts **Date**: 2026-10-14 (amended the same day after a cross check: AC-1 to AC-5, AC-7 to AC-11, AC-13 to AC-15, AC-17, AC-19, AC-20, AC-23, AC-24, AC-26) -**Status**: Proposed +**Status**: In Progress ## Summary @@ -250,7 +250,7 @@ Reasoning and options: see [rationale.md](rationale.md). Tracer Bullet: the first task drives one real customer from sign up to their account page through every layer: Supabase config, the hook, the transport via Mailpit, the confirm and set password pages, the session, the DB and the proxy. Each later task thickens one segment end to end, with its own Vitest and Playwright coverage. -1. [ ] Thin thread: +1. [x] Thin thread: * `supabase/config.toml`: sign up on, confirmations on, redirect URLs, the hook block via `env()`, `max_frequency`. * `SEND_EMAIL_HOOK_SECRET` and `MAILPIT_URL` in `src/lib/env.ts` and `.env.example`; the `mailpit` transport and its env rules; Playwright and CI switched to `mailpit`. * `standardwebhooks`, and the hook route with the confirm template only. diff --git a/emails/admin-reset-password.tsx b/emails/admin-reset-password.tsx new file mode 100644 index 0000000..d09b6ce --- /dev/null +++ b/emails/admin-reset-password.tsx @@ -0,0 +1,44 @@ +import { Text } from "react-email"; + +import { ActionLink } from "./components/action-link"; +import { type EmailFrame, EmailLayout, textStyle } from "./components/layout"; +import { fixtureFrame } from "./fixtures"; + +// spec 0004, AC-9, sent by the Send Email Hook since spec 0012 (AC-8): the wording of the old +// Supabase template. The link always goes to /auth/confirm, where the reset waits behind the +// authenticator, whatever redirect the request asked for. + +export type AdminResetPasswordProps = { + readonly frame: EmailFrame; + readonly resetUrl: string; +}; + +export const adminResetPasswordSubject = "Reset your admin password"; + +export function AdminResetPasswordEmail(props: AdminResetPasswordProps) { + return ( + + + Someone asked to reset the password for this admin account. If it was you, open the link + below and follow the steps. You will need your authenticator app. + + + + The link works once and expires in one hour. If you did not ask for this, you can ignore + this email; your password stays the same. + + + ); +} + +AdminResetPasswordEmail.PreviewProps = { + frame: { ...fixtureFrame, greetingName: null }, + resetUrl: "http://localhost:3000/auth/confirm?token_hash=pkce_example&type=recovery", +} satisfies AdminResetPasswordProps; + +// React Email's preview server (`pnpm email:dev`) finds a template by its default export. +export default AdminResetPasswordEmail; diff --git a/emails/components/action-link.tsx b/emails/components/action-link.tsx new file mode 100644 index 0000000..3734e20 --- /dev/null +++ b/emails/components/action-link.tsx @@ -0,0 +1,34 @@ +import { Button, Link, Text } from "react-email"; + +import { colors, mutedStyle } from "./layout"; + +// The one action of an auth email: a button, then the same address as text for mail apps that +// drop buttons. Dark on white and white on dark both pass WCAG AA. +export function ActionLink({ href, label }: { readonly href: string; readonly label: string }) { + return ( + <> + + + Or copy this link into your browser:{" "} + + {href} + + + + ); +} diff --git a/emails/confirm-signup.tsx b/emails/confirm-signup.tsx new file mode 100644 index 0000000..9474b64 --- /dev/null +++ b/emails/confirm-signup.tsx @@ -0,0 +1,39 @@ +import { Text } from "react-email"; + +import { ActionLink } from "./components/action-link"; +import { type EmailFrame, EmailLayout, textStyle } from "./components/layout"; +import { fixtureFrame } from "./fixtures"; + +// spec 0012, AC-1, AC-2 and AC-8: sent by the Send Email Hook for a sign up. The link opens a page +// with a Continue button, so a mail scanner that opens it spends nothing. + +export type ConfirmSignupProps = { + readonly frame: EmailFrame; + readonly confirmUrl: string; +}; + +export const confirmSignupSubject = "Confirm your email"; + +export function ConfirmSignupEmail(props: ConfirmSignupProps) { + return ( + + + Someone asked to create a {props.frame.storeName} account with this email. If it was you, + confirm your email, then choose your password. + + + + The link works once and expires in one hour. If you did not ask for this, ignore this email + and no account is made. + + + ); +} + +ConfirmSignupEmail.PreviewProps = { + frame: { ...fixtureFrame, greetingName: null }, + confirmUrl: "http://localhost:3000/account/confirm?token_hash=pkce_example&type=signup", +} satisfies ConfirmSignupProps; + +// React Email's preview server (`pnpm email:dev`) finds a template by its default export. +export default ConfirmSignupEmail; diff --git a/emails/reset-password.tsx b/emails/reset-password.tsx new file mode 100644 index 0000000..2e0edb0 --- /dev/null +++ b/emails/reset-password.tsx @@ -0,0 +1,39 @@ +import { Text } from "react-email"; + +import { ActionLink } from "./components/action-link"; +import { type EmailFrame, EmailLayout, textStyle } from "./components/layout"; +import { fixtureFrame } from "./fixtures"; + +// spec 0012, AC-8 and AC-9: a customer's password reset, sent by the Send Email Hook. Users who +// run the store get admin-reset-password.tsx instead. + +export type ResetPasswordProps = { + readonly frame: EmailFrame; + readonly resetUrl: string; +}; + +export const resetPasswordSubject = "Reset your password"; + +export function ResetPasswordEmail(props: ResetPasswordProps) { + return ( + + + Someone asked to reset the password for your {props.frame.storeName} account. If it was you, + open the link and choose a new password. + + + + The link works once and expires in one hour. If you did not ask for this, ignore this email; + your password stays the same. + + + ); +} + +ResetPasswordEmail.PreviewProps = { + frame: { ...fixtureFrame, greetingName: null }, + resetUrl: "http://localhost:3000/account/confirm?token_hash=pkce_example&type=recovery", +} satisfies ResetPasswordProps; + +// React Email's preview server (`pnpm email:dev`) finds a template by its default export. +export default ResetPasswordEmail; diff --git a/next.config.test.ts b/next.config.test.ts index 5dcf0da..0ccf8a5 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -10,17 +10,21 @@ describe("next config", () => { }); describe("confirmation page headers", () => { - it("sends no referrer and noindex on /checkout/complete (spec 0006, AC-11)", async () => { - const rules = (await nextConfig.headers?.()) ?? []; - const rule = rules.find((entry) => entry.source === "/checkout/complete"); + // spec 0006, AC-11 and spec 0012, AC-2: both URLs carry a key. + it.each(["/checkout/complete", "/account/confirm"])( + "sends no referrer and noindex on %s", + async (source) => { + const rules = (await nextConfig.headers?.()) ?? []; + const rule = rules.find((entry) => entry.source === source); - expect(rule?.headers).toEqual( - expect.arrayContaining([ - { key: "Referrer-Policy", value: "no-referrer" }, - { key: "X-Robots-Tag", value: "noindex" }, - ]), - ); - }); + expect(rule?.headers).toEqual( + expect.arrayContaining([ + { key: "Referrer-Policy", value: "no-referrer" }, + { key: "X-Robots-Tag", value: "noindex" }, + ]), + ); + }, + ); }); describe("productImagesConfig", () => { diff --git a/next.config.ts b/next.config.ts index df52ac6..1d8c987 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,6 +20,14 @@ const nextConfig: NextConfig = { { key: "X-Robots-Tag", value: "noindex" }, ], }, + // The same for the account confirm link, which carries a one time token (spec 0012, AC-2). + { + source: "/account/confirm", + headers: [ + { key: "Referrer-Policy", value: "no-referrer" }, + { key: "X-Robots-Tag", value: "noindex" }, + ], + }, ]; }, }; diff --git a/package.json b/package.json index 7f8f685..047acaf 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "resend": "^6.32.1", "server-only": "^0.0.1", "shadcn": "^4.21.0", + "standardwebhooks": "1.1.1", "stripe": "^22.6.2", "tw-animate-css": "^1.4.0", "zod": "^4.6.5" diff --git a/playwright.config.ts b/playwright.config.ts index 13abbfa..4f8faa1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,19 +15,20 @@ export default defineConfig({ ], // CI tests what ships: a production build. Locally the dev server is faster to iterate on. // spec 0011, AC-16: the app under test never sends a real email (Playwright merges this env - // over its own). + // over its own). spec 0012, AC-23: every email lands in the local stack's Mailpit, where the + // account and admin reset tests read it. webServer: isCI ? { command: "pnpm build && pnpm start", url: "http://localhost:3000", reuseExistingServer: false, timeout: 300_000, - env: { EMAIL_TRANSPORT: "log" }, + env: { EMAIL_TRANSPORT: "mailpit", MAILPIT_URL: "http://127.0.0.1:55324" }, } : { command: "pnpm dev", url: "http://localhost:3000", reuseExistingServer: true, - env: { EMAIL_TRANSPORT: "log" }, + env: { EMAIL_TRANSPORT: "mailpit", MAILPIT_URL: "http://127.0.0.1:55324" }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f362b3..def7cae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: shadcn: specifier: ^4.21.0 version: 4.21.0(supports-color@7.2.0)(typescript@5.9.3) + standardwebhooks: + specifier: 1.1.1 + version: 1.1.1 stripe: specifier: ^22.6.2 version: 22.6.2(@types/node@24.13.6) diff --git a/proxy.ts b/proxy.ts index d99413f..0f49ff8 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,15 +1,20 @@ import type { NextRequest } from "next/server"; +import { accountProxy } from "@/features/account/proxy"; import { adminAuthProxy } from "@/features/admin-auth/proxy"; -// First gate only: refreshes the Supabase session and redirects signed out visitors. -// Every admin page, action and route handler still calls requireAdmin() itself. +// First gate only: refreshes the Supabase session and redirects signed out visitors. Every admin +// page, action and route handler still calls requireAdmin() itself, and every account page and +// action requireCustomer() (spec 0012, AC-13). export function proxy(request: NextRequest) { - return adminAuthProxy(request); + const { pathname } = request.nextUrl; + if (pathname.startsWith("/admin") || pathname.startsWith("/auth")) { + return adminAuthProxy(request); + } + return accountProxy(request); } -// Storefront routes never reach Supabase (spec 0004, AC-1). Customer accounts (feature 12) -// widen this list. +// Catalog pages never reach Supabase and stay cached (spec 0004, AC-1; spec 0012, AC-22). export const config = { - matcher: ["/admin/:path*", "/auth/:path*"], + matcher: ["/admin/:path*", "/auth/:path*", "/account/:path*", "/checkout/:path*"], }; diff --git a/scripts/admin/create.ts b/scripts/admin/create.ts index 32e88a7..3ab3202 100644 --- a/scripts/admin/create.ts +++ b/scripts/admin/create.ts @@ -1,4 +1,4 @@ -import { passwordSchema } from "../../src/features/admin-auth/schemas"; +import { passwordSchema } from "../../src/lib/auth/password"; import { createPrompter, readFlags, run } from "./cli"; import { connect, createAdmin, findAuthUserId } from "./lib"; diff --git a/src/features/account/actions/confirm.ts b/src/features/account/actions/confirm.ts new file mode 100644 index 0000000..cc75ae1 --- /dev/null +++ b/src/features/account/actions/confirm.ts @@ -0,0 +1,40 @@ +"use server"; + +import { redirect } from "next/navigation"; + +import { logAccountEvent } from "@/lib/customer/log"; +import { customerIdentity } from "@/lib/customer/session"; +import { runsTheStore } from "@/lib/customer/store-admin"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; + +import { accountPaths } from "../paths"; +import { confirmLinkSchema } from "../schemas"; + +// The admin reset waits behind the authenticator (spec 0004): the same place /auth/confirm sends. +const adminResetPath = "/admin/mfa?next=%2Fadmin%2Freset-password"; + +// spec 0012, AC-2: the POST half of /account/confirm. Only a person pressing Continue spends the +// one time token, never a mail scanner that opens the link. +export async function confirmEmail(formData: FormData): Promise { + const parsed = confirmLinkSchema.safeParse({ + tokenHash: formData.get("token_hash"), + type: formData.get("type"), + }); + if (!parsed.success) redirect(`${accountPaths.confirm}?failed=1`); + const { tokenHash, type } = parsed.data; + + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.auth.verifyOtp({ type, token_hash: tokenHash }); + if (error || !data.user) redirect(`${accountPaths.confirm}?failed=1&type=${type}`); + + // A password that guards /admin is never reset from an inbox alone (Key invariants), even with + // an admin's link pasted here: it goes to the authenticator step like /auth/confirm. + if (type === "recovery" && (await runsTheStore(data.user.id))) redirect(adminResetPath); + + if (type === "signup") { + logAccountEvent("account.confirmed", { customerId: customerIdentity(data.user)?.id ?? null }); + } + redirect( + type === "recovery" ? `${accountPaths.setPassword}?from=reset` : accountPaths.setPassword, + ); +} diff --git a/src/features/account/actions/password-reset.ts b/src/features/account/actions/password-reset.ts new file mode 100644 index 0000000..44a7ede --- /dev/null +++ b/src/features/account/actions/password-reset.ts @@ -0,0 +1,38 @@ +"use server"; + +import { after } from "next/server"; + +import { logAccountEvent } from "@/lib/customer/log"; +import { env } from "@/lib/env"; +import { fieldErrors } from "@/lib/form-errors"; +import type { ActionResult } from "@/lib/result"; +import { createSupabaseStatelessClient } from "@/lib/supabase/server"; + +import { accountPaths } from "../paths"; +import { emailFormSchema } from "../schemas"; +import { settle } from "../settle"; +import type { EmailFormError } from "./sign-up"; + +// spec 0012, AC-9: the same reply for any email, at the same speed: Supabase runs in after(). The +// Send Email Hook picks the email (AC-8): an admin, or a user with an authenticator, gets the +// admin reset whatever redirect this asks for. +export async function requestPasswordReset( + values: unknown, +): Promise> { + const parsed = emailFormSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + const { email } = parsed.data; + + after(async () => { + const reason = await settle(() => + createSupabaseStatelessClient().auth.resetPasswordForEmail(email, { + redirectTo: `${env.NEXT_PUBLIC_SITE_URL}${accountPaths.setPassword}`, + }), + ); + logAccountEvent("account.reset_requested", { + sent: reason === null, + ...(reason ? { reason } : {}), + }); + }); + return { ok: true, data: null }; +} diff --git a/src/features/account/actions/set-password.ts b/src/features/account/actions/set-password.ts new file mode 100644 index 0000000..e6146a5 --- /dev/null +++ b/src/features/account/actions/set-password.ts @@ -0,0 +1,70 @@ +"use server"; + +import { redirect } from "next/navigation"; + +import { classifyAuthError } from "@/lib/auth/errors"; +import { logAccountEvent } from "@/lib/customer/log"; +import { customerIdentity } from "@/lib/customer/session"; +import { accountSetup } from "@/lib/customer/setup"; +import { runsTheStore } from "@/lib/customer/store-admin"; +import { fieldErrors } from "@/lib/form-errors"; +import { logger } from "@/lib/logger"; +import type { ActionResult } from "@/lib/result"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; + +import { accountPaths } from "../paths"; +import { type PasswordUpdateError, passwordUpdateError } from "../password-errors"; +import { newPasswordSchema } from "../schemas"; + +export type SetPasswordError = { + readonly form?: PasswordUpdateError | "session_missing" | "runs_the_store" | "setup_failed"; + readonly fields?: Partial>; +}; + +// spec 0012, AC-3: the customer chooses their password after confirming (or after a reset link), +// so only someone with the inbox ever sets it. Every other session of the user ends, account setup +// runs (AC-10), and the customer lands on /account. +export async function setPassword( + values: unknown, + from: unknown, +): Promise> { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: { form: "session_missing" } }; + + const parsed = newPasswordSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + + // A password that guards /admin is only ever changed behind the authenticator (spec 0004). + if (await runsTheStore(user.id)) return { ok: false, error: { form: "runs_the_store" } }; + + const { data, error } = await supabase.auth.updateUser({ password: parsed.data.password }); + if (error) { + const reason = passwordUpdateError(error); + if (reason === "weak_password") { + logger.error({ customerId: user.id }, "account.password.policy_mismatch"); + } + return { ok: false, error: { form: reason } }; + } + + // Whoever else holds a session is signed out. The password is set either way; a failed revoke + // is logged because those sessions may still be live. + const { error: revokeError } = await supabase.auth.signOut({ scope: "others" }); + if (revokeError) { + logger.error( + { customerId: user.id, reason: classifyAuthError(revokeError) }, + "account.password.revoke_others_failed", + ); + } + + const identity = customerIdentity(data.user); + if (identity === null) return { ok: false, error: { form: "session_missing" } }; + const setup = await accountSetup(identity); + if (!setup.ok) return { ok: false, error: { form: "setup_failed" } }; + + const reset = from === "reset"; + if (reset) logAccountEvent("account.password_reset", { customerId: identity.id }); + redirect(`${accountPaths.home}?notice=${reset ? "password_changed" : "ready"}`); +} diff --git a/src/features/account/actions/sign-in.ts b/src/features/account/actions/sign-in.ts new file mode 100644 index 0000000..01d641c --- /dev/null +++ b/src/features/account/actions/sign-in.ts @@ -0,0 +1,71 @@ +"use server"; + +import { redirect, RedirectType } from "next/navigation"; + +import { classifyAuthError } from "@/lib/auth/errors"; +import { clearCartCookie } from "@/lib/cart/cookie"; +import { logAccountEvent } from "@/lib/customer/log"; +import { customerIdentity } from "@/lib/customer/session"; +import { accountSetup } from "@/lib/customer/setup"; +import { fieldErrors } from "@/lib/form-errors"; +import type { ActionResult } from "@/lib/result"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { endLocalSession } from "@/lib/supabase/session"; + +import { accountPaths, safeAccountNext } from "../paths"; +import { signInSchema } from "../schemas"; + +export type SignInError = { + readonly form?: "invalid_credentials" | "rate_limited" | "unavailable"; + readonly fields?: Partial>; +}; + +// spec 0012, AC-4: one message for a wrong password, an unknown email and an unconfirmed account. +// On success, account setup (AC-10) links guest orders and merges the cart, then the customer goes +// to a safe `next` or to /account. +export async function signInCustomer( + values: unknown, + next: unknown, +): Promise> { + const parsed = signInSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + + const supabase = await createSupabaseServerClient(); + // A customer sign in never replaces a live session, an admin's included (AC-4). + const { + data: { user: current }, + } = await supabase.auth.getUser(); + if (current) redirect(accountPaths.home); + + const { data, error } = await supabase.auth.signInWithPassword(parsed.data); + if (error) { + const failure = classifyAuthError(error); + const reason = failure === "rejected" ? "invalid_credentials" : failure; + logAccountEvent("account.sign_in_failed", { reason }); + return { ok: false, error: { form: reason } }; + } + + const identity = customerIdentity(data.user); + if (identity === null) { + await endLocalSession(supabase); + logAccountEvent("account.sign_in_failed", { reason: "unconfirmed" }); + return { ok: false, error: { form: "invalid_credentials" } }; + } + const setup = await accountSetup(identity); + if (!setup.ok) { + await endLocalSession(supabase); + logAccountEvent("account.sign_in_failed", { customerId: identity.id, reason: "setup_failed" }); + return { ok: false, error: { form: "unavailable" } }; + } + + logAccountEvent("account.sign_in", { customerId: identity.id }); + redirect(safeAccountNext(next) ?? accountPaths.home); +} + +// AC-6: this browser only. The account cart stays in the database for the next sign in. Admins and +// customers share one session per browser, so this also ends an admin session here. +export async function signOutCustomer(): Promise { + await endLocalSession(await createSupabaseServerClient()); + await clearCartCookie(); + redirect("/", RedirectType.replace); +} diff --git a/src/features/account/actions/sign-up.ts b/src/features/account/actions/sign-up.ts new file mode 100644 index 0000000..f34ec80 --- /dev/null +++ b/src/features/account/actions/sign-up.ts @@ -0,0 +1,48 @@ +"use server"; + +import { after } from "next/server"; + +import { logAccountEvent } from "@/lib/customer/log"; +import { fieldErrors } from "@/lib/form-errors"; +import type { ActionResult } from "@/lib/result"; +import { createSupabaseStatelessClient } from "@/lib/supabase/server"; + +import { emailFormSchema } from "../schemas"; +import { settle } from "../settle"; +import { sendSignUp } from "../sign-up-email"; + +export type EmailFormError = { + readonly fields?: Partial>; +}; + +// spec 0012, AC-1: always the same "Check your email" answer, for a new email, an existing +// account (confirmed or not) and an admin's email. Supabase runs in after(), so neither the reply +// nor its timing reveals which; a failure (a rate limit, the hook) is only logged. +export async function signUp(values: unknown): Promise> { + const parsed = emailFormSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + const { email } = parsed.data; + + after(() => sendSignUp(email)); + return { ok: true, data: null }; +} + +// AC-1: "Send the link again". The same answer whatever happens. +export async function resendConfirmation( + values: unknown, +): Promise> { + const parsed = emailFormSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + const { email } = parsed.data; + + after(async () => { + const reason = await settle(() => + createSupabaseStatelessClient().auth.resend({ type: "signup", email }), + ); + logAccountEvent("account.sign_up", { + sent: reason === null, + reason: reason === null ? "resend" : `resend_${reason}`, + }); + }); + return { ok: true, data: null }; +} diff --git a/src/features/account/components/account-link.tsx b/src/features/account/components/account-link.tsx new file mode 100644 index 0000000..12f2464 --- /dev/null +++ b/src/features/account/components/account-link.tsx @@ -0,0 +1,21 @@ +import { UserIcon } from "lucide-react"; +import Link from "next/link"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +import { accountPaths } from "../paths"; + +// spec 0012, AC-22: the same plain link for everyone. It never reads the session, so the catalog +// pages keep their static shell and never call Supabase; /account sends a signed out visitor on. +export function AccountLink() { + return ( + +