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/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 0000000..d357646 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,5 @@ +# Test fixtures hold dummy `v1,whsec_...` Standard Webhooks secrets (the Supabase Send Email Hook), +# which secret scanning reads as Stripe webhook secrets. Real secrets never live in these files. +paths-ignore: + - "tests/**" + - "**/*.test.ts" 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/AGENTS.md b/AGENTS.md index b3df2b7..e7aa5a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Stored in `docs/specs/`. Format: `docs/specs/NNNN-title/index.md`. Scope lives i - **Caching**: `cacheComponents` is on. Catalog reads are `'use cache'` functions tagged from `src/lib/cache-tags.ts`; whatever changes what they return must expire the tag (`updateTag` in a server action, `revalidateTag(tag, { expire: 0 })` in a route handler). Request data (cookies, the cart) sits behind Suspense. - **Status codes**: under Cache Components a page's `notFound()` / `redirect()` answer 200 (the shell streams first). Where the real status matters, decide it in the proxy before rendering (the admin gate does this). - **Cron**: handlers under `app/api/cron/` refuse anything but `Authorization: Bearer ${CRON_SECRET}` (constant time compare); schedules live in `vercel.json`. -- **Env**: every variable is added to the Zod schema in `src/lib/env.ts` and to `.env.example`. +- **Env**: every variable the app reads is added to the Zod schema in `src/lib/env.ts` and to `.env.example`. A variable only a CLI reads (e.g. `SEND_EMAIL_HOOK_URI`, read by the Supabase CLI through `env()` in `supabase/config.toml`) goes in `.env.example` alone, marked as such. - **UI**: WCAG AA baseline on storefront and admin (labels, keyboard use, visible focus, contrast). - **Comments**: explain the why (invariants, gotchas), not the what. - **Tests**: each feature ships Vitest tests for its logic and server actions plus Playwright for its user flows. @@ -102,6 +102,7 @@ MCP servers: Supabase (connected), Stripe (connected), Vercel (connected), Playw - [src/features/checkout/AGENTS.md](src/features/checkout/AGENTS.md): the checkout form, `startCheckout` and the Stripe session, the confirmation page - [src/features/orders/AGENTS.md](src/features/orders/AGENTS.md): the Stripe webhook, order transitions, the reconcile cron, the expired order PII purge cron, admin orders - [src/features/settings/AGENTS.md](src/features/settings/AGENTS.md): the admin settings page, the shipping fee and free delivery threshold +- [src/features/account/AGENTS.md](src/features/account/AGENTS.md): customer accounts, sign up and sign in, the customer session and lock, order history, the address book, settings - [src/lib/shipping/AGENTS.md](src/lib/shipping/AGENTS.md): the shared delivery fee rule, address schema and settings reads _Drafted by /audit from the repo, worth a quick human pass. Edit freely: once a line stops matching this draft, later runs treat it as curated and will flag rather than overwrite it._ diff --git a/app/(store)/account/(area)/addresses/page.tsx b/app/(store)/account/(area)/addresses/page.tsx new file mode 100644 index 0000000..4247a53 --- /dev/null +++ b/app/(store)/account/(area)/addresses/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { AddressBook, AddressBookSkeleton } from "@/features/account/components/address-book"; + +export const metadata: Metadata = { + title: "Your addresses", + robots: { index: false, follow: false }, +}; + +export default function AccountAddressesPage() { + return ( +
+

+ Addresses +

+ }> + + +
+ ); +} 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)/orders/[number]/page.tsx b/app/(store)/account/(area)/orders/[number]/page.tsx new file mode 100644 index 0000000..1293a9b --- /dev/null +++ b/app/(store)/account/(area)/orders/[number]/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { OrderDetail, OrderDetailSkeleton } from "@/features/account/components/order-detail"; + +export const metadata: Metadata = { + title: "Order", + robots: { index: false, follow: false }, +}; + +export default function AccountOrderPage({ params }: PageProps<"/account/orders/[number]">) { + return ( + }> + + + ); +} 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/(area)/settings/page.tsx b/app/(store)/account/(area)/settings/page.tsx new file mode 100644 index 0000000..62f5479 --- /dev/null +++ b/app/(store)/account/(area)/settings/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { Settings, SettingsSkeleton } from "@/features/account/components/settings"; + +export const metadata: Metadata = { + title: "Account settings", + robots: { index: false, follow: false }, +}; + +export default function AccountSettingsPage() { + return ( +
+

+ Settings +

+ }> + + +
+ ); +} 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)/checkout/complete/page.tsx b/app/(store)/checkout/complete/page.tsx index 70d61b7..3537459 100644 --- a/app/(store)/checkout/complete/page.tsx +++ b/app/(store)/checkout/complete/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import { storeContainer } from "@/components/layout/container"; +import { OrderSignUpOffer } from "@/features/account/components/order-sign-up-offer"; import { CheckoutComplete, CheckoutCompleteSkeleton, @@ -22,6 +23,10 @@ export default function CheckoutCompletePage({ searchParams }: PageProps<"/check }> + {/* spec 0012, AC-17: a guest may turn this order into an account. */} + + + ); } 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/(store)/page.tsx b/app/(store)/page.tsx index 5bc519e..c7397ef 100644 --- a/app/(store)/page.tsx +++ b/app/(store)/page.tsx @@ -1,4 +1,7 @@ +import { Suspense } from "react"; + import { storeContainer } from "@/components/layout/container"; +import { AccountDeletedNotice } from "@/features/account/components/account-deleted-notice"; import { ProductGrid } from "@/features/catalog/components/product-grid"; import { brand } from "@/lib/brand/brand"; import { cn } from "@/lib/utils"; @@ -7,6 +10,9 @@ import { cn } from "@/lib/utils"; export default function StoreHomePage() { return (
+ + +

{brand.name}

diff --git a/app/api/auth/send-email/route.ts b/app/api/auth/send-email/route.ts new file mode 100644 index 0000000..099238a --- /dev/null +++ b/app/api/auth/send-email/route.ts @@ -0,0 +1,10 @@ +import { connection } from "next/server"; + +import { handleSendEmailHook } from "@/features/account/send-email-hook"; + +// 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/reviews/2026-10-15-feat-customer-accounts.md b/docs/reviews/2026-10-15-feat-customer-accounts.md new file mode 100644 index 0000000..cf2c652 --- /dev/null +++ b/docs/reviews/2026-10-15-feat-customer-accounts.md @@ -0,0 +1,48 @@ +# Review, feat/customer-accounts, 2026-10-15 + +**Reviewed by**: Sonnet 5.5 (author on a different model per the check skill) +**Scope**: 162 files, branch vs main +**Verdict**: Changes requested + +## Summary +This branch adds optional customer accounts on the shared Supabase user pool, a signed Send Email Hook route, an address book, cart merge, signed in checkout and account deletion. The code is careful and consistent: queries filter by the session customer id, the hook verifies the signature before anything else, and sign up and reset never reveal whether an account exists. The headline issue is that the "choose your password after confirming" defence has a hole. A password planted by an attacker survives if the real owner confirms the email and then leaves without choosing a password. + +## Major +### 🟠 A planted password survives when the owner confirms and does not set one, `src/features/account/actions/confirm.ts:34` +**Problem**: Supabase sign up is public (`enable_signup = true`), so anyone can call it directly with the victim's email and a password they know. Our hook then mails the victim a genuine "Confirm your email" link. The design says the owner's new password overwrites the planted one, but AC-3 also lets the owner leave the set password page. After `verifyOtp` the user is confirmed and signed in, and the attacker's password is still valid. +**Why it matters**: The attacker signs in with the planted password. Account setup then links every guest order for that email, so the attacker sees the victim's order history, names, phones and delivery addresses. The throwaway password in `sendSignUp` only protects sign ups made through our own form, not direct API calls. +**Suggested fix**: When a `signup` token is spent, immediately replace the password with a fresh throwaway one (service role `updateUserById`, or `updateUser` in the new session) before redirecting, so only a password chosen after the confirm can ever exist. Alternatively keep the account unusable (not linked, no orders shown) until a password has been chosen, tracked by a flag in user metadata. Add a db or action test for "confirm, abandon, old password no longer signs in". + +## Minor +### 🟡 Signed out visitors can use a customer's cart through a leftover cookie, `src/features/cart/actions.ts:39` +**Problem**: `hasLiveCart` only checks that the cookie cart exists and has not expired, not who owns it. If a customer's session ends without our sign out (expiry, sign out from another device, an admin sign out in the same browser), the cart cookie still names their account cart. A guest on that browser then reads and adds to that customer's cart. +**Why it matters**: Cart contents leak and get changed on shared computers. Money and addresses are not exposed, so this is not a blocker. +**Suggested fix**: In the cart read and add paths treat a cookie cart that has a `customer_id` as foreign when the visitor has no matching session, and start a guest cart instead. Or clear the cart cookie in the proxy when there is no session on account pages. + +### 🟡 Merging a guest cart can orphan an open Stripe session, `src/lib/cart/merge.ts:79` +**Problem**: The merge deletes the guest cart, which sets `cart_id` null on any pending order. `clearPendingOrder` in `startCheckout` then looks up pending orders by the account cart id, so it never finds the old one and its Stripe session stays open. +**Why it matters**: A guest who started checkout, went back, signed in and paid again can end up with two payable sessions for the same goods. The spec accepts the null `cart_id`, but not the duplicate payment path. +**Suggested fix**: Before deleting the guest cart, expire its pending order's session (the same way `clearPendingOrder` does), or refuse the merge while a pending order exists. + +### 🟡 Set password accepts any session without the current password, `src/features/account/actions/set-password.ts:31` +**Problem**: Anyone holding a valid customer session can change the password here with no re-check. The change password form does ask for the current one, so the two are inconsistent. +**Why it matters**: A stolen or left open session becomes a permanent account takeover. It is the documented design (the page must work right after a confirm link), so it is minor. +**Suggested fix**: Only allow this page for a session that came from `verifyOtp` in the last few minutes (for example a short lived marker cookie set by `confirmEmail`), and send everyone else to change password. + +### 🟡 Account deletion holds a database transaction open across a network call, `src/features/account/actions/settings.ts:113` +**Problem**: The advisory lock and an open Prisma transaction wait on `auth.admin.deleteUser` for up to 15 seconds. +**Why it matters**: Slow Auth responses tie up pooled connections. Rare, because it only runs on deletion. +**Suggested fix**: Acceptable for now. Note it in the runbook, and keep the 15 second cap. + +## Nits +- ⚪ `src/features/admin-auth/actions/mfa.ts:10`, the moved imports (`@/lib/auth/errors`, `@/lib/form-errors`, `@/lib/supabase/session`) sit among the relative imports; group them with the other `@/` imports (same in `password-reset.ts` and `sign-in.ts`). +- ⚪ `src/features/account/actions/sign-up-from-order.ts:17`, the eligibility lookup runs before `after()`, so its reply differs slightly from a non eligible one. Harmless because it says nothing about accounts. + +## Strengths +- The Send Email Hook checks the Standard Webhooks signature over the raw body first, refuses every action type except signup and recovery, picks the admin template from the database (not from `redirect_to`), and uses a hashed idempotency key. +- Order, address and profile reads and writes always filter by the session customer id; an order that is missing, foreign or hidden gives the same not found. +- The `getUser()` choice, the per customer advisory lock, the confirm link that only a POST spends, and safe `next` handling are well reasoned and carry why comments. +- Logs carry only ids, counts and reason codes, backed by a redaction test. + +## Test coverage +Good. Vitest covers the hook (signature, types, template choice), the actions, paths and `next` safety, password errors, proxy decisions, log redaction and env rules. The db project covers setup, order linking, address rules and limits, settings, deletion and sign up from an order. Playwright covers sign up, sign in, reset, settings, addresses, orders, checkout and axe on each page. The gap is the abandoned confirm scenario above (a planted password still works), and no test covers a signed out visitor with a leftover account cart cookie. diff --git a/docs/runbooks/admin-accounts.md b/docs/runbooks/admin-accounts.md index 0ec521d..08078a3 100644 --- a/docs/runbooks/admin-accounts.md +++ b/docs/runbooks/admin-accounts.md @@ -10,21 +10,13 @@ The production auth settings live in the Supabase dashboard, not in code, so the ### Supabase project: Authentication settings -- **Sign ups**: turn off "Allow new users to sign up". Keep the Email provider itself on (turning it off also blocks password sign in). -- **Passwords**: minimum length 8, required characters "lowercase, uppercase, digits and symbols". `passwordSchema` in `src/features/admin-auth/schemas.ts` mirrors this; keep the two in step. +- **Sign ups**: on since customer accounts (spec 0012); see [customer-accounts.md](customer-accounts.md). Signing up never makes an admin: only `admin:create` adds an `admin_users` row. Keep the Email provider itself on (turning it off also blocks password sign in). +- **Passwords**: minimum length 8, required characters "lowercase, uppercase, digits and symbols". `passwordSchema` in `src/lib/auth/password.ts` mirrors this; keep the two in step. - **MFA**: TOTP enroll and verify both on. - **Site URL**: the production domain, the same value as `NEXT_PUBLIC_SITE_URL` (for example `https://shop.example.com`). Reset links always point here, so a reset started from a preview deploy lands on production. Test password reset locally, not on previews. - **Redirect URLs**: `https:///**`. - **JWT signing keys**: switch to asymmetric keys, so `getClaims()` in the proxy verifies tokens locally without a network call. -- **Email template, "Reset password"**: subject `Reset your admin password`, body copied from [supabase/templates/recovery.html](../../supabase/templates/recovery.html). The link must stay `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery`. - -### Supabase project: custom SMTP through Resend - -Supabase's built in mailer is for testing only, so password reset emails go through Resend. - -1. In Resend, verify the sending domain (feature 11 reuses it for order emails). -2. In Supabase, Authentication, SMTP settings: host `smtp.resend.com`, port `465`, user `resend`, password a Resend API key with sending access, sender an address on the verified domain. -3. Until this is done, only the scripts can recover an admin. +- **Auth emails**: since spec 0012, Supabase hands every auth email to the app's Send Email Hook, which sends the admin reset email (`emails/admin-reset-password.tsx`, link `/auth/confirm?token_hash=...&type=recovery`) to anyone with an `admin_users` row or an authenticator, whatever page asked. The hook setup is in [customer-accounts.md](customer-accounts.md). Until it is done, only the scripts can recover an admin. ### Vercel Firewall rule (AC-15) diff --git a/docs/runbooks/customer-accounts.md b/docs/runbooks/customer-accounts.md new file mode 100644 index 0000000..5225b25 --- /dev/null +++ b/docs/runbooks/customer-accounts.md @@ -0,0 +1,43 @@ +# Runbook: customer accounts in production + +Spec: [0012](../specs/0012-customer-accounts/index.md). Customers sign up with their email, confirm it from a link, then choose a password. Every auth email (confirm sign up, reset password, the admin reset) leaves through the app's Send Email Hook and Resend, never through Supabase's own mailer. + +These are recommendations for running things safely. Each step says why, so you can judge when to skip one. They are applied at deploy (scope feature 19); nothing here is needed before then. + +## Production setup (once, before launch) + +The production auth settings live in the Supabase dashboard, not in code, so they can drift from `supabase/config.toml`. This list is the record. Check it again after any Supabase dashboard change. The admin settings in [admin-accounts.md](admin-accounts.md) still apply. + +### Supabase project: Authentication settings + +- **Sign ups**: turn on "Allow new users to sign up". Customers need it; admins are still made only by `pnpm admin:create`. +- **Email confirmations**: turn on "Confirm email". Without it a sign up is active at once, and the customer never proves they own the inbox before choosing a password (AC-1 to AC-3). +- **Redirect URLs**: keep `https:///**` and add `https:///account/**`, so the forgot password redirect to `/account/set-password` is allowed. +- **Rate limits and link expiry**: keep Supabase's defaults. The local stack raises them only so e2e can run (AC-23, AC-26). + +### Supabase project: the Send Email Hook + +Supabase calls one URL for every auth email. Previews sit behind Deployment Protection and cannot take it, so only production (and local, and CI) has a hook. + +1. Generate a secret: `echo "v1,whsec_$(openssl rand -base64 32)"`. Keep it in a password manager; it is shown once. +2. In Supabase, Authentication, Hooks, add a "Send Email" hook of type HTTPS: URL `https:///api/auth/send-email`, secret the value from step 1. +3. In Vercel, set `SEND_EMAIL_HOOK_SECRET` to the same value for the production environment, then redeploy so the app reads it. +4. Check it: use "Forgot password?" on `/account/forgot-password` with your own customer email. The email should arrive within a minute, from `EMAIL_FROM`. + +Why both sides hold the secret: the route answers 401 to anything not signed with it (AC-8), so nobody else can make the app send email. If you rotate it, change Supabase and Vercel together; in between, every sign up and reset fails. + +### Vercel Firewall rule (AC-26) + +Supabase sees every sign in, sign up and reset coming from Vercel's servers, so its per IP limits are shared by all customers. The Firewall rule is the real per IP guard. + +- Rule: rate limit, condition "Request path starts with `/account/`, or is `/checkout/complete`" and "Method is `POST`" (the account forms and the confirmation page's "Create an account" button are server actions, so they all POST to their page). +- Limit: 20 requests per 60 seconds, keyed by IP. +- Action: deny, for 10 minutes. +- Apply it to the production environment and publish the change. + +## Where to look when something goes wrong + +- **No auth email arrives** (sign up, reset, or an admin reset): the hook failed or never ran. The visitor still saw "Check your email", because Supabase runs after the reply. Look in Vercel logs for `auth_email.failed` (an error, with Resend's error name) and `auth_email.bad_signature` (the secret differs between Supabase and Vercel). Supabase's own Auth logs show `hook_failed` or `hook_timeout`. Resend or the app being down stops every sign up and reset, admins included, until it recovers. +- **`account.email_conflict`** (an error): someone's account setup found another live account holding the same email. It should not happen; compare the two `customers` rows and their `auth.users` rows before changing anything. +- **`account.delete_incomplete`** (an error): a customer's login was deleted but the database commit failed after it. The leftover `customers` row has no login and is removed when that email signs up again; it can also be deleted by hand. +- Every account log line carries the customer id, order ids, counts and reason codes, never an email, name, phone, address, password, token, token hash or idempotency key. diff --git a/docs/scope/scope.md b/docs/scope/scope.md index fd90efc..ba6a43d 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 | done | | 13 | Categories, search & filters | Slice 7 | planned | | 14 | Discounts & promo codes | Slice 8 | planned | | 15 | Admin sales dashboard | Slice 9 | planned | @@ -229,10 +229,22 @@ 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 · done · 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) · code in [src/features/account/](../../src/features/account/), [src/lib/customer/](../../src/lib/customer/), [src/lib/cart/merge.ts](../../src/lib/cart/merge.ts), [src/lib/email/](../../src/lib/email/), [emails/](../../emails/), [app/(store)/account/](../../app/(store)/account/) +- [x] Design it (spec): `/architect customer accounts` +- [x] Build it: `/develop customer accounts` + - [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) + - [x] Sign in, sign out, cart merge and password reset through the hook (AC-4 to AC-9) + - [x] Account area: order history and detail, address book (AC-11 to AC-14) + - [x] Checkout integration, settings, delete account and the admin badge (AC-15 to AC-21) + - [x] Safety net: logs, axe, header link, production runbook (AC-22, AC-24 to AC-26) + - [x] Review follow ups: account cart usable only by its owner's session, the merge count, the admin guard on the confirm password replace (AC-2, AC-5, AC-7, AC-24) +- [x] Verify it: `/check verify customer accounts` +- [x] Test it: `/test customer accounts` +- [x] Review it (fresh model): `/check review customer accounts` +- [x] Document it: `/document customer accounts` ## Slice 7: Discovery @@ -294,6 +306,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/0004-admin-sign-in/index.md b/docs/specs/0004-admin-sign-in/index.md index ad498c1..be6cc01 100644 --- a/docs/specs/0004-admin-sign-in/index.md +++ b/docs/specs/0004-admin-sign-in/index.md @@ -1,6 +1,6 @@ # 0004. Admin sign in with required TOTP -**Date**: 2026-09-25 +**Date**: 2026-09-25 (amended 2026-10-15 by [spec 0012](../0012-customer-accounts/index.md): public sign up, the recovery template, the proxy matcher) **Status**: Accepted ## Summary @@ -25,7 +25,7 @@ Admins sign in to `/admin` with an email and password through Supabase Auth, the - **AC-7**: When 12 hours have passed since a session signed in (the earliest `amr` timestamp in its token), the next `/admin` request ends that session on this device and redirects to `/admin/sign-in` with "Your session has expired. Please sign in again." - **AC-8**: Sign out in the user menu ends the session on this device only (Supabase `scope: 'local'`) and shows `/admin/sign-in` with "You have signed out." Pressing Back afterwards shows no admin content, and sessions on other devices keep working. - **AC-9**: `/admin/forgot-password` accepts an email and always shows "If that email belongs to an admin, a reset link is on its way." A reset email is sent only when the email matches an active admin. The link opens `/auth/confirm`, which shows a "Continue" button (so email link scanners that open the link cannot use it up); pressing it verifies the link, then asks for the TOTP code, then shows `/admin/reset-password`. Saving a valid new password signs out the admin's other sessions and lands them on `/admin`. A used, expired or tampered link lands on `/admin/sign-in` with "That link is invalid or has expired. Request a new one." -- **AC-10**: Supabase enforces passwords of at least 8 characters with lower case, upper case, digits and symbols. The reset page checks the same rule with the shared Zod schema and names each missing part, and shows "Choose a password you have not used here before." when Supabase answers `same_password`. Public sign up is off in `supabase/config.toml` and in the production project. +- **AC-10**: Supabase enforces passwords of at least 8 characters with lower case, upper case, digits and symbols. The reset page checks the same rule with the shared Zod schema and names each missing part, and shows "Choose a password you have not used here before." when Supabase answers `same_password`. Public sign up is off in `supabase/config.toml` and in the production project. (Amended by spec 0012: public sign up is now on for customer accounts; admins are still created only by `pnpm admin:create`, and signing up never makes an `admin_users` row.) - **AC-11**: `pnpm admin:create --email --name ` prompts twice for a hidden password that must meet AC-10, creates a confirmed Supabase auth user and the `admin_users` row, and prints the admin id. If an auth user with that email already exists (for example a future customer), it keeps that user and password, marks the email confirmed, and adds the `admin_users` row, or re-enables it and updates the name if it exists. `pnpm admin:disable --email ` sets `disabled_at`. `pnpm admin:reset-mfa --email ` deletes the admin's TOTP factors and all their sessions, so every device is signed out and they enroll again at their next sign in. Emails are stored in lower case. - **AC-12**: `next` is honored only when it is a relative path starting with `/admin` (not `//`, no backslash, no scheme, not an auth page). Anything else falls back to `/admin`, so the sign in flow cannot become an open redirect. - **AC-13**: Sign in success, sign in failure, MFA success, MFA failure, MFA enrollment, sign out, session expiry, 404 denial, reset requested and password changed are each logged as one pino event with the admin id when known, a SHA-256 hash of the lower cased email otherwise, and the client IP. No password, code, secret, token or cookie is ever logged. @@ -61,7 +61,7 @@ Admins sign in through server actions that call Supabase Auth. A second TOTP ste - **Session age**: measured from the earliest timestamp in the `amr` list (the moment of the first factor). Supabase carries `amr` across token refreshes, and a later TOTP verify bumps only the `totp` entry, never the first factor's, so neither refreshing nor verifying again resets the cap (a unit test pins this). The limit is a constant, `ADMIN_SESSION_MAX_AGE_SECONDS = 43_200`, in the feature (not an env var). Runner up: the `totp` entry's timestamp, which moves on every verify and would let the cap be extended. - **Where expiry signs out**: the proxy, because a Server Component cannot write cookies. The proxy checks session age on every `/admin` request and calls `signOut({ scope: 'local' })`. For a normal page request it redirects with `reason=expired`, copying the cleared cookies and `Cache-Control: no-store` onto the redirect response (a fresh `NextResponse.redirect` drops them otherwise). For a server action request (it carries the `Next-Action` header) it only clears the cookies and lets the request through, because a proxy redirect answering an action POST breaks the client; `requireAdmin()` inside the action then redirects. `requireAdmin()` repeats the age check everywhere in case the proxy misses a path. A disabled or non admin user is not signed out; their session is harmless because every request checks the row, and it dies at the 12 hour cap. Runner up: a GET sign out route that pages redirect to, which invites logout CSRF (a third party page signing the admin out). - **Non admin with valid credentials at the sign in form**: the action calls `signOut({ scope: 'local' })` at once (never the default `global`, which would end a future customer's sessions on every device) and shows the AC-3 message, so the form cannot be used to test whether an account is an admin. -- **Reset link flow**: the token hash pattern, with a fixed template. The recovery email template hard codes its link as `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery`, so it never depends on `redirectTo` matching the allow list. `/auth/confirm` accepts only `type=recovery` and ignores any `next`. Its GET renders a small page with a "Continue" button (the token in hidden fields); only the POST calls `verifyOtp({ type: 'recovery', token_hash })`, then redirects to `/admin/mfa?next=/admin/reset-password`. The GET step exists because corporate link scanners (for example Outlook Safe Links) open links before the person does, and a GET that verified would burn the one time token. Runner up: the PKCE code exchange (`exchangeCodeForSession`), which fails when the link is opened in a different browser than the one that asked for it. +- **Reset link flow**: the token hash pattern, with a fixed template. The recovery email template hard codes its link as `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery`, so it never depends on `redirectTo` matching the allow list. (Amended by spec 0012 AC-8: the template is gone. Supabase hands every auth email to the Send Email Hook at `/api/auth/send-email`, which sends the admin reset email, with this wording and the same `/auth/confirm` link, to any user with an `admin_users` row or a verified factor.) `/auth/confirm` accepts only `type=recovery` and ignores any `next`. Its GET renders a small page with a "Continue" button (the token in hidden fields); only the POST calls `verifyOtp({ type: 'recovery', token_hash })`, then redirects to `/admin/mfa?next=/admin/reset-password`. The GET step exists because corporate link scanners (for example Outlook Safe Links) open links before the person does, and a GET that verified would burn the one time token. Runner up: the PKCE code exchange (`exchangeCodeForSession`), which fails when the link is opened in a different browser than the one that asked for it. - **Requesting the reset**: `requestPasswordReset` sends through a separate, stateless Supabase client (`createClient` with `auth: { persistSession: false, flowType: 'implicit' }`), because the `@supabase/ssr` client defaults to PKCE and would try to set a code verifier cookie from inside `after()`, when the response is already sent. - **Reset requires TOTP**: `/admin/reset-password` requires `aal2`, so an attacker who controls only the admin's inbox cannot change the password. A reset session can only verify an existing factor, never enroll one (AC-4), which closes the path for an admin with no factor yet. After `updateUser({ password })`, the action calls `signOut({ scope: 'others' })`. Runner up: allowing the change at `aal1`, which makes the inbox alone enough to take over the password. - **Enrollment**: the enroll view is started by the "Set up authenticator" button, never on page render (a reload, prefetch or second tab would otherwise replace a QR code already scanned). `enrollTotp` first unenrolls unverified factors from `mfa.listFactors()`, then calls `mfa.enroll({ factorType: 'totp', friendlyName: 'Authenticator', issuer: brand.name })`, and returns the QR code and secret for the client to keep showing across wrong code retries. The QR code is the SVG `totp.qr_code` shown in an `` with alt text; the secret is shown as text in groups of four. It refuses when the session `amr` contains `recovery` or `otp`. @@ -269,7 +269,7 @@ Tracer Bullet: task 1 threads one real path (config, clients, proxy, script, act - [ ] `/sync`: record `otpauth` agent skills as declined in root `AGENTS.md` (none credible found; offered on 2026-09-25). - [ ] `/sync`: once built, add an `src/features/admin-auth/AGENTS.md` (or root `## Rules` line) noting that `requireAdmin()` also enforces `aal2` and the 12 hour cap, so later admin features do not add their own checks. - [ ] Spec 0001 Follow-up "Feature 5 decides where the admin role lives and the shape of `requireAdmin()`" is settled here; spec 0002 Follow-ups on `requireAdmin()`, the first admin row, and "one auth user may hold both rows" (confirmed: allowed) are settled here. -- [ ] Feature 12 (customer accounts): turn public sign up back on, widen the proxy matcher to account routes, and give customer password reset its own template path (the recovery template is shared, so the `next` in `redirectTo` must decide where the link lands). +- [x] Feature 12 (customer accounts), done in spec 0012: turn public sign up back on, widen the proxy matcher to account routes, and give customer password reset its own template path (the recovery template is shared, so the `next` in `redirectTo` must decide where the link lands). - [ ] Feature 19 (Production deploy): apply the runbook to production (Supabase auth settings, Resend SMTP, recovery template, redirect URLs, JWT keys), turn on the Vercel Firewall rule and check AC-15, then create the first real admin. Added to the scope on 2026-09-25. - [ ] Feature 11 (order emails) reuses the Resend domain verified here for Supabase SMTP. - [ ] If an admin management screen is ever wanted, it needs its own spec (invites, roles, audit of admin changes). diff --git a/docs/specs/0011-order-emails/index.md b/docs/specs/0011-order-emails/index.md index 682a444..dfaf2a2 100644 --- a/docs/specs/0011-order-emails/index.md +++ b/docs/specs/0011-order-emails/index.md @@ -1,6 +1,6 @@ # 0011. Order emails -**Date**: 2026-10-13 (amended 2026-10-14 after review: AC-7, AC-9, AC-17) +**Date**: 2026-10-13 (amended 2026-10-14 after review: AC-7, AC-9, AC-17; amended 2026-10-15 by [spec 0012](../0012-customer-accounts/index.md) AC-23: the `mailpit` transport) **Status**: Accepted ## Summary @@ -41,7 +41,7 @@ Customers get three kinds of email: a confirmation receipt when an order is paid - **AC-15**: `sendEmailAgain` calls `requireAdmin()` itself. A visitor without an active admin at `aal2` gets the spec 0004 behaviour and nothing changes. The row must belong to the order named in the input, else `not_found`. *Environments, logs, accessibility* -- **AC-16**: `EMAIL_TRANSPORT` picks `log` or `resend`. With `log`, nothing leaves the machine: delivery runs the same claim, render and status steps, marks the row `sent` with `resend_id` null, and logs `email.sent` with `transport: "log"`. The env schema refuses `log` when `VERCEL_ENV` is `production`, and requires `RESEND_API_KEY` when the transport is `resend`. The Vitest setup, the Playwright web server env and the CI workflow all set `EMAIL_TRANSPORT=log`. DB tests replace `scheduleDelivery` (no request scope for `after()`); e2e runs the real one under `next dev`. `pnpm email:dev` previews every template with fixture props. +- **AC-16**: `EMAIL_TRANSPORT` picks `log` or `resend`. With `log`, nothing leaves the machine: delivery runs the same claim, render and status steps, marks the row `sent` with `resend_id` null, and logs `email.sent` with `transport: "log"`. The env schema refuses `log` when `VERCEL_ENV` is `production`, and requires `RESEND_API_KEY` when the transport is `resend`. The Vitest setup, the Playwright web server env and the CI workflow all set `EMAIL_TRANSPORT=log`. (Amended by spec 0012 AC-23: a third value, `mailpit`, posts every email to the local Mailpit at `MAILPIT_URL`. Playwright, CI and local dev now use it, Vitest stays on `log`, and the env schema accepts only `resend` when `VERCEL_ENV` is `production`.) DB tests replace `scheduleDelivery` (no request scope for `after()`); e2e runs the real one under `next dev`. `pnpm email:dev` previews every template with fixture props. - **AC-17**: Logs carry the email row id, kind, order id and number, refund id, attempts and the Resend error name. They never carry a recipient address, subject, body, name, delivery address or tracking number. Events: `email.queued`, `email.sent`, `email.retry` (warn), `email.failed` (warn), `email.gave_up` (error), `email.idempotent_conflict` (warn), `email.config_error` (error), `email.resend_requested`, `cron.email_sweep` (counts), `email.delivery_error` (error: a try that threw, such as a database error or a template bug; it carries the error name and SQLSTATE, and only the row id when the throw came before the row was read), and `order.email_missing` (reused). - **AC-18**: The Emails section and the Send again dialog work by keyboard alone, move focus into the dialog and back out, and pass axe with zero violations (desktop and phone, same tags as spec 0003). Each email uses one column, real headings, layout tables marked `role="presentation"`, the line items as a data table with header cells, and text contrast of at least 4.5:1. @@ -155,7 +155,7 @@ Dedupe keys keep spec 0002's format (`order_confirmation:`, `order_ship - `RESEND_API_KEY` is a server only secret. `EMAIL_FROM` must be on a domain verified in Resend (SPF and DKIM set at the DNS host) before production. **Configuration required**: -- `EMAIL_TRANSPORT`: `log` or `resend` (required; `.env.example` sets `log`; `resend` required when `VERCEL_ENV=production`). +- `EMAIL_TRANSPORT`: `log` or `resend` (required; `.env.example` sets `log`; `resend` required when `VERCEL_ENV=production`). Spec 0012 adds `mailpit`, which `.env.example` now sets. - `RESEND_API_KEY`: required when `EMAIL_TRANSPORT=resend`, a sending only key (`re_...`). - `EMAIL_FROM`: sender and reply mailbox, `Name

` or a bare address, for example `BeStore `. It must be monitored, since replies land there. The schema exposes the parsed address for the footer. - Prerequisite before production: a Resend account, the sending domain verified (SPF, DKIM, a DMARC record), and the sending only key in Vercel's env. diff --git a/docs/specs/0012-customer-accounts/index.md b/docs/specs/0012-customer-accounts/index.md new file mode 100644 index 0000000..1b50e93 --- /dev/null +++ b/docs/specs/0012-customer-accounts/index.md @@ -0,0 +1,326 @@ +# 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; amended 2026-10-14 again: AC-15 now names where the order's `customer_name` comes from; amended 2026-10-15 after the code review: AC-2, AC-3, AC-5, AC-7, AC-13, AC-24, the template choice, and the leftover account cart cookie) +**Status**: Accepted + +## 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`. For `signup` and a user who does not run the store (the AC-8 admin check), `confirmEmail` first replaces the password with a new throwaway one (AC-1) and signs out every other session (`scope: 'others'`), so a password planted before the confirm never works, even when the customer leaves set password without choosing one. A failure there is logged and the customer goes on to set password, which replaces it anyway. For `recovery`, a user who runs the store (the AC-8 admin check) goes to `/admin/mfa?next=/admin/reset-password` instead, as `/auth/confirm` does, so an admin link pasted here still needs the authenticator. 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 user who runs the store (the AC-8 admin check) is refused with the AC-19 message, `runs_the_store`; their password changes only on `/admin` behind the authenticator. 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. A guest line for an archived or sold out variant moves as it is when the account cart has no line for that variant, and its flags are computed at read time as today. When the account cart already has that variant, the account line stays as it is and the guest quantity is dropped: one line per variant, and nothing there can be bought. 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. + * A cookie cart with a `customer_id` belongs to that customer only. This covers a session that ended without our sign out (expiry, sign out on another device, an admin sign out in the same browser). + - One helper in `src/lib/cart/` (`readOwnedCartId()`, plus an owned variant of `loadCart`) reads the cookie cart with its `customer_id`. When that is set it calls `getCustomerId()`, and returns no cart unless the same customer is signed in. A `getUser()` failure counts as no cart, never a 500. + - Its callers: `addToCart`, `setCartItemQuantity`, `removeCartItem`, `startCheckout`, `checkoutPrefill`, the `/cart` page and the checkout summary. They run at request time inside their existing Suspense boundaries, never in a `'use cache'` function. + - No cart means: `addToCart` takes its create branch (a guest cart, or the signed in customer's own account cart), so a signed in customer whose cookie names another customer's cart adds to their own; `setCartItemQuantity` and `removeCartItem` answer `not_found`; `startCheckout` gives its empty cart answer; the pages render their empty state. + - Only a successful add replaces the cookie. Read paths and the `not_found` answers never clear or renew it. + - `hasLiveCart` in `addToCart` becomes "live and owned": it also selects `customer_id`, so a live foreign cart no longer skips the session read. + - The header cart count keeps the raw read, so catalog pages never call Supabase (AC-22). It is the one exception and shows only a number. + * No other cart path 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`. A signed payload that does not match Supabase's shape (no user email, no token hash) also answers 400 and sends nothing, but logs `auth_email.bad_payload` with the field paths only. + * 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. Its action `setPassword` is the one exception: it reads the user with `getUser()` directly (any valid session), because it runs AC-10 itself from the user `updateUser` returns. 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. + * The order's `customer_name` is `customers.name` when that is set, else the delivery full name (`ship_full_name`). A guest order (signed out) always gets the delivery full name. Before this, nothing set `customer_name`, so order emails said "Hello," and admin search on it never matched. + * "Set" and "blank" mean exactly: blank is null or `""` (the profile schema already stores names trimmed or null), the same test the phone fill uses. No new validation: `fullName` and the profile name are both trimmed and capped at 100. `customer_name` is written only when the order is created (checkout never reuses a pending order; spec 0008's purge already nulls it on expired orders). + * A blank `customers.name` is then filled from the order's `customer_name`, and a blank `phone` from its `phone`, whether or not the save box is ticked. Values already set are never overwritten. The fill is silent: no new log event, and `customer_name` never appears in a log (AC-24). + * 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 who runs the store (an `admin_users` row in any state, or a verified factor, the AC-19 check) 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: an icon link whose accessible name is "Account", like the cart link beside it. 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` (guest lines that went into the account cart, moved or summed, not dropped; an adopted guest cart counts all its lines; no event when there was no guest cart), `account.reset_requested` (sent flag, reason code); + * `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), `auth_email.bad_payload` (warn, field paths). +- **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: the email type, then the admin check; `redirect_to` is not read.** Anyone with the public anon key can call Supabase's recover endpoint with any allowed `redirect_to`, so it is a hint, not a trust boundary. The admin check (an `admin_users` row or a TOTP factor) cannot be steered, and every other `recovery` is a customer reset, so `redirect_to` would add nothing (amended 2026-10-15; the first draft also parsed it). 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. +- **Order `customer_name`: the profile name, else the delivery full name.** It is the buyer's name as best we know it before payment, with no new checkout field. A signed in customer who set a name is greeted by it even when shipping to someone else; everyone else is greeted by the recipient's name. The blank profile fill then copies it, so it stays a one time fill that never overwrites. Existing orders are not backfilled (nothing is deployed, only local and test data). Runners up: always the delivery full name (simpler, but ignores a name the customer chose); the Stripe billing name from the webhook (`customer_details.name`, the true payer, but only after payment, it couples the profile fill to the webhook, and the cardholder may be someone else); or dropping `customer_name` and leaving the profile name to settings only. +- **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`, `runs_the_store`, `setup_failed` | +| `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`, `runs_the_store` | +| `deleteAccount` | action | `password` (req) | redirect `/` | customer | `wrong_password`, `runs_the_store`, `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`; `redirect_to` is not read | +| 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 | +| cart actions, `/cart`, `/checkout` | owner of the cookie cart | `carts.customer_id` read by `readOwnedCartId()`; when set, `getCustomerId()` must return that customer (AC-7) | +| AC-5 merge | `account.cart_merged` lines | guest lines moved or summed into the account cart, or every line of an adopted guest cart | +| 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` | order `customer_name` | signed in: `customers.name` of `getCustomerId()`, read with `tx.customer.findUnique` right after `lockCustomer` in the order's transaction, when not null or `""`; otherwise (blank profile, a missing customers row, or a guest) the parsed `fullName` that also becomes `ship_full_name`. Set as its own field on the order create, not inside `deliveryColumns` | +| `startCheckout` | save address flag; profile fill | `saveAddress` input; the order's `customer_name` (fills a blank `customers.name` through `updateMany` where name is null or `""`) and `phone` (fills a blank `customers.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 or a verified `auth.mfa_factors` row | +| 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. +- A cart with a `customer_id` is read or written only by that customer's session (AC-7). The header's item count is the one exception. +- 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** +- Happy path: the order's `customer_name` is the profile name when set, else the delivery full name (guests too), and a blank profile name is filled from it once, verifies **AC-15** +- 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: a leftover cookie naming an account cart, with no session, shows an empty `/cart` and the next add starts a guest cart, verifies **AC-7** +- 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. [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. + * `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. [x] 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. [x] Password reset through the hook: customer and admin reset templates, the admin first template choice (`admin_users`, `auth.mfa_factors`), 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. [x] 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. [x] 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. [x] Checkout: signed in prefill (pending order first) and address list, read only email, the order's `customer_name` (profile name, else the delivery full name, for guest orders too, computed in the transaction outside `saveToAccount`), `saveAddress` and the name and phone profile fill in `startCheckout` (the name fill beside the phone fill, before the save box early return), `customer_name` added to the log redaction test, 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. [x] 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. [x] Admin badge on the order list and detail. Satisfies **AC-21** +9. [x] 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** +10. [x] Review follow ups: `readOwnedCartId()` and the owned cart load in `src/lib/cart/`, wired into every AC-7 caller, `hasLiveCart` made live and owned, with DB tests (foreign cart for each action, a signed in customer holding another's cookie, a `getUser()` failure) and an e2e for a leftover cookie after the session ends; the `account.cart_merged` count (moved or summed, adoption included, none without a guest cart) and the `MergeOutcome` comment, with DB tests; the store admin guard on the signup password replace in `confirmEmail`. Satisfies **AC-5**, **AC-7**, **AC-24** + +## 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. +- On a gift order, a guest (or a signed in customer with no profile name) is greeted by the recipient's name, and that name can become their profile name. They can change it in settings. + +**Neutral**: +- `/checkout` now passes through the proxy for session refresh. +- `addToCart` reads the session only when it creates a cart. +- Order emails now greet by first name (spec 0011's `greetingName` already reads `customer_name`), and admin order search now matches it (spec 0010). +- Spec 0004's AC-10 note "Public sign up is off" and its recovery template no longer hold; spec 0004 carries an amendment line pointing here. +- A browser with a leftover account cart cookie still shows that cart's item count in the header after the session ends, until the next add replaces the cookie. It is only a number, and keeping it avoids a Supabase call on catalog pages. +- A signed in customer's own cart costs one more `getUser()` on each `/cart` and `/checkout` view and each cart action. + +## Follow-up + +- [x] 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..91454c1 --- /dev/null +++ b/docs/specs/0012-customer-accounts/rationale.md @@ -0,0 +1,73 @@ +# 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. + +### Amendment (2026-10-14): where the order's customer name comes from + +AC-15 said a blank `customers.name` is filled from the order's `customer_name`, but nothing ever set `customer_name`: checkout writes only `ship_full_name`. So the fill had no source, order emails always said "Hello,", and admin search on that column never matched. Four options were weighed: the profile name else the delivery full name (chosen), always the delivery full name, the Stripe billing name from the webhook, and dropping the column. The chosen rule needs no new field, is known before payment so the fill stays in `startCheckout`, and respects a name the customer chose. Its cost is that a gift order from someone with no profile name greets them by the recipient's name, which they can correct in settings. No backfill, since nothing is deployed. diff --git a/docs/specs/0012-customer-accounts/verify.md b/docs/specs/0012-customer-accounts/verify.md new file mode 100644 index 0000000..0d77538 --- /dev/null +++ b/docs/specs/0012-customer-accounts/verify.md @@ -0,0 +1,68 @@ +# Verify: customer accounts · spec 0012 · updated 2026-10-14 +_Steps derived from spec 0012 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +Local setup: `pnpm db:start` (the stack reads `SEND_EMAIL_HOOK_URI` and `SEND_EMAIL_HOOK_SECRET` from `.env.local`), `EMAIL_TRANSPORT=mailpit`, `pnpm dev` on the port the hook URI names. Emails show in Mailpit at http://127.0.0.1:55324. + +## UI / manual +- [x] `/account/sign-up` with a new email, an existing confirmed account's email and an admin's email → the same "Check your email" screen with "Send the link again" every time; Mailpit gets a "Confirm your email" only for the new one → AC-1 +- [x] Sign up with `Ada@Example.COM` → the auth user and later the `customers` row hold `ada@example.com` → AC-1 +- [x] Stop Mailpit or break the hook secret, sign up → the screen is unchanged; the log has `auth_email.failed` or `auth_email.bad_signature`, never the address → AC-1, AC-8, AC-24 +- [x] Open the confirm link with `curl` (a scanner) → 200, `Referrer-Policy: no-referrer`, the token still works afterwards → AC-2 +- [x] Continue on the confirm link → `/account/set-password`; Continue again on the same link → "This link has expired or was already used." with the resend form → AC-2 +- [x] `/account/confirm?type=magiclink&token_hash=x` and a link with no hash → the expired message, no Continue → AC-2 +- [ ] On `/account/set-password`, type `short` → every missing part named; a password from a leak (with HIBP on in production) → the leaked message; the old password after a reset → "Choose a password you have not used here before." → AC-3 +- [x] Choose a password with a second browser signed in to the same account → lands on `/account` with "Your account is ready."; the other browser is signed out → AC-3 +- [x] Plant a password through the Auth API before the owner confirms; the owner confirms and chooses theirs → the planted password no longer signs in → AC-1, AC-3 +- [x] `/account/sign-in` with a wrong password, an unknown email, an unconfirmed account → the same "That email and password do not match." → AC-4 +- [x] Sign in with `next` set to `/checkout`, `/account/orders/1001`, `//evil.example`, `/account/sign-in`, `%2F%2Fevil.example` → only the first two are followed, the rest land on `/account` → AC-4 +- [x] Signed in as an admin at `/admin`, open `/account/sign-in` → sent to `/account`, and `/admin` still works → AC-4 +- [x] Guest cart with variant X ×4 and an account cart with X ×5 (stock 7) → after sign in the cart holds X ×7, the guest cart row is gone, a pending order on it keeps its row with `cart_id` null → AC-5 +- [x] Two browsers sign in to one account at the same moment, each with a guest cart → one account cart → AC-5 +- [x] Sign out → home page, the cart cookie is gone, Back does not show the account page; sign in again → the cart is back → AC-6 +- [x] Signed in with no cart cookie, add to cart → the line lands in the account cart (`carts.customer_id` set) → AC-7 +- [x] Forgot password for a customer → "Reset your password" email linking to `/account/confirm?...&type=recovery`; for an admin or a user with an authenticator → "Reset your admin password" linking to `/auth/confirm` → AC-8, AC-9 +- [x] Paste an admin's reset token into `/account/confirm?type=recovery` → Continue goes to the authenticator step, never to `/account/set-password` → AC-8 (Key invariants) +- [x] Forgot password for an unknown email → the same reply, no email → AC-9 +- [x] A guest order placed earlier with the email shows on `/account` after sign up; a pending one does not, until it is paid → AC-10, AC-11 +- [x] `/account` with 21 paid orders → 20 on page 1 (newest paid first), 1 on page 2 with Previous; status words Paid, Shipped, Delivered, Cancelled; item count and total → AC-11 +- [x] `/account/orders/` shows lines, subtotal, discount when above 0, "Free" delivery when 0, total, "Refunded" when above 0, address, carrier and tracking → AC-12 +- [x] Another customer's order number, a guest order's number, a pending order's number and a missing number → the same not found page → AC-13 +- [x] Signed out, open `/account/settings` → 307 to `/account/sign-in?next=%2Faccount%2Fsettings` with `Cache-Control: no-store` → AC-13 +- [x] Address book: add two, the first is default; make the second default; delete the default → the newest remaining becomes default; at 10 the Add button is replaced by "You can save up to 10 addresses."; a row with postal code `12345` shows "Check this address" → AC-14 +- [x] Signed in checkout: the email is read only and is the account email; the form starts with the pending order's address, else the default address; the saved address list swaps the fields; "Save this address to my account" is ticked → AC-15 +- [x] Pay with the box ticked → the order has `customer_id`, the address is saved once (paying again with the same address adds nothing); a blank profile phone is filled from the order → AC-15 +- [x] Order `customer_name`: signed in with profile name "Ada Lovelace", shipping to "Grace Hopper" → the order's `customer_name` is "Ada Lovelace" and the confirmation email says "Hi Ada,"; with a blank profile name → `customer_name` is "Grace Hopper" and the profile name becomes "Grace Hopper" (a set name is never overwritten); a guest order → `customer_name` is the delivery full name; with the save box unticked the blank profile name is still filled; admin search by that name finds the order; paying does not change `customer_name` (the webhook leaves it alone) → AC-15 +- [x] Guest cart plus an account cart with lines, signed in, Pay → "Your cart was updated with items saved in your account. Check it and pay again." and the merged cart shows → AC-15 +- [x] Signed out on `/checkout` → "Have an account? Sign in" → back on `/checkout` with the merged cart and the default address → AC-16 +- [x] `/checkout/complete` for a paid guest order, signed out → "Create an account to track this order" → "Check your email" and a confirm email to the order's email; signed in, or for a pending order or an account order → no button → AC-17 +- [x] `/account/settings`: save name and phone; phone `123` refused with checkout's message; "To change your email, contact us." → AC-18 +- [x] Change password: wrong current → "Your current password is not right."; success → other sessions end, this one stays; as an admin or a user with an authenticator → refused with the admin message → AC-19 +- [x] Delete with a paid, unshipped order → the warning names the count and the email; wrong password → refused; right password → home page with "Your account is deleted.", the login is gone, the paid order keeps its snapshot with `customer_id` null; as an admin → refused → AC-20 +- [x] `/admin/orders` and an order page show "Account" for a linked order and "Guest" for the rest → AC-21 +- [x] The header has an "Account" link on every store page; a product page makes no call to Supabase (no `sb-` cookie work in the proxy log) → AC-22 +- [x] Every account page passes axe on desktop and phone, works by keyboard, and focuses the first invalid field on a refused submit → AC-25 + +## Value sourcing +- [x] Throwaway password: inspect a sign up in the Auth logs, it is never the typed value and never logged → AC-1 +- [x] Hook link: set `NEXT_PUBLIC_SITE_URL` to another host → the link in the email uses it → AC-8 +- [x] Hook recipient: the email goes to `user.email` from the signed payload, never to a value the browser sent → AC-8 +- [x] Hook template: a non admin user with a `redirect_to` of `/auth/confirm` still gets the customer reset; an admin with a `redirect_to` of `/account/set-password` still gets the admin reset → AC-8 +- [ ] Idempotency key: `auth::` in the Resend dashboard, never the token hash → AC-8 +- [ ] Account id and email for setup come from the user Supabase returned (sign in as one user while the browser holds another's stale cookie → the signed in user's orders link) → AC-10 +- [x] Merge line cap: lower the stock between adding and signing in → the merged line stops at the new stock → AC-5 +- [x] Dates on `/account`: set `STORE_TIMEZONE` to a far zone and pay near midnight UTC → the paid date follows the store's day → AC-11 +- [x] Checkout email: type another email while signed in → the order and Stripe get the account email → AC-15 +- [x] Prefill: a cart with a pending order → its address wins over the default address → AC-15 +- [x] Address book country: the store's country only, never editable → AC-14 +- [ ] `signUpFromOrder` email: tamper with the button's request body → the email still comes from the order → AC-17 +- [x] Open order count: one `paid` and one `shipped` order → the warning says 1 → AC-20 +- [x] Admin badge: unlink an order (`customer_id` null) → it shows Guest → AC-21 + +## Commands +- [x] `pnpm test` → all unit tests pass, including `src/lib/email/send-auth-email.test.ts`, `src/features/account/actions/auth-actions.test.ts` and `src/lib/customer/log.test.ts` → AC-1 to AC-4, AC-8, AC-9, AC-24 +- [x] `pnpm test:db` → all db suites pass, including `account-setup`, `account-orders`, `account-addresses`, `account-settings`, `order-sign-up`, `cart-actions` and `start-checkout` → AC-5, AC-7, AC-10 to AC-15, AC-17 to AC-21 +- [x] `pnpm test:e2e tests/e2e/account tests/e2e/admin/password-reset.spec.ts` → every account flow and the spec 0004 admin reset pass through the hook → AC-1 to AC-25 +- [x] `pnpm build` → the account pages build as partial prerender with no Cache Components errors → AC-25 + +## Acceptance-criteria coverage +- AC-1 sign up and resend · AC-2 confirm link · AC-3 choose password · AC-4 sign in and `next` · AC-5 cart merge · AC-6 sign out · AC-7 add to cart for a customer · AC-8 the hook · AC-9 forgot password · AC-10 account setup · AC-11 order list · AC-12 order page · AC-13 not found and the proxy · AC-14 address book · AC-15 signed in checkout · AC-16 sign in from checkout · AC-17 account offer · AC-18 profile · AC-19 change password · AC-20 delete account · AC-21 admin badge · AC-22 header link and proxy matcher · AC-23 Mailpit transport and config · AC-24 logs · AC-25 accessibility · AC-26 runbook (applied at deploy, feature 19) diff --git a/emails/AGENTS.md b/emails/AGENTS.md index 98d8651..a91546c 100644 --- a/emails/AGENTS.md +++ b/emails/AGENTS.md @@ -10,6 +10,8 @@ React Email templates for the customer emails (order confirmation, shipped, refu - Sending happens after the work it reports is committed, and must be safe to retry: an email for one event goes out once. - Build the Resend idempotency key from the email type plus the order or event id (e.g. `order_confirmation:`, spec 0011 AC-2), so different emails for one order never share a key. - Resend keeps idempotency keys for only 24 hours. Retries later than that must also check a durable record of sent emails in our DB. +- Auth emails (`confirm-signup.tsx`, `reset-password.tsx`, `admin-reset-password.tsx`) are not sent by Supabase templates: Supabase calls the Send Email Hook at `POST /api/auth/send-email` (`src/features/account/send-email-hook.ts`, signed with `SEND_EMAIL_HOOK_SECRET`), which picks the template, admin reset first, and sends it through the app's transport within 4 seconds (spec 0012, AC-8). +- `EMAIL_TRANSPORT` is `log` (unit tests), `mailpit` (local dev, e2e, CI; read the mail at `MAILPIT_URL`) or `resend`, the only one allowed when `VERCEL_ENV` is production. - `RESEND_API_KEY` and `EMAIL_FROM` join the Zod schema in `src/lib/env.ts` when this area is built. ## Agent skills diff --git a/emails/admin-reset-password.tsx b/emails/admin-reset-password.tsx new file mode 100644 index 0000000..e73c4b7 --- /dev/null +++ b/emails/admin-reset-password.tsx @@ -0,0 +1,35 @@ +import { AuthLinkEmail } from "./components/auth-link-email"; +import type { EmailFrame } 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 ( + + ); +} + +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/components/auth-link-email.tsx b/emails/components/auth-link-email.tsx new file mode 100644 index 0000000..eb9777f --- /dev/null +++ b/emails/components/auth-link-email.tsx @@ -0,0 +1,22 @@ +import { Text } from "react-email"; + +import { ActionLink } from "./action-link"; +import { type EmailFrame, EmailLayout, textStyle } from "./layout"; + +// The shape every auth email shares: why it came, the one link, and what happens if it was not you. +export function AuthLinkEmail(props: { + readonly frame: EmailFrame; + readonly subject: string; + readonly intro: string; + readonly href: string; + readonly label: string; + readonly outro: string; +}) { + return ( + + {props.intro} + + {props.outro} + + ); +} diff --git a/emails/confirm-signup.tsx b/emails/confirm-signup.tsx new file mode 100644 index 0000000..f8ebf3c --- /dev/null +++ b/emails/confirm-signup.tsx @@ -0,0 +1,34 @@ +import { AuthLinkEmail } from "./components/auth-link-email"; +import type { EmailFrame } 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 ( + + ); +} + +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..cae3f45 --- /dev/null +++ b/emails/reset-password.tsx @@ -0,0 +1,34 @@ +import { AuthLinkEmail } from "./components/auth-link-email"; +import type { EmailFrame } 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 ( + + ); +} + +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/AGENTS.md b/src/features/account/AGENTS.md new file mode 100644 index 0000000..d51178b --- /dev/null +++ b/src/features/account/AGENTS.md @@ -0,0 +1,36 @@ +# Account + +## Overview + +Optional customer accounts under `/account`: sign up, confirm, choose a password, sign in and out, forgot password, order history and detail, the address book, settings (profile, password, delete account), and the "Create an account to track this order" offer on the confirmation page. Guest checkout stays the default. It runs on the same Supabase Auth user pool as the admin panel. Code that checkout and cart also need lives in `src/lib/customer/` and `src/lib/cart/merge.ts`. Governing spec: [0012 Customer accounts](../../../docs/specs/0012-customer-accounts/index.md). Production setup: [docs/runbooks/customer-accounts.md](../../../docs/runbooks/customer-accounts.md). + +## Files + +- `app/(store)/account/(auth)/`: the public pages (sign in, sign up, forgot password, confirm, set password). `(area)/`: the signed in pages (orders, order detail, addresses, settings). +- `actions/`: `sign-up.ts`, `sign-up-from-order.ts`, `confirm.ts` (`confirmEmail`, the POST that spends the token), `set-password.ts`, `sign-in.ts`, `password-reset.ts`, `addresses.ts`, `settings.ts`. +- `paths.ts`: pure. Every account URL, which pages are public, and `safeAccountNext`, the only `next` values sign in may follow (`/checkout` or a non auth page under `/account`). +- `proxy.ts`, `proxy-decision.ts`: the first gate, called from the root `proxy.ts` for `/account` and `/checkout`. Refreshes the session, redirects signed out visitors to sign in, sets `Cache-Control: no-store` on account pages. +- `guards.ts`: `redirectIfSignedIn` for the sign in and sign up pages. +- `queries.ts`, `order-view.ts`: order history and detail; `order-sign-up.ts`: when the confirmation page may offer sign up. +- `send-email-hook.ts`: `handleSendEmailHook`, behind `POST /api/auth/send-email` (AC-8). Checks the Standard Webhooks signature, picks the template (admin reset first) and sends it within 4 seconds; the pure parts live in `src/lib/email/auth-hook.ts`. +- `sign-up-email.ts`, `throwaway-password.ts`, `settle.ts`: the sign up call made in `after()` with a password nobody knows, and its failure as a reason code. +- `src/lib/customer/session.ts`: `getCustomer`, `getCustomerId`, `requireCustomer`. `setup.ts`: `accountSetup` (customer row, guest order links, cart merge). `lock.ts`: `lockCustomer`. `addresses.ts`, `address-rules.ts`: the address book. `store-admin.ts`: `runsTheStore`. `log.ts`: `account.*` events. +- `src/lib/auth/password.ts`: the one password schema for admin pages, account pages and scripts, mirroring `password_requirements` in `supabase/config.toml`. Keep the two in step. + +## Conventions + +- Every account page and action below the auth pages calls `requireCustomer()` itself; the proxy is only the first gate and never redirects a server action POST. +- `getCustomer` uses `getUser()` (asks the Auth server), never `getClaims()`, so a deleted or revoked account fails at once. The proxy may use `getClaims()` because it only hints. +- A customer is a confirmed Supabase user; the `customers` row is created on demand under the customer lock. A live email conflict reads as signed out and logs `account.email_conflict`. +- One advisory lock per customer (`lockCustomer`) guards every write to their cart, addresses or order links. Take it before any cart or order row lock. +- Sign up never sets the real password: Supabase gets a throwaway one and the customer chooses theirs after confirming. Sign up and forgot password answer the same whatever the email, and the Supabase call runs in `after()`. +- Email links use the token hash pattern: the GET shows Continue, only the POST calls `verifyOtp`. `/account/confirm` sends `no-referrer` (set in `next.config.ts`). +- A user who runs the store (an `admin_users` row in any state, or a verified TOTP factor) always gets the admin reset email, keeps their password when a sign up link is confirmed, and cannot delete their account here. `auth.users` and `auth.mfa_factors` are read only, through `$queryRaw`. +- Signed in checkout uses the account email, whatever was typed. An order number that is missing, someone else's or hidden renders the same not found content. +- Logs carry the customer id, order ids and numbers, counts and reason codes only, never an email, name, address, password or token. + +## Tests + +Pure rules have `*.test.ts` beside them. Setup, addresses, settings, order links and order sign up run against a real database in `tests/db/account-*.db.test.ts` and `tests/db/order-sign-up.db.test.ts` (`pnpm test:db`, `tests/db/auth-stub.ts` stubs Supabase). Flows are in `tests/e2e/account/`, which read the emails from Mailpit (`EMAIL_TRANSPORT=mailpit`, needs `pnpm db:start` and `SEND_EMAIL_HOOK_URI` pointing at the port the app runs on). + +_Drafted by /sync from the introducing change, worth a quick human pass._ diff --git a/src/features/account/CLAUDE.md b/src/features/account/CLAUDE.md new file mode 100644 index 0000000..3e8f00c --- /dev/null +++ b/src/features/account/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +This project's context for all AI tools lives in [AGENTS.md](./AGENTS.md). +Claude Code loads it via the import below: + +@AGENTS.md diff --git a/src/features/account/actions/addresses.ts b/src/features/account/actions/addresses.ts new file mode 100644 index 0000000..5514680 --- /dev/null +++ b/src/features/account/actions/addresses.ts @@ -0,0 +1,103 @@ +"use server"; + +import { refresh } from "next/cache"; +import { z } from "zod"; + +import { addressFields } from "@/lib/customer/address-rules"; +import { + deleteSavedAddress, + saveAddress, + setDefaultAddress as makeDefault, + updateSavedAddress, +} from "@/lib/customer/addresses"; +import { lockCustomer } from "@/lib/customer/lock"; +import { logAccountEvent } from "@/lib/customer/log"; +import { requireCustomer } from "@/lib/customer/session"; +import { db, type Tx } from "@/lib/db"; +import { env } from "@/lib/env"; +import { fieldErrors } from "@/lib/form-errors"; +import type { ActionResult } from "@/lib/result"; +import { shippingAddressSchema } from "@/lib/shipping/address"; + +export type AddressField = "fullName" | "line1" | "line2" | "postalCode" | "city" | "phone"; + +export type AddressError = + | { + readonly code: "validation"; + readonly fields: Partial>; + } + | { readonly code: "limit_reached" } + | { readonly code: "not_found" }; + +type Result = ActionResult; + +const idSchema = z.uuid(); + +// Every write runs under the customer lock (AC-14), for the signed in customer only. +async function underLock(customerId: string, write: (tx: Tx) => Promise): Promise { + return db.$transaction(async (tx) => { + await lockCustomer(tx, customerId); + return write(tx); + }); +} + +function parseAddress(values: unknown) { + const parsed = shippingAddressSchema(env.STORE_COUNTRY).safeParse(values); + return parsed.success + ? ({ ok: true, fields: addressFields(parsed.data, env.STORE_COUNTRY) } as const) + : ({ + ok: false, + error: { code: "validation", fields: fieldErrors(parsed.error) }, + } as const); +} + +function done(): Result { + refresh(); + return { ok: true, data: null }; +} + +// spec 0012, AC-14: the same fields and rules as checkout (spec 0007); the country is the store's. +export async function createAddress(values: unknown): Promise { + const customer = await requireCustomer(); + const parsed = parseAddress(values); + if (!parsed.ok) return { ok: false, error: parsed.error }; + + const outcome = await underLock(customer.id, (tx) => saveAddress(tx, customer.id, parsed.fields)); + if (outcome.kind === "limit_reached") return { ok: false, error: { code: "limit_reached" } }; + if (outcome.kind === "saved") + logAccountEvent("account.address_saved", { customerId: customer.id }); + return done(); +} + +export async function updateAddress(id: unknown, values: unknown): Promise { + const customer = await requireCustomer(); + const addressId = idSchema.safeParse(id); + if (!addressId.success) return { ok: false, error: { code: "not_found" } }; + const parsed = parseAddress(values); + if (!parsed.ok) return { ok: false, error: parsed.error }; + + const updated = await underLock(customer.id, (tx) => + updateSavedAddress(tx, customer.id, addressId.data, parsed.fields), + ); + if (!updated) return { ok: false, error: { code: "not_found" } }; + logAccountEvent("account.address_saved", { customerId: customer.id }); + return done(); +} + +export async function deleteAddress(id: unknown): Promise { + const customer = await requireCustomer(); + const addressId = idSchema.safeParse(id); + if (!addressId.success) return { ok: false, error: { code: "not_found" } }; + const deleted = await underLock(customer.id, (tx) => + deleteSavedAddress(tx, customer.id, addressId.data), + ); + return deleted ? done() : { ok: false, error: { code: "not_found" } }; +} + +export async function setDefaultAddress(id: unknown): Promise { + const customer = await requireCustomer(); + const addressId = idSchema.safeParse(id); + if (!addressId.success) return { ok: false, error: { code: "not_found" } }; + const set = await underLock(customer.id, (tx) => makeDefault(tx, customer.id, addressId.data)); + return set ? done() : { ok: false, error: { code: "not_found" } }; +} diff --git a/src/features/account/actions/auth-actions.test.ts b/src/features/account/actions/auth-actions.test.ts new file mode 100644 index 0000000..176ee4b --- /dev/null +++ b/src/features/account/actions/auth-actions.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// spec 0012: the account auth actions with Supabase and the database at the edges mocked +// (AC-1 to AC-4, AC-6, AC-9, AC-24). The real flows run in tests/e2e/account. + +const mocks = vi.hoisted(() => { + class NavigationSignal extends Error {} + return { + NavigationSignal, + redirect: vi.fn((url: string) => { + throw new NavigationSignal(`redirect:${url}`); + }), + after: vi.fn(), + getUser: vi.fn(), + signInWithPassword: vi.fn(), + signOut: vi.fn(), + verifyOtp: vi.fn(), + updateUser: vi.fn(), + statelessSignUp: vi.fn(), + statelessResend: vi.fn(), + statelessReset: vi.fn(), + runsTheStore: vi.fn(), + accountSetup: vi.fn(), + clearCartCookie: vi.fn(), + clearAuthCookies: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("next/navigation", () => ({ + redirect: mocks.redirect, + RedirectType: { replace: "replace", push: "push" }, +})); +vi.mock("next/server", () => ({ after: mocks.after })); +vi.mock("@/lib/env", () => ({ env: { NEXT_PUBLIC_SITE_URL: "https://shop.example" } })); +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: async () => ({ + auth: { + getUser: mocks.getUser, + signInWithPassword: mocks.signInWithPassword, + signOut: mocks.signOut, + verifyOtp: mocks.verifyOtp, + updateUser: mocks.updateUser, + }, + }), + createSupabaseStatelessClient: () => ({ + auth: { + signUp: mocks.statelessSignUp, + resend: mocks.statelessResend, + resetPasswordForEmail: mocks.statelessReset, + }, + }), + clearAuthCookies: mocks.clearAuthCookies, +})); +vi.mock("@/lib/customer/store-admin", () => ({ runsTheStore: mocks.runsTheStore })); +vi.mock("@/lib/customer/setup", () => ({ accountSetup: mocks.accountSetup })); +vi.mock("@/lib/cart/cookie", () => ({ clearCartCookie: mocks.clearCartCookie })); +vi.mock("@/lib/logger", () => ({ + logger: { info: mocks.info, warn: mocks.warn, error: mocks.error }, +})); + +const { confirmEmail } = await import("./confirm"); +const { requestPasswordReset } = await import("./password-reset"); +const { setPassword } = await import("./set-password"); +const { signInCustomer, signOutCustomer } = await import("./sign-in"); +const { resendConfirmation, signUp } = await import("./sign-up"); + +const USER_ID = "33333333-3333-3333-3333-333333333333"; +const confirmedUser = { + id: USER_ID, + email: "Ada@Example.com", + email_confirmed_at: "2026-10-14T10:00:00Z", +}; + +async function outcome(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof mocks.NavigationSignal) return error.message; + throw error; + } +} + +// Runs whatever the action scheduled with after(). +async function runAfter() { + for (const [task] of mocks.after.mock.calls) await (task as () => Promise)(); +} + +function logged(): string { + return JSON.stringify([ + ...mocks.info.mock.calls, + ...mocks.warn.mock.calls, + ...mocks.error.mock.calls, + ]); +} + +function form(entries: Record): FormData { + const data = new FormData(); + for (const [key, value] of Object.entries(entries)) data.set(key, value); + return data; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getUser.mockResolvedValue({ data: { user: null } }); + mocks.signOut.mockResolvedValue({ error: null }); + mocks.runsTheStore.mockResolvedValue(false); + mocks.accountSetup.mockResolvedValue({ ok: true, linked: 0 }); + mocks.statelessSignUp.mockResolvedValue({ error: null }); + mocks.statelessResend.mockResolvedValue({ error: null }); + mocks.statelessReset.mockResolvedValue({ error: null }); +}); + +describe("signUp (AC-1)", () => { + it("answers before Supabase runs, with a throwaway password and a lower cased email", async () => { + const result = await signUp({ email: " Ada@Example.COM " }); + + expect(result).toEqual({ ok: true, data: null }); + expect(mocks.statelessSignUp).not.toHaveBeenCalled(); + await runAfter(); + const [call] = mocks.statelessSignUp.mock.calls[0]!; + expect(call.email).toBe("ada@example.com"); + expect(call.password).toMatch(/^[A-Za-z0-9_-]{43}aA1!$/); + }); + + it("gives the same answer when Supabase refuses, and only logs a reason code", async () => { + mocks.statelessSignUp.mockResolvedValue({ + error: { code: "over_email_send_rate_limit", status: 429, message: "ada@example.com" }, + }); + + expect(await signUp({ email: "ada@example.com" })).toEqual({ ok: true, data: null }); + await runAfter(); + expect(mocks.info).toHaveBeenCalledWith( + { event: "account.sign_up", reason: "rate_limited", sent: false }, + "account.sign_up", + ); + expect(logged()).not.toContain("ada@example.com"); + expect(logged()).not.toContain("aA1!"); + }); + + it("returns the field error for an email that is not one, without calling Supabase", async () => { + const result = await signUp({ email: "not-an-email" }); + + expect(result).toEqual({ + ok: false, + error: { fields: { email: ["Enter a valid email address."] } }, + }); + expect(mocks.after).not.toHaveBeenCalled(); + }); + + it("resends the confirm link the same way", async () => { + expect(await resendConfirmation({ email: "ada@example.com" })).toEqual({ + ok: true, + data: null, + }); + await runAfter(); + expect(mocks.statelessResend).toHaveBeenCalledWith({ + type: "signup", + email: "ada@example.com", + }); + }); +}); + +describe("confirmEmail (AC-2)", () => { + beforeEach(() => { + mocks.updateUser.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + }); + + it("spends the token and sends a sign up to choose a password", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + + const result = await outcome(() => confirmEmail(form({ token_hash: "abc", type: "signup" }))); + + expect(mocks.verifyOtp).toHaveBeenCalledWith({ type: "signup", token_hash: "abc" }); + expect(result).toBe("redirect:/account/set-password"); + }); + + // A password planted by a direct Supabase sign up must not survive a customer who confirms and + // then leaves set password without choosing one. + it("replaces the sign up password with a throwaway and ends other sessions", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + + await outcome(() => confirmEmail(form({ token_hash: "abc", type: "signup" }))); + + const [call] = mocks.updateUser.mock.calls[0]!; + expect(call.password).toMatch(/^[A-Za-z0-9_-]{43}aA1!$/); + expect(mocks.signOut).toHaveBeenCalledWith({ scope: "others" }); + }); + + it("still sends the customer to choose a password when the replace fails, and logs a code", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + mocks.updateUser.mockResolvedValue({ data: { user: null }, error: { status: 500 } }); + + const result = await outcome(() => confirmEmail(form({ token_hash: "abc", type: "signup" }))); + + expect(result).toBe("redirect:/account/set-password"); + expect(mocks.error).toHaveBeenCalledWith( + expect.objectContaining({ customerId: USER_ID }), + "account.confirm.password_replace_failed", + ); + expect(mocks.signOut).not.toHaveBeenCalled(); + }); + + // A store admin's password guards /admin, so a confirm link never replaces it (Key invariants). + it("keeps the password of a user who runs the store on a sign up confirm", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + mocks.runsTheStore.mockResolvedValue(true); + + const result = await outcome(() => confirmEmail(form({ token_hash: "abc", type: "signup" }))); + + expect(result).toBe("redirect:/account/set-password"); + expect(mocks.updateUser).not.toHaveBeenCalled(); + expect(mocks.signOut).not.toHaveBeenCalled(); + }); + + it("sends a recovery to the new password page with the reset wording", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + + const result = await outcome(() => confirmEmail(form({ token_hash: "abc", type: "recovery" }))); + + expect(result).toBe("redirect:/account/set-password?from=reset"); + expect(mocks.updateUser).not.toHaveBeenCalled(); + }); + + it("sends anyone who runs the store to the authenticator step instead", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + mocks.runsTheStore.mockResolvedValue(true); + + const result = await outcome(() => confirmEmail(form({ token_hash: "abc", type: "recovery" }))); + + expect(result).toBe("redirect:/admin/mfa?next=%2Fadmin%2Freset-password"); + }); + + it("shows the expired message for a used token and never verifies another type", async () => { + mocks.verifyOtp.mockResolvedValue({ data: { user: null }, error: { status: 403 } }); + + expect(await outcome(() => confirmEmail(form({ token_hash: "abc", type: "signup" })))).toBe( + "redirect:/account/confirm?failed=1&type=signup", + ); + expect(await outcome(() => confirmEmail(form({ token_hash: "abc", type: "magiclink" })))).toBe( + "redirect:/account/confirm?failed=1", + ); + expect(mocks.verifyOtp).toHaveBeenCalledOnce(); + }); +}); + +describe("setPassword (AC-3)", () => { + const values = { password: "Garden-gnome-42", confirm: "Garden-gnome-42" }; + + beforeEach(() => { + mocks.getUser.mockResolvedValue({ data: { user: confirmedUser } }); + mocks.updateUser.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + }); + + it("sets the password, ends the other sessions, runs account setup and lands on /account", async () => { + const result = await outcome(() => setPassword(values, "signup")); + + expect(mocks.updateUser).toHaveBeenCalledWith({ password: "Garden-gnome-42" }); + expect(mocks.signOut).toHaveBeenCalledWith({ scope: "others" }); + expect(mocks.accountSetup).toHaveBeenCalledWith({ id: USER_ID, email: "ada@example.com" }); + expect(result).toBe("redirect:/account?notice=ready"); + }); + + it("says the password changed after a reset", async () => { + expect(await outcome(() => setPassword(values, "reset"))).toBe( + "redirect:/account?notice=password_changed", + ); + }); + + it("needs a session", async () => { + mocks.getUser.mockResolvedValue({ data: { user: null } }); + + expect(await setPassword(values, "signup")).toEqual({ + ok: false, + error: { form: "session_missing" }, + }); + }); + + it("refuses anyone who runs the store", async () => { + mocks.runsTheStore.mockResolvedValue(true); + + expect(await setPassword(values, "reset")).toEqual({ + ok: false, + error: { form: "runs_the_store" }, + }); + expect(mocks.updateUser).not.toHaveBeenCalled(); + }); + + it.each([ + [{ code: "weak_password", status: 422, reasons: ["pwned"] }, "leaked_password"], + [{ code: "same_password", status: 422 }, "same_password"], + ])("maps Supabase's %j to %s", async (error, form) => { + mocks.updateUser.mockResolvedValue({ data: { user: null }, error }); + + expect(await setPassword(values, "signup")).toEqual({ ok: false, error: { form } }); + }); + + it("returns every missing part of a weak password", async () => { + const result = await setPassword({ password: "short", confirm: "short" }, "signup"); + + expect(result).toMatchObject({ ok: false, error: { fields: { password: expect.any(Array) } } }); + expect(mocks.updateUser).not.toHaveBeenCalled(); + }); + + it("reports a failed account setup", async () => { + mocks.accountSetup.mockResolvedValue({ ok: false, error: "email_conflict" }); + + expect(await setPassword(values, "signup")).toEqual({ + ok: false, + error: { form: "setup_failed" }, + }); + }); +}); + +describe("signInCustomer (AC-4)", () => { + const values = { email: "Ada@Example.com", password: "Garden-gnome-42" }; + + it("signs in, runs account setup and goes to a safe next", async () => { + mocks.signInWithPassword.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + + expect(await outcome(() => signInCustomer(values, "/checkout"))).toBe("redirect:/checkout"); + expect(mocks.signInWithPassword).toHaveBeenCalledWith({ + email: "ada@example.com", + password: "Garden-gnome-42", + }); + expect(mocks.accountSetup).toHaveBeenCalledWith({ id: USER_ID, email: "ada@example.com" }); + }); + + it("ignores an unsafe next", async () => { + mocks.signInWithPassword.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + + expect(await outcome(() => signInCustomer(values, "https://evil.example"))).toBe( + "redirect:/account", + ); + }); + + it("never replaces a live session", async () => { + mocks.getUser.mockResolvedValue({ data: { user: confirmedUser } }); + + expect(await outcome(() => signInCustomer(values, null))).toBe("redirect:/account"); + expect(mocks.signInWithPassword).not.toHaveBeenCalled(); + }); + + it.each([ + [{ code: "invalid_credentials", status: 400 }, "invalid_credentials"], + [{ code: "email_not_confirmed", status: 400 }, "invalid_credentials"], + [{ code: "over_request_rate_limit", status: 429 }, "rate_limited"], + ])("answers %j as %s and logs no email", async (error, form) => { + mocks.signInWithPassword.mockResolvedValue({ data: { user: null }, error }); + + expect(await signInCustomer(values, null)).toEqual({ ok: false, error: { form } }); + expect(logged()).not.toContain("ada@example.com"); + }); + + // covers: AC-10 (value sourcing): a stale cookie of another account reads as no session, and + // setup runs for the user Supabase signed in, never for the typed email. + it("runs account setup for the user Supabase returned", async () => { + const grace = { + id: "44444444-4444-4444-4444-444444444444", + email: "Grace@Example.com", + email_confirmed_at: "2026-10-14T10:00:00Z", + }; + mocks.signInWithPassword.mockResolvedValue({ data: { user: grace }, error: null }); + + expect(await outcome(() => signInCustomer(values, null))).toBe("redirect:/account"); + expect(mocks.accountSetup).toHaveBeenCalledWith({ id: grace.id, email: "grace@example.com" }); + }); + + it("ends the session of an unconfirmed user and answers with the usual message", async () => { + mocks.signInWithPassword.mockResolvedValue({ + data: { user: { ...confirmedUser, email_confirmed_at: undefined } }, + error: null, + }); + + expect(await signInCustomer(values, null)).toEqual({ + ok: false, + error: { form: "invalid_credentials" }, + }); + expect(mocks.signOut).toHaveBeenCalledWith({ scope: "local" }); + expect(mocks.accountSetup).not.toHaveBeenCalled(); + }); + + it("ends the session when account setup fails", async () => { + mocks.signInWithPassword.mockResolvedValue({ data: { user: confirmedUser }, error: null }); + mocks.accountSetup.mockResolvedValue({ ok: false, error: "email_conflict" }); + + expect(await signInCustomer(values, null)).toEqual({ + ok: false, + error: { form: "unavailable" }, + }); + expect(mocks.signOut).toHaveBeenCalledWith({ scope: "local" }); + }); +}); + +describe("signOutCustomer (AC-6)", () => { + it("signs out this browser only, forgets the cart and replaces the page with the home page", async () => { + expect(await outcome(() => signOutCustomer())).toBe("redirect:/"); + expect(mocks.signOut).toHaveBeenCalledWith({ scope: "local" }); + expect(mocks.clearCartCookie).toHaveBeenCalledOnce(); + expect(mocks.redirect).toHaveBeenCalledWith("/", "replace"); + }); +}); + +describe("requestPasswordReset (AC-9)", () => { + it("answers at once and asks Supabase afterwards, with the account redirect", async () => { + expect(await requestPasswordReset({ email: "Ada@Example.com" })).toEqual({ + ok: true, + data: null, + }); + expect(mocks.statelessReset).not.toHaveBeenCalled(); + await runAfter(); + expect(mocks.statelessReset).toHaveBeenCalledWith("ada@example.com", { + redirectTo: "https://shop.example/account/set-password", + }); + }); +}); diff --git a/src/features/account/actions/confirm.ts b/src/features/account/actions/confirm.ts new file mode 100644 index 0000000..160818a --- /dev/null +++ b/src/features/account/actions/confirm.ts @@ -0,0 +1,72 @@ +"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 { runsTheStore } from "@/lib/customer/store-admin"; +import { logger } from "@/lib/logger"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; + +import { accountPaths } from "../paths"; +import { confirmLinkSchema } from "../schemas"; +import { throwawayPassword } from "../throwaway-password"; + +// 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 changed from an inbox alone (Key invariants), even with + // an admin's link pasted here: a reset goes to the authenticator step like /auth/confirm, and a + // sign up keeps the admin's password as it is. + const storeAdmin = await runsTheStore(data.user.id); + if (type === "recovery" && storeAdmin) redirect(adminResetPath); + + if (type === "signup") { + if (!storeAdmin) await dropPlantedPassword(supabase, data.user.id); + logAccountEvent("account.confirmed", { customerId: customerIdentity(data.user)?.id ?? null }); + } + redirect( + type === "recovery" ? `${accountPaths.setPassword}?from=reset` : accountPaths.setPassword, + ); +} + +// AC-1 and AC-3: Supabase sign up is public, so anyone can create the unconfirmed user straight +// through the API with a password they know, and this confirm makes that password work. Replacing +// it here means only a password chosen after the confirm can sign in, even when the customer +// leaves set password without choosing one. A sign in that slipped in since verifyOtp is ended. +async function dropPlantedPassword( + supabase: Awaited>, + customerId: string, +): Promise { + const { error } = await supabase.auth.updateUser({ password: throwawayPassword() }); + if (error) { + // The customer still goes on to set password, which replaces it; the log flags the gap. + logger.error( + { customerId, reason: classifyAuthError(error) }, + "account.confirm.password_replace_failed", + ); + return; + } + const { error: revokeError } = await supabase.auth.signOut({ scope: "others" }); + if (revokeError) { + logger.error( + { customerId, reason: classifyAuthError(revokeError) }, + "account.confirm.revoke_others_failed", + ); + } +} diff --git a/src/features/account/actions/password-reset.ts b/src/features/account/actions/password-reset.ts new file mode 100644 index 0000000..a5bde23 --- /dev/null +++ b/src/features/account/actions/password-reset.ts @@ -0,0 +1,37 @@ +"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 { type EmailFormError, emailFormSchema } from "../schemas"; +import { settle } from "../settle"; + +// 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/settings.ts b/src/features/account/actions/settings.ts new file mode 100644 index 0000000..896b3b4 --- /dev/null +++ b/src/features/account/actions/settings.ts @@ -0,0 +1,137 @@ +"use server"; + +import { refresh } from "next/cache"; +import { redirect } from "next/navigation"; + +import { clearCartCookie } from "@/lib/cart/cookie"; +import { lockCustomer } from "@/lib/customer/lock"; +import { logAccountEvent } from "@/lib/customer/log"; +import { requireCustomer } from "@/lib/customer/session"; +import { runsTheStore } from "@/lib/customer/store-admin"; +import { db } from "@/lib/db"; +import { pgErrorCode } from "@/lib/db-errors"; +import { fieldErrors } from "@/lib/form-errors"; +import { logger } from "@/lib/logger"; +import type { ActionResult } from "@/lib/result"; +import { createSupabaseAdminClient } from "@/lib/supabase/admin"; +import { clearAuthCookies, createSupabaseServerClient } from "@/lib/supabase/server"; + +import { checkPassword, passwordCheckError } from "../password-check"; +import { type PasswordUpdateError, passwordUpdateError } from "../password-errors"; +import { changePasswordSchema, deleteAccountSchema, profileSchema } from "../schemas"; + +export type ProfileError = { + readonly fields: Partial>; +}; + +// spec 0012, AC-18: name and phone, both optional. The email is not editable here. +export async function updateProfile( + values: unknown, +): Promise< + ActionResult<{ readonly name: string | null; readonly phone: string | null }, ProfileError> +> { + const customer = await requireCustomer(); + const parsed = profileSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + + const saved = await db.customer.update({ + where: { id: customer.id }, + data: parsed.data, + select: { name: true, phone: true }, + }); + refresh(); + return { ok: true, data: saved }; +} + +export type ChangePasswordError = { + readonly form?: PasswordUpdateError | "wrong_password" | "runs_the_store"; + readonly fields?: Partial>; +}; + +// spec 0012, AC-19: the current password, then a new one under the AC-3 rule. Every other session +// ends and this one stays. Anyone who runs the store changes it behind the authenticator instead. +export async function changePassword( + values: unknown, +): Promise> { + const customer = await requireCustomer(); + const parsed = changePasswordSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + if (await runsTheStore(customer.id)) return { ok: false, error: { form: "runs_the_store" } }; + + const check = await checkPassword(customer.id, customer.email, parsed.data.currentPassword); + if (!check.ok) return { ok: false, error: { form: passwordCheckError(check.failure) } }; + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.auth.updateUser({ password: parsed.data.password }); + if (error) return { ok: false, error: { form: passwordUpdateError(error) } }; + const { error: revokeError } = await supabase.auth.signOut({ scope: "others" }); + if (revokeError) { + logger.error({ customerId: customer.id }, "account.password.revoke_others_failed"); + } + logAccountEvent("account.password_changed", { customerId: customer.id }); + return { ok: true, data: null }; +} + +function authErrorName(error: unknown): string | null { + return error instanceof Error && typeof error.cause === "string" ? error.cause : null; +} + +export type DeleteAccountError = { + readonly form?: + "wrong_password" | "runs_the_store" | "delete_failed" | "rate_limited" | "unavailable"; + readonly fields?: Partial>; +}; + +// spec 0012, AC-20: the profile, addresses and cart go (they cascade from the customers row) and +// the login with them; paid orders keep their snapshot with customer_id null. The auth user is +// deleted inside the transaction, before it commits, so a failed call rolls everything back. +export async function deleteAccount( + values: unknown, +): Promise> { + const customer = await requireCustomer(); + const parsed = deleteAccountSchema.safeParse(values); + if (!parsed.success) return { ok: false, error: { fields: fieldErrors(parsed.error) } }; + + if (await runsTheStore(customer.id)) { + logAccountEvent("account.delete_refused_admin", { customerId: customer.id }); + return { ok: false, error: { form: "runs_the_store" } }; + } + + const check = await checkPassword(customer.id, customer.email, parsed.data.password); + if (!check.ok) return { ok: false, error: { form: passwordCheckError(check.failure) } }; + + // Set once the login is gone: a failure after that point (the commit) cannot be rolled back. + let authDeleted = false; + try { + await db.$transaction( + async (tx) => { + await lockCustomer(tx, customer.id); + await tx.customer.deleteMany({ where: { id: customer.id } }); + const { error } = await createSupabaseAdminClient().auth.admin.deleteUser(customer.id); + // Thrown to roll the row delete back; caught below. + if (error) throw new Error("auth_delete_failed", { cause: error.name }); + authDeleted = true; + }, + // Covers one round trip to the Auth server. + { timeout: 15_000 }, + ); + } catch (error) { + if (!authDeleted) { + // Supabase's error name, or the SQLSTATE of a database failure; never a message, which can + // quote the row. + logger.error( + { customerId: customer.id, errorName: authErrorName(error), pgCode: pgErrorCode(error) }, + "account.delete_failed", + ); + return { ok: false, error: { form: "delete_failed" } }; + } + // The login is gone but the rows stayed: they have no login, and account setup removes the + // leftover row if this email signs up again (AC-10). + logAccountEvent("account.delete_incomplete", { customerId: customer.id }); + } + + logAccountEvent("account.deleted", { customerId: customer.id }); + await clearAuthCookies(); + await clearCartCookie(); + redirect("/?notice=account_deleted"); +} 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-from-order.ts b/src/features/account/actions/sign-up-from-order.ts new file mode 100644 index 0000000..cc9e556 --- /dev/null +++ b/src/features/account/actions/sign-up-from-order.ts @@ -0,0 +1,21 @@ +"use server"; + +import { after } from "next/server"; + +import type { ActionResult } from "@/lib/result"; + +import { orderSignUpEmail } from "../order-sign-up"; +import { sendSignUp } from "../sign-up-email"; + +// spec 0012, AC-17: the confirmation page's "Create an account" button. The server checks the +// same conditions the page did and runs the AC-1 sign up with the order's email, in after(). If an +// account already exists for that email nothing is sent, and the order links at that customer's +// next sign in (AC-10). +export async function signUpFromOrder( + sessionId: unknown, +): Promise> { + const email = await orderSignUpEmail(sessionId); + if (email === null) return { ok: false, error: "not_eligible" }; + after(() => sendSignUp(email)); + return { ok: true, data: null }; +} diff --git a/src/features/account/actions/sign-up.ts b/src/features/account/actions/sign-up.ts new file mode 100644 index 0000000..b63ae53 --- /dev/null +++ b/src/features/account/actions/sign-up.ts @@ -0,0 +1,44 @@ +"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 { type EmailFormError, emailFormSchema } from "../schemas"; +import { settle } from "../settle"; +import { sendSignUp } from "../sign-up-email"; + +// 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-deleted-notice.tsx b/src/features/account/components/account-deleted-notice.tsx new file mode 100644 index 0000000..b1d765e --- /dev/null +++ b/src/features/account/components/account-deleted-notice.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { FormNotice } from "@/components/form-notice"; + +import { accountMessages } from "../messages"; + +// spec 0012, AC-20: the home page after a deletion. It reads only the query string, in the +// browser, so the home page stays cached. +export function AccountDeletedNotice() { + const deleted = useSearchParams().get("notice") === "account_deleted"; + return ; +} 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 ( + +
diff --git a/src/features/checkout/schemas.test.ts b/src/features/checkout/schemas.test.ts index 49cdd06..19a6328 100644 --- a/src/features/checkout/schemas.test.ts +++ b/src/features/checkout/schemas.test.ts @@ -25,9 +25,20 @@ describe("checkoutSchema", () => { postalCode: "00-950", city: "Warsaw", phone: null, + saveAddress: false, }); }); + // spec 0012, AC-15. + it("carries the save address choice, false unless sent", () => { + expect( + schema.parse({ email: "a@example.com", ...address, saveAddress: true }).saveAddress, + ).toBe(true); + expect( + schema.safeParse({ email: "a@example.com", ...address, saveAddress: "yes" }).success, + ).toBe(false); + }); + it("parses its own output to the same values, as the server does after the form", () => { const once = schema.parse({ email: "Ada@Example.com", ...address }); diff --git a/src/features/checkout/schemas.ts b/src/features/checkout/schemas.ts index 9213c91..b1a586f 100644 --- a/src/features/checkout/schemas.ts +++ b/src/features/checkout/schemas.ts @@ -17,7 +17,12 @@ const emailSchema = z .transform((email) => email.toLowerCase()); export function checkoutSchema(country: StoreCountry) { - return z.object({ email: emailSchema, ...shippingAddressSchema(country).shape }); + return z.object({ + email: emailSchema, + ...shippingAddressSchema(country).shape, + // spec 0012, AC-15: "Save this address to my account"; only a signed in checkout reads it. + saveAddress: z.boolean().default(false), + }); } export type CheckoutValues = z.input>; diff --git a/src/features/orders/admin-queries.ts b/src/features/orders/admin-queries.ts index b417d12..e62c64a 100644 --- a/src/features/orders/admin-queries.ts +++ b/src/features/orders/admin-queries.ts @@ -20,6 +20,7 @@ import { withDeliveryAddress, } from "@/lib/shipping/address"; import { stripeDashboardUrl } from "@/lib/stripe"; +import { maxOrderNumber } from "@/lib/orders/order-number"; import type { AdminEmailSend } from "./email-status"; import { type AdminOrdersParams, searchNumber } from "./list-params"; @@ -64,6 +65,8 @@ export type AdminOrderRow = { readonly refundedCents: number; // ", ", or null for an order with no address (spec 0007, AC-12). readonly shipTo: string | null; + // spec 0012, AC-21: the order belongs to a customer account (else a guest's). + readonly hasAccount: boolean; }; export type AdminOrdersPage = { @@ -97,6 +100,7 @@ export async function getAdminOrders(params: AdminOrdersParams): Promise ({ + rows: page.map(({ lines, shipFullName, shipCity, customerId, ...order }) => ({ ...order, + hasAccount: customerId !== null, itemCount: lines.reduce((sum, line) => sum + line.quantity, 0), shipTo: shipFullName !== null && shipCity !== null ? `${shipFullName}, ${shipCity}` : null, })), @@ -209,6 +214,8 @@ export type AdminOrderDetail = { // null only when piiPurgedAt is set: the query throws otherwise (spec 0008, AC-9). readonly email: string | null; readonly piiPurgedAt: Date | null; + // spec 0012, AC-21: the order belongs to a customer account (else a guest's). + readonly hasAccount: boolean; readonly currency: string; readonly subtotalCents: number; readonly discountCents: number; @@ -258,7 +265,7 @@ export type AdminOrderDetail = { }[]; }; -const orderNumberSchema = z.coerce.number().int().min(1001).max(2_147_483_647); +const orderNumberSchema = z.coerce.number().int().min(1001).max(maxOrderNumber); // spec 0006, AC-13 and spec 0010, AC-21: null for a malformed or unknown number (the page shows // its not found state). @@ -295,6 +302,7 @@ export async function getAdminOrder( phone: true, ...deliveryAddressColumns, stripeCheckoutSessionId: true, + customerId: true, stripePaymentIntentId: true, lines: { orderBy: [{ createdAt: "asc" }, { id: "asc" }], @@ -410,6 +418,7 @@ export async function getAdminOrder( needsAttention: order.needsAttention, updatedAt: order.updatedAt.toISOString(), email: order.email, + hasAccount: order.customerId !== null, piiPurgedAt: order.piiPurgedAt, currency: order.currency, subtotalCents: order.subtotalCents, diff --git a/src/features/orders/components/admin-order-detail.tsx b/src/features/orders/components/admin-order-detail.tsx index 7dd6374..2b219da 100644 --- a/src/features/orders/components/admin-order-detail.tsx +++ b/src/features/orders/components/admin-order-detail.tsx @@ -26,13 +26,14 @@ import { import { type DateFormat, formatDateTime } from "@/lib/dates"; import { deliveryLabel } from "@/lib/shipping/rule"; import { cn } from "@/lib/utils"; +import { orderStatusLabels } from "@/lib/orders/status-labels"; import type { AdminOrderDetail as Detail } from "../admin-queries"; import { adminOrdersPath } from "../paths"; import { EmailsSection } from "./emails-section"; import { NoteForm } from "./note-form"; import { OrderActionsPanel } from "./order-actions-panel"; -import { NeedsAttentionBadge, orderStatusLabels, OrderStatusBadge } from "./order-status-badge"; +import { NeedsAttentionBadge, OrderStatusBadge } from "./order-status-badge"; import { OrderEmail, PersonalDataField } from "./personal-data"; import { RefundStateBadge } from "./refund-state-badge"; import { RefundsSection } from "./refunds-section"; @@ -223,6 +224,8 @@ export function AdminOrderDetail({ + {/* spec 0012, AC-21: whether the customer can look the order up themselves. */} + {order.hasAccount ? "Account" : "Guest"} Order Date Email + Customer Ship to Status Items @@ -118,6 +119,7 @@ export function AdminOrdersList({ + {order.hasAccount ? "Account" : "Guest"} {order.shipTo ?? ( diff --git a/src/features/orders/components/order-status-badge.tsx b/src/features/orders/components/order-status-badge.tsx index e8bcb4a..69f6fb1 100644 --- a/src/features/orders/components/order-status-badge.tsx +++ b/src/features/orders/components/order-status-badge.tsx @@ -1,17 +1,9 @@ import { TriangleAlertIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { orderStatusLabels } from "@/lib/orders/status-labels"; import type { OrderStatus } from "@/generated/prisma/enums"; -export const orderStatusLabels: Record = { - pending_payment: "Awaiting payment", - paid: "Paid", - shipped: "Shipped", - delivered: "Delivered", - cancelled: "Cancelled", - expired: "Expired", -}; - export function OrderStatusBadge({ status }: { readonly status: OrderStatus }) { const variant = status === "paid" || status === "shipped" || status === "delivered" diff --git a/src/features/orders/components/reason-dialog.tsx b/src/features/orders/components/reason-dialog.tsx index 3fc8800..9435de1 100644 --- a/src/features/orders/components/reason-dialog.tsx +++ b/src/features/orders/components/reason-dialog.tsx @@ -15,7 +15,8 @@ import { TextField, useOrderAction, } from "./action-dialog"; -import { orderStatusLabels } from "./order-status-badge"; + +import { orderStatusLabels } from "@/lib/orders/status-labels"; type Kind = "undo" | "resolve"; diff --git a/src/features/orders/list-params.ts b/src/features/orders/list-params.ts index b13d172..b2b7201 100644 --- a/src/features/orders/list-params.ts +++ b/src/features/orders/list-params.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { OrderStatus } from "@/generated/prisma/enums"; import { firstValue, searchQuerySchema } from "@/lib/admin-search"; import { parseCalendarDay } from "@/lib/dates"; +import { maxOrderNumber } from "@/lib/orders/order-number"; import { adminOrdersPath } from "./paths"; import type { RefundState } from "./refund-math"; @@ -41,7 +42,7 @@ const schema = z.object({ refund: z.enum(["none", "partial", "full"]).optional().catch(undefined), from: z.string().optional().catch(undefined), to: z.string().optional().catch(undefined), - before: z.coerce.number().int().min(1).max(2_147_483_647).optional().catch(undefined), + before: z.coerce.number().int().min(1).max(maxOrderNumber).optional().catch(undefined), }); function day(text: string | undefined): string | null { @@ -105,5 +106,5 @@ export function adminOrdersHref(params: AdminOrdersParams, before: number | null export function searchNumber(q: string): number | null { if (!/^\d+$/.test(q)) return null; const number = Number(q); - return number >= 1 && number <= 2_147_483_647 ? number : null; + return number >= 1 && number <= maxOrderNumber ? number : null; } diff --git a/src/features/orders/schemas.ts b/src/features/orders/schemas.ts index a809ab7..8c72522 100644 --- a/src/features/orders/schemas.ts +++ b/src/features/orders/schemas.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { maxOrderNumber } from "@/lib/orders/order-number"; + // Pure: the inputs of every admin order action (spec 0010, API surface), shared by the forms // and the actions. Text is trimmed; carrier and tracking save an empty value as null. @@ -9,7 +11,7 @@ export const TRACKING_MAX_LENGTH = 100; // What the page was rendered with, posted back so the action can refuse a stale form (AC-8). const orderRef = { - orderNumber: z.number().int().min(1001).max(2_147_483_647), + orderNumber: z.number().int().min(1001).max(maxOrderNumber), expectedUpdatedAt: z.iso.datetime(), }; @@ -76,7 +78,7 @@ export const checkRefundSchema = z.object({ // spec 0011, API surface. The order number names the order the row must belong to (AC-15). export const sendEmailAgainSchema = z.object({ - orderNumber: z.number().int().min(1).max(2_147_483_647), + orderNumber: z.number().int().min(1).max(maxOrderNumber), emailSendId: z.uuid(), }); diff --git a/src/features/admin-auth/auth-errors.test.ts b/src/lib/auth/errors.test.ts similarity index 94% rename from src/features/admin-auth/auth-errors.test.ts rename to src/lib/auth/errors.test.ts index 5ee4f0c..5568307 100644 --- a/src/features/admin-auth/auth-errors.test.ts +++ b/src/lib/auth/errors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { classifyAuthError } from "./auth-errors"; +import { classifyAuthError } from "./errors"; describe("classifyAuthError", () => { it("maps Supabase rate limits", () => { diff --git a/src/features/admin-auth/auth-errors.ts b/src/lib/auth/errors.ts similarity index 90% rename from src/features/admin-auth/auth-errors.ts rename to src/lib/auth/errors.ts index c70fd3c..51ff985 100644 --- a/src/features/admin-auth/auth-errors.ts +++ b/src/lib/auth/errors.ts @@ -1,5 +1,5 @@ -// Maps a Supabase Auth error to the fixed message keys of spec 0004. Pure, so the mapping is -// unit tested without a network. +// Maps a Supabase Auth error to the fixed message keys of spec 0004 and spec 0012. Pure, so the +// mapping is unit tested without a network. export type AuthErrorLike = { readonly code?: string | undefined; diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts new file mode 100644 index 0000000..a234ff1 --- /dev/null +++ b/src/lib/auth/password.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { isPasswordTooLong, missingPasswordRules, passwordSchema } from "./password"; + +describe("passwordSchema (spec 0004 AC-10, spec 0012 AC-3)", () => { + it("accepts a password with all four classes and 8 characters", () => { + expect(passwordSchema.safeParse("Abcdef1!").success).toBe(true); + }); + + it("names every missing part", () => { + expect(missingPasswordRules("")).toEqual(["length", "lower", "upper", "digit", "symbol"]); + expect(missingPasswordRules("short")).toEqual(["length", "upper", "digit", "symbol"]); + const result = passwordSchema.safeParse("abcdefgh"); + expect(result.error?.issues.map((issue) => issue.message)).toEqual([ + "An upper case letter", + "A digit", + "A symbol", + ]); + }); + + it("counts only the symbols Supabase counts", () => { + expect(missingPasswordRules("Abcdefg1€")).toEqual(["symbol"]); + expect(missingPasswordRules("Abcdefg1~")).toEqual([]); + }); + + it("refuses more than 72 bytes, counting multi byte characters", () => { + expect(isPasswordTooLong(`Aa1!${"x".repeat(68)}`)).toBe(false); + expect(isPasswordTooLong(`Aa1!${"x".repeat(69)}`)).toBe(true); + expect(isPasswordTooLong(`Aa1!${"é".repeat(35)}`)).toBe(true); + expect(passwordSchema.safeParse(`Aa1!${"x".repeat(69)}`).success).toBe(false); + }); +}); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts new file mode 100644 index 0000000..9a5b32a --- /dev/null +++ b/src/lib/auth/password.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +// Pure (no server-only imports): the admin pages, the account pages and the admin scripts all +// check passwords with this one schema, which mirrors the project wide Supabase policy +// (spec 0004, AC-10; supabase/config.toml `password_requirements`). Keep the two in step. + +export const passwordRuleMessages = { + length: "At least 8 characters", + lower: "A lower case letter", + upper: "An upper case letter", + digit: "A digit", + symbol: "A symbol", +} as const; + +export type PasswordRule = keyof typeof passwordRuleMessages; + +// bcrypt reads only the first 72 bytes, and Supabase refuses anything longer. +export const PASSWORD_MAX_BYTES = 72; + +export const passwordTooLongMessage = "Use at most 72 characters (fewer with accents or emoji)."; + +// Supabase's `lower_upper_letters_digits_symbols` policy counts only these as symbols, so the +// schema uses the same set; a password that passes here also passes the Auth server. +const symbols = "!@#$%^&*()_+-=[]{};'\\:\"|<>?,./`~"; + +const passwordRules: readonly (readonly [PasswordRule, (value: string) => boolean])[] = [ + ["length", (value) => value.length >= 8], + ["lower", (value) => /[a-z]/.test(value)], + ["upper", (value) => /[A-Z]/.test(value)], + ["digit", (value) => /[0-9]/.test(value)], + ["symbol", (value) => [...value].some((char) => symbols.includes(char))], +]; + +export function missingPasswordRules(value: string): PasswordRule[] { + return passwordRules.filter(([, passes]) => !passes(value)).map(([rule]) => rule); +} + +export function isPasswordTooLong(value: string): boolean { + return new TextEncoder().encode(value).length > PASSWORD_MAX_BYTES; +} + +// One issue per missing part, so a form can name each of them. +export const passwordSchema = z.string().superRefine((value, ctx) => { + for (const rule of missingPasswordRules(value)) { + ctx.addIssue({ code: "custom", message: passwordRuleMessages[rule] }); + } + if (isPasswordTooLong(value)) ctx.addIssue({ code: "custom", message: passwordTooLongMessage }); +}); + +// The rule list a form prints under a new password field. +export const passwordRuleList = Object.values(passwordRuleMessages).join(", ").toLowerCase(); diff --git a/src/lib/cart/cookie.ts b/src/lib/cart/cookie.ts index 2e1856e..0520bba 100644 --- a/src/lib/cart/cookie.ts +++ b/src/lib/cart/cookie.ts @@ -39,3 +39,9 @@ export async function setCartCookie(cartId: string, expiresAt: Date): Promise { + (await cookies()).delete(CART_COOKIE); +} diff --git a/src/lib/cart/merge.ts b/src/lib/cart/merge.ts new file mode 100644 index 0000000..c183cb9 --- /dev/null +++ b/src/lib/cart/merge.ts @@ -0,0 +1,123 @@ +import "server-only"; + +import { lineCap } from "@/lib/availability"; +import type { Tx } from "@/lib/db"; + +import { cartExpiry } from "./cookie"; + +export type MergeOutcome = { + // The cart the cookie names from now on, or null for none. + readonly cartId: string | null; + readonly expiresAt: Date; + // spec 0012, AC-24 (`account.cart_merged`): guest lines that went into the account cart, moved + // or summed into a line already there; a guest line dropped beside an account line of an + // archived or sold out variant does not count. Every line of an adopted guest cart counts. Null + // when no guest cart joined, so no event is logged. + readonly linesMerged: number | null; + // The account cart already held lines, so the merged cart differs from the guest one the + // visitor saw (spec 0012, AC-15: checkout then asks them to look again). + readonly accountHadLines: boolean; +}; + +type LockedCart = { readonly id: string; readonly customer_id: string | null }; + +// spec 0012, AC-5: the guest cart named by the cookie joins the customer's cart. The caller holds +// the customer lock (src/lib/customer/lock.ts); the carts are then locked here, so two sign ins of +// one account, or a sign in and an add to cart, never leave two account carts. +export async function mergeCarts( + tx: Tx, + customerId: string, + cookieCartId: string | null, + nowMs: number, +): Promise { + const expiresAt = cartExpiry(nowMs); + const cookieCart = + cookieCartId === null + ? null + : (( + await tx.$queryRaw` + SELECT id, customer_id FROM carts + WHERE id = ${cookieCartId}::uuid AND expires_at > now() FOR UPDATE` + )[0] ?? null); + // A cookie naming another customer's cart is ignored, and replaced below. + const guest = cookieCart !== null && cookieCart.customer_id === null ? cookieCart : null; + + const account = await lockAccountCart(tx, customerId); + const done = async ( + cartId: string | null, + linesMerged: number | null = null, + accountHadLines = false, + ) => { + if (cartId !== null) await tx.cart.update({ where: { id: cartId }, data: { expiresAt } }); + return { cartId, expiresAt, linesMerged, accountHadLines }; + }; + + if (guest === null) return done(account); + if (account === null) { + await tx.cart.update({ where: { id: guest.id }, data: { customerId } }); + return done(guest.id, await tx.cartItem.count({ where: { cartId: guest.id } })); + } + + const [guestLines, accountLines] = await Promise.all([ + cartLines(tx, guest.id), + cartLines(tx, account), + ]); + let linesMerged = 0; + for (const line of guestLines) { + const cap = line.available ? lineCap(line.stockQuantity) : 0; + const existing = accountLines.find((other) => other.variantId === line.variantId); + if (existing) { + // An archived or sold out variant keeps the account's line as it is. + if (cap > 0) { + await tx.cartItem.update({ + where: { id: existing.id }, + data: { quantity: Math.min(existing.quantity + line.quantity, cap) }, + }); + linesMerged += 1; + } + continue; + } + // A moved line keeps its row (and its place in the cart); flags are computed at read time. + await tx.cartItem.update({ + where: { id: line.id }, + data: { cartId: account, ...(cap > 0 ? { quantity: Math.min(line.quantity, cap) } : {}) }, + }); + linesMerged += 1; + } + // The rest of the guest cart's lines go with it. A pending order on it keeps its row with + // cart_id null, as when the expired cart cron deletes a cart. + await tx.cart.delete({ where: { id: guest.id } }); + return done(account, linesMerged, accountLines.length > 0); +} + +// The customer's cart, locked; the caller holds the customer lock. One that has expired but not +// been swept yet is deleted, so the customer starts a fresh one (carts.customer_id is unique). +export async function lockAccountCart(tx: Tx, customerId: string): Promise { + const [row] = await tx.$queryRaw<{ id: string; live: boolean }[]>` + SELECT id, expires_at > now() AS live FROM carts + WHERE customer_id = ${customerId}::uuid FOR UPDATE`; + if (row === undefined) return null; + if (row.live) return row.id; + await tx.cart.delete({ where: { id: row.id } }); + return null; +} + +async function cartLines(tx: Tx, cartId: string) { + const items = await tx.cartItem.findMany({ + where: { cartId }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + variantId: true, + quantity: true, + variant: { + select: { stockQuantity: true, archived: true, product: { select: { status: true } } }, + }, + }, + }); + return items.map(({ variant, ...item }) => ({ + ...item, + stockQuantity: variant.stockQuantity, + available: !variant.archived && variant.product.status === "active", + })); +} diff --git a/src/lib/cart/owned.ts b/src/lib/cart/owned.ts new file mode 100644 index 0000000..3b54203 --- /dev/null +++ b/src/lib/cart/owned.ts @@ -0,0 +1,45 @@ +import "server-only"; + +import { getCustomerId } from "@/lib/customer/session"; +import { db } from "@/lib/db"; +import { logger } from "@/lib/logger"; + +import { readCartId } from "./cookie"; +import { type Cart, loadCartById } from "./load-cart"; + +// spec 0012, AC-7: the live cart the cookie names, when this visitor may use it. An account cart +// belongs to its customer only, so a cookie left behind by a session that ended without our sign +// out (expiry, sign out on another device, an admin sign out here) reads as no cart. Read only: +// it never clears or renews the cookie; the next successful add replaces it. +export async function readOwnedCartId(): Promise { + const cartId = await readCartId(); + if (cartId === null) return null; + const cart = await db.cart.findFirst({ + where: { id: cartId, expiresAt: { gt: new Date() } }, + select: { customerId: true }, + }); + if (cart === null) return null; + if (cart.customerId === null) return cartId; + return (await sessionCustomerId()) === cart.customerId ? cartId : null; +} + +// The cart page and checkout's owned read; the header count keeps the raw loadCart so catalog +// pages never call Supabase (AC-22). +export async function loadOwnedCart(): Promise { + const cartId = await readOwnedCartId(); + if (cartId === null) return null; + return loadCartById(cartId, new Date()); +} + +// A session we cannot read only hides an account cart, never a 500. +async function sessionCustomerId(): Promise { + try { + return await getCustomerId(); + } catch (error) { + logger.warn( + { event: "cart.owner_unknown", error: error instanceof Error ? error.name : "unknown" }, + "cart.owner_unknown", + ); + return null; + } +} diff --git a/src/lib/customer/address-rules.test.ts b/src/lib/customer/address-rules.test.ts new file mode 100644 index 0000000..d926749 --- /dev/null +++ b/src/lib/customer/address-rules.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { addressNeedsCheck, normalizeAddress, sameAddress } from "./address-rules"; + +const saved = { + fullName: "Ada Lovelace", + line1: "ul. Długa 1", + line2: null, + postalCode: "00-950", + city: "Warsaw", + phone: null, + countryCode: "PL", +}; + +describe("normalizeAddress", () => { + it("trims every field and reads an empty line 2 or phone as null", () => { + expect( + normalizeAddress({ ...saved, fullName: " Ada Lovelace ", line2: " ", phone: "" }), + ).toEqual(saved); + }); +}); + +describe("sameAddress (AC-15)", () => { + it("treats null and empty alike and compares every field", () => { + expect(sameAddress(saved, { ...saved, line2: "", phone: " " })).toBe(true); + expect(sameAddress(saved, { ...saved, city: "Kraków" })).toBe(false); + expect(sameAddress(saved, { ...saved, phone: "+48 600 100 200" })).toBe(false); + }); +}); + +describe("addressNeedsCheck (AC-14)", () => { + it("accepts an address the current rules accept", () => { + expect(addressNeedsCheck(saved, "PL")).toBe(false); + }); + + it("flags another country, a bad postal code or a field the rules now refuse", () => { + expect(addressNeedsCheck({ ...saved, countryCode: "DE" }, "PL")).toBe(true); + expect(addressNeedsCheck({ ...saved, postalCode: "12345" }, "PL")).toBe(true); + expect(addressNeedsCheck({ ...saved, fullName: "x".repeat(101) }, "PL")).toBe(true); + // Stored unnormalized (00950): the rules would store it differently. + expect(addressNeedsCheck({ ...saved, postalCode: "00950" }, "PL")).toBe(true); + }); +}); diff --git a/src/lib/customer/address-rules.ts b/src/lib/customer/address-rules.ts new file mode 100644 index 0000000..1b2a982 --- /dev/null +++ b/src/lib/customer/address-rules.ts @@ -0,0 +1,66 @@ +import { + shippingAddressSchema, + type ShippingAddress, + type StoreCountry, +} from "@/lib/shipping/address"; + +// Pure: the address book rules (spec 0012, AC-14 and AC-15), shared by the address book and +// checkout. + +export const MAX_SAVED_ADDRESSES = 10; + +export type SavedAddressFields = { + readonly fullName: string; + readonly line1: string; + readonly line2: string | null; + readonly postalCode: string; + readonly city: string; + readonly phone: string | null; + readonly countryCode: string; +}; + +// Trimmed, with an empty line2 or phone as null, so a saved row and a typed address compare +// field by field. +export function normalizeAddress(fields: SavedAddressFields): SavedAddressFields { + const optional = (value: string | null) => { + const trimmed = (value ?? "").trim(); + return trimmed === "" ? null : trimmed; + }; + return { + fullName: fields.fullName.trim(), + line1: fields.line1.trim(), + line2: optional(fields.line2), + postalCode: fields.postalCode.trim(), + city: fields.city.trim(), + phone: optional(fields.phone), + countryCode: fields.countryCode.trim(), + }; +} + +const compared = [ + "fullName", + "line1", + "line2", + "postalCode", + "city", + "phone", + "countryCode", +] as const; + +export function sameAddress(a: SavedAddressFields, b: SavedAddressFields): boolean { + const left = normalizeAddress(a); + const right = normalizeAddress(b); + return compared.every((key) => left[key] === right[key]); +} + +export function addressFields(address: ShippingAddress, country: StoreCountry): SavedAddressFields { + return normalizeAddress({ ...address, countryCode: country }); +} + +// AC-14: a saved address the current rules refuse (a changed postal rule, another country) shows +// "Check this address" and is never used by checkout until it is edited. +export function addressNeedsCheck(fields: SavedAddressFields, country: StoreCountry): boolean { + if (fields.countryCode !== country) return true; + const parsed = shippingAddressSchema(country).safeParse(fields); + return !parsed.success || !sameAddress(addressFields(parsed.data, country), fields); +} diff --git a/src/lib/customer/addresses.ts b/src/lib/customer/addresses.ts new file mode 100644 index 0000000..873d034 --- /dev/null +++ b/src/lib/customer/addresses.ts @@ -0,0 +1,121 @@ +import "server-only"; + +import { db, type Tx } from "@/lib/db"; + +import { + MAX_SAVED_ADDRESSES, + normalizeAddress, + sameAddress, + type SavedAddressFields, +} from "./address-rules"; + +// The address book writes (spec 0012, AC-14 and AC-15). Every function here runs inside a +// transaction that already holds the customer lock (src/lib/customer/lock.ts), so the default +// and the cap of 10 hold under concurrent saves. + +const addressColumns = { + id: true, + fullName: true, + line1: true, + line2: true, + postalCode: true, + city: true, + phone: true, + countryCode: true, + isDefault: true, +} as const; + +export type SavedAddress = SavedAddressFields & { + readonly id: string; + readonly isDefault: boolean; +}; + +// Default first, then the newest, then id. +const bookOrder = [ + { isDefault: "desc" as const }, + { createdAt: "desc" as const }, + { id: "desc" as const }, +]; + +export function listSavedAddresses( + customerId: string, + client: Pick = db, +): Promise { + return client.customerAddress.findMany({ + where: { customerId }, + orderBy: bookOrder, + select: addressColumns, + }); +} + +export type SaveOutcome = + | { readonly kind: "saved"; readonly id: string } + | { readonly kind: "existing"; readonly id: string } + | { readonly kind: "limit_reached" }; + +// An address equal to a saved one, field by field, adds nothing; at 10 nothing is added either. +// The first address saved becomes the default. +export async function saveAddress( + tx: Tx, + customerId: string, + fields: SavedAddressFields, +): Promise { + const saved = await listSavedAddresses(customerId, tx); + const match = saved.find((row) => sameAddress(row, fields)); + if (match) return { kind: "existing", id: match.id }; + if (saved.length >= MAX_SAVED_ADDRESSES) return { kind: "limit_reached" }; + const created = await tx.customerAddress.create({ + data: { customerId, ...normalizeAddress(fields), isDefault: saved.length === 0 }, + select: { id: true }, + }); + return { kind: "saved", id: created.id }; +} + +export async function updateSavedAddress( + tx: Tx, + customerId: string, + id: string, + fields: SavedAddressFields, +): Promise { + const { count } = await tx.customerAddress.updateMany({ + where: { id, customerId }, + data: normalizeAddress(fields), + }); + return count === 1; +} + +// Deleting the default makes the newest remaining address the default. +export async function deleteSavedAddress(tx: Tx, customerId: string, id: string): Promise { + const row = await tx.customerAddress.findFirst({ + where: { id, customerId }, + select: { isDefault: true }, + }); + if (!row) return false; + await tx.customerAddress.delete({ where: { id } }); + if (row.isDefault) { + const newest = await tx.customerAddress.findFirst({ + where: { customerId }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + select: { id: true }, + }); + if (newest) + await tx.customerAddress.update({ where: { id: newest.id }, data: { isDefault: true } }); + } + return true; +} + +// The old default is cleared before the new one is set, so the partial unique index (one default +// per customer) never fails. +export async function setDefaultAddress(tx: Tx, customerId: string, id: string): Promise { + const row = await tx.customerAddress.findFirst({ + where: { id, customerId }, + select: { id: true }, + }); + if (!row) return false; + await tx.customerAddress.updateMany({ + where: { customerId, isDefault: true, NOT: { id } }, + data: { isDefault: false }, + }); + await tx.customerAddress.update({ where: { id }, data: { isDefault: true } }); + return true; +} diff --git a/src/lib/customer/lock.ts b/src/lib/customer/lock.ts new file mode 100644 index 0000000..775c39e --- /dev/null +++ b/src/lib/customer/lock.ts @@ -0,0 +1,10 @@ +import "server-only"; + +import type { Tx } from "@/lib/db"; + +// spec 0012: one mutex per customer for every write that touches their cart, addresses or order +// links. "No cart yet" and "no address yet" have no row to lock, and one lock keeps the lock +// order question away. It lives until the transaction ends. +export async function lockCustomer(tx: Tx, customerId: string): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`customer:${customerId}`}))`; +} diff --git a/src/lib/customer/log.test.ts b/src/lib/customer/log.test.ts new file mode 100644 index 0000000..1b4b610 --- /dev/null +++ b/src/lib/customer/log.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createLogger } from "@/lib/logger"; + +// spec 0012, AC-24: account log lines carry ids, counts and reason codes only, at the right level. + +const { lines, sink } = await vi.hoisted(async () => { + const { Writable } = await import("node:stream"); + const lines: string[] = []; + const sink = new Writable({ + write(chunk: Buffer, _encoding, done) { + lines.push(chunk.toString()); + done(); + }, + }); + return { lines, sink }; +}); + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logger: actual.createLogger(sink) }; +}); + +const { accountEventRecord, logAccountEvent } = await import("./log"); + +beforeEach(() => { + lines.length = 0; +}); + +describe("accountEventRecord", () => { + it("keeps only the known fields and drops empty ones", () => { + expect(accountEventRecord("account.orders_linked", { customerId: "c1", count: 2 })).toEqual({ + event: "account.orders_linked", + customerId: "c1", + count: 2, + }); + expect(accountEventRecord("account.sign_in_failed", { customerId: null, reason: "x" })).toEqual( + { event: "account.sign_in_failed", reason: "x" }, + ); + }); + + it("never passes on a field it does not know, such as an email smuggled in", () => { + const smuggled = { + customerId: "c1", + email: "ada@example.com", + password: "Secret-1", + customerName: "Ada Lovelace", + customer_name: "Ada Lovelace", + } as never; + + expect(JSON.stringify(accountEventRecord("account.sign_up", smuggled))).not.toMatch( + /ada@example\.com|Secret-1|Lovelace/, + ); + }); +}); + +describe("logAccountEvent", () => { + it("logs conflicts and incomplete deletes as errors, refusals as warnings, the rest as info", () => { + logAccountEvent("account.email_conflict", { customerId: "c1" }); + logAccountEvent("account.delete_incomplete", { customerId: "c1" }); + logAccountEvent("account.sign_in_failed", { reason: "invalid_credentials" }); + logAccountEvent("account.delete_refused_admin", { customerId: "c1" }); + logAccountEvent("account.sign_in", { customerId: "c1" }); + + const levels = lines.map((line) => (JSON.parse(line) as { level: number }).level); + expect(levels).toEqual([50, 50, 40, 40, 30]); + }); + + it("is redacted by the shared logger even if a secret key ever slipped through", () => { + const logger = createLogger(sink); + logger.info({ event: "x", nested: { password: "Secret-1", token_hash: "abc" } }, "x"); + + expect(lines.join("")).not.toContain("Secret-1"); + expect(lines.join("")).not.toContain("abc"); + }); +}); diff --git a/src/lib/customer/log.ts b/src/lib/customer/log.ts new file mode 100644 index 0000000..dc6f169 --- /dev/null +++ b/src/lib/customer/log.ts @@ -0,0 +1,57 @@ +import { logger } from "@/lib/logger"; + +// spec 0012, AC-24: the customer id, order ids and numbers, counts and reason codes only. Never an +// email, name, phone, address, password, token, token hash or idempotency key. + +export type AccountEvent = + | "account.sign_up" + | "account.sign_in" + | "account.sign_in_failed" + | "account.confirmed" + | "account.orders_linked" + | "account.cart_merged" + | "account.address_saved" + | "account.password_changed" + | "account.reset_requested" + | "account.password_reset" + | "account.deleted" + | "account.delete_refused_admin" + | "account.email_conflict" + | "account.delete_incomplete"; + +export type AccountEventFields = { + readonly customerId?: string | null; + readonly count?: number; + // A fixed reason code, never a Supabase message. + readonly reason?: string; + readonly lines?: number; + readonly sent?: boolean; +}; + +const errorEvents: ReadonlySet = new Set([ + "account.email_conflict", + "account.delete_incomplete", +]); +const warnEvents: ReadonlySet = new Set([ + "account.sign_in_failed", + "account.delete_refused_admin", +]); + +export function accountEventRecord(event: AccountEvent, fields: AccountEventFields) { + const { customerId, count, reason, lines, sent } = fields; + return { + event, + ...(customerId ? { customerId } : {}), + ...(count === undefined ? {} : { count }), + ...(reason === undefined ? {} : { reason }), + ...(lines === undefined ? {} : { lines }), + ...(sent === undefined ? {} : { sent }), + }; +} + +export function logAccountEvent(event: AccountEvent, fields: AccountEventFields = {}) { + const record = accountEventRecord(event, fields); + if (errorEvents.has(event)) logger.error(record, event); + else if (warnEvents.has(event)) logger.warn(record, event); + else logger.info(record, event); +} diff --git a/src/lib/customer/session.test.ts b/src/lib/customer/session.test.ts new file mode 100644 index 0000000..120d5fb --- /dev/null +++ b/src/lib/customer/session.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// spec 0012, AC-10 and AC-13: who the signed in customer is, with Supabase and the database mocked. +// The real row writes run in tests/db/account-setup.db.test.ts. + +const mocks = vi.hoisted(() => { + class NavigationSignal extends Error {} + return { + NavigationSignal, + redirect: vi.fn((url: string) => { + throw new NavigationSignal(`redirect:${url}`); + }), + getUser: vi.fn(), + findUnique: vi.fn(), + transaction: vi.fn(), + lockCustomer: vi.fn(), + ensureCustomerRow: vi.fn(), + logAccountEvent: vi.fn(), + }; +}); + +vi.mock("server-only", () => ({})); +// React's cache() memoizes per request; outside one each call runs on its own. +vi.mock("react", () => ({ cache: (fn: T) => fn })); +vi.mock("next/navigation", () => ({ redirect: mocks.redirect })); +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: async () => ({ auth: { getUser: mocks.getUser } }), +})); +vi.mock("@/lib/db", () => ({ + db: { + customer: { findUnique: mocks.findUnique }, + $transaction: mocks.transaction, + }, +})); +vi.mock("./lock", () => ({ lockCustomer: mocks.lockCustomer })); +vi.mock("./setup", () => ({ ensureCustomerRow: mocks.ensureCustomerRow })); +vi.mock("./log", () => ({ logAccountEvent: mocks.logAccountEvent })); + +const { customerIdentity, getCustomer, requireCustomer } = await import("./session"); + +const USER_ID = "33333333-3333-3333-3333-333333333333"; +const confirmedUser = { + id: USER_ID, + email: "Ada@Example.com", + email_confirmed_at: "2026-10-14T10:00:00Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getUser.mockResolvedValue({ data: { user: confirmedUser } }); + mocks.findUnique.mockResolvedValue({ email: "ada@example.com" }); + mocks.transaction.mockImplementation(async (run: (tx: object) => Promise) => run({})); + mocks.ensureCustomerRow.mockResolvedValue(true); +}); + +describe("customerIdentity", () => { + it("is the user id and the lower cased email", () => { + expect(customerIdentity(confirmedUser)).toEqual({ id: USER_ID, email: "ada@example.com" }); + }); + + it("is null before the email is confirmed, or with no email", () => { + expect(customerIdentity({ ...confirmedUser, email_confirmed_at: undefined })).toBeNull(); + expect(customerIdentity({ ...confirmedUser, email: undefined })).toBeNull(); + }); +}); + +describe("getCustomer", () => { + it("returns the confirmed customer without a write when the row is in step", async () => { + expect(await getCustomer()).toEqual({ id: USER_ID, email: "ada@example.com" }); + expect(mocks.transaction).not.toHaveBeenCalled(); + }); + + it("is null when Supabase knows no user (signed out, revoked or deleted)", async () => { + mocks.getUser.mockResolvedValue({ data: { user: null } }); + + expect(await getCustomer()).toBeNull(); + expect(mocks.findUnique).not.toHaveBeenCalled(); + }); + + it("is null for an unconfirmed user", async () => { + mocks.getUser.mockResolvedValue({ + data: { user: { ...confirmedUser, email_confirmed_at: undefined } }, + }); + + expect(await getCustomer()).toBeNull(); + }); + + it("creates or updates the row under the customer lock when it is missing or stale", async () => { + mocks.findUnique.mockResolvedValue(null); + + expect(await getCustomer()).toEqual({ id: USER_ID, email: "ada@example.com" }); + expect(mocks.lockCustomer).toHaveBeenCalledWith({}, USER_ID); + expect(mocks.ensureCustomerRow).toHaveBeenCalledWith( + {}, + { id: USER_ID, email: "ada@example.com" }, + ); + }); + + it("reads a live email conflict as signed out and logs only the id", async () => { + mocks.findUnique.mockResolvedValue({ email: "old@example.com" }); + mocks.ensureCustomerRow.mockResolvedValue(false); + + expect(await getCustomer()).toBeNull(); + expect(mocks.logAccountEvent).toHaveBeenCalledWith("account.email_conflict", { + customerId: USER_ID, + }); + }); +}); + +describe("requireCustomer (AC-13)", () => { + it("returns the signed in customer", async () => { + expect(await requireCustomer()).toEqual({ id: USER_ID, email: "ada@example.com" }); + }); + + it("sends anyone else to sign in", async () => { + mocks.getUser.mockResolvedValue({ data: { user: null } }); + + await expect(requireCustomer()).rejects.toThrow("redirect:/account/sign-in"); + }); +}); diff --git a/src/lib/customer/session.ts b/src/lib/customer/session.ts new file mode 100644 index 0000000..a1c7f92 --- /dev/null +++ b/src/lib/customer/session.ts @@ -0,0 +1,58 @@ +import "server-only"; + +import type { User } from "@supabase/supabase-js"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +import { db } from "@/lib/db"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; + +import { lockCustomer } from "./lock"; +import { logAccountEvent } from "./log"; +import { type CustomerIdentity, ensureCustomerRow } from "./setup"; + +export const accountSignInPath = "/account/sign-in"; + +// The identity a Supabase user stands for, or null when they have not confirmed their email. +export function customerIdentity(user: Pick) { + if (!user.email || !user.email_confirmed_at) return null; + return { id: user.id, email: user.email.toLowerCase() } satisfies CustomerIdentity; +} + +// spec 0012, AC-10: the signed in customer, once per request. getUser() asks the Auth server, so a +// signed out, revoked or deleted account fails at once; getClaims() would trust its token for up +// to an hour and recreate the row of a deleted account. The customers row is created or brought in +// step under the customer lock, and a live email conflict reads as signed out. +export const getCustomer = cache(async (): Promise => { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + const customer = user ? customerIdentity(user) : null; + if (customer === null) return null; + + const row = await db.customer.findUnique({ where: { id: customer.id }, select: { email: true } }); + if (row?.email === customer.email) return customer; + + const ensured = await db.$transaction(async (tx) => { + await lockCustomer(tx, customer.id); + return ensureCustomerRow(tx, customer); + }); + if (!ensured) { + logAccountEvent("account.email_conflict", { customerId: customer.id }); + return null; + } + return customer; +}); + +export async function getCustomerId(): Promise { + return (await getCustomer())?.id ?? null; +} + +// Every account page and action below the auth pages calls this itself (AC-13); the proxy is only +// the first gate. It only returns for a signed in, confirmed customer. +export async function requireCustomer(): Promise { + const customer = await getCustomer(); + if (customer === null) redirect(accountSignInPath); + return customer; +} diff --git a/src/lib/customer/setup.ts b/src/lib/customer/setup.ts new file mode 100644 index 0000000..33cd874 --- /dev/null +++ b/src/lib/customer/setup.ts @@ -0,0 +1,93 @@ +import "server-only"; + +import { clearCartCookie, readCartId, setCartCookie } from "@/lib/cart/cookie"; +import { type MergeOutcome, mergeCarts } from "@/lib/cart/merge"; +import { db, type Tx } from "@/lib/db"; + +import { lockCustomer } from "./lock"; +import { logAccountEvent } from "./log"; + +// A confirmed Supabase user as this store knows them: the auth user id and the lower cased email. +export type CustomerIdentity = { readonly id: string; readonly email: string }; + +// spec 0012, AC-10: makes sure the customers row exists and carries the auth email. Another row +// holding the email is removed first only when its auth user is gone (an account deleted after a +// failed commit, AC-20); a live one is a conflict the caller reports. Runs under the lock. +export async function ensureCustomerRow(tx: Tx, customer: CustomerIdentity): Promise { + const holder = await tx.customer.findUnique({ + where: { email: customer.email }, + select: { id: true }, + }); + if (holder && holder.id !== customer.id) { + // auth.users is Supabase's own table: read only. + const live = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM auth.users WHERE id = ${holder.id}::uuid`; + if (live.length > 0) return false; + await tx.customer.delete({ where: { id: holder.id } }); + } + await tx.customer.upsert({ + where: { id: customer.id }, + create: { id: customer.id, email: customer.email }, + update: { email: customer.email }, + select: { id: true }, + }); + return true; +} + +// AC-10 (2): every guest order placed with this email joins the account. Checkout stores the email +// lower cased, so equality is enough. Unpaid ones stay hidden by the history query, not here. +export async function linkGuestOrders(tx: Tx, customer: CustomerIdentity): Promise { + const { count } = await tx.order.updateMany({ + where: { email: customer.email, customerId: null }, + data: { customerId: customer.id }, + }); + return count; +} + +export type SetupOutcome = + | { readonly ok: true; readonly linked: number; readonly merge: MergeOutcome } + | { readonly ok: false; readonly error: "email_conflict" }; + +// "Account setup" (AC-10): after choosing a password and after every sign in, in one transaction +// under the customer lock: the row, the order links, then the cart merge (AC-5). The identity +// comes from the user Supabase just returned, never from a cookie read in the same request. +export async function accountSetup(customer: CustomerIdentity): Promise { + const cookieCartId = await readCartId(); + const outcome = await db.$transaction(async (tx): Promise => { + await lockCustomer(tx, customer.id); + if (!(await ensureCustomerRow(tx, customer))) return { ok: false, error: "email_conflict" }; + const linked = await linkGuestOrders(tx, customer); + const merge = await mergeCarts(tx, customer.id, cookieCartId, Date.now()); + return { ok: true, linked, merge }; + }); + if (!outcome.ok) { + logAccountEvent("account.email_conflict", { customerId: customer.id }); + return outcome; + } + await finishMerge(customer.id, cookieCartId, outcome.merge); + if (outcome.linked > 0) { + logAccountEvent("account.orders_linked", { customerId: customer.id, count: outcome.linked }); + } + return outcome; +} + +// The cart merge on its own (AC-5), for checkout (AC-15): under the customer lock, then the cookie. +export async function mergeCartForCustomer(customerId: string): Promise { + const cookieCartId = await readCartId(); + const merge = await db.$transaction(async (tx) => { + await lockCustomer(tx, customerId); + return mergeCarts(tx, customerId, cookieCartId, Date.now()); + }); + await finishMerge(customerId, cookieCartId, merge); + return merge; +} + +// Every merge renews the cookie with the cart's own expiry; with no cart left, a cookie that +// named someone else's cart is dropped. +async function finishMerge(customerId: string, cookieCartId: string | null, merge: MergeOutcome) { + if (merge.cartId !== null) await setCartCookie(merge.cartId, merge.expiresAt); + else if (cookieCartId !== null) await clearCartCookie(); + if (merge.linesMerged !== null) { + logAccountEvent("account.cart_merged", { customerId, lines: merge.linesMerged }); + } +} diff --git a/src/lib/customer/store-admin.ts b/src/lib/customer/store-admin.ts new file mode 100644 index 0000000..11fc64d --- /dev/null +++ b/src/lib/customer/store-admin.ts @@ -0,0 +1,18 @@ +import "server-only"; + +import { db, type Tx } from "@/lib/db"; + +type Client = Pick; + +// spec 0012, AC-8 and AC-19: a user who runs the store (an admin_users row in any state, or a +// verified authenticator) always takes the admin path for passwords, so an inbox alone never +// resets a password that guards /admin. auth.mfa_factors is Supabase's own table: read only. +export async function runsTheStore(userId: string, client: Client = db): Promise { + const rows = await client.$queryRaw<{ found: boolean }[]>` + SELECT EXISTS (SELECT 1 FROM admin_users WHERE id = ${userId}::uuid) + OR EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = ${userId}::uuid AND status = 'verified' + ) AS found`; + return rows[0]?.found ?? false; +} diff --git a/src/lib/email/auth-hook.test.ts b/src/lib/email/auth-hook.test.ts new file mode 100644 index 0000000..c380e4f --- /dev/null +++ b/src/lib/email/auth-hook.test.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + authEmailKind, + authEmailLink, + authIdempotencyKey, + hookPayloadSchema, + hookSecretKey, + usedActionType, +} from "./auth-hook"; + +// spec 0012, AC-8. + +describe("usedActionType", () => { + it("keeps sign up and recovery and refuses every other type", () => { + expect(usedActionType("signup")).toBe("signup"); + expect(usedActionType("recovery")).toBe("recovery"); + for (const type of ["magiclink", "invite", "email_change", "reauthentication", "email"]) { + expect(usedActionType(type)).toBeNull(); + } + }); +}); + +describe("authEmailKind", () => { + it("confirms a sign up whoever the user is", () => { + expect(authEmailKind("signup", false)).toBe("confirm_signup"); + expect(authEmailKind("signup", true)).toBe("confirm_signup"); + }); + + it("sends the admin reset to anyone who runs the store, else the customer reset", () => { + expect(authEmailKind("recovery", true)).toBe("admin_reset"); + expect(authEmailKind("recovery", false)).toBe("customer_reset"); + }); +}); + +describe("authEmailLink", () => { + const site = "https://shop.example"; + + it("points customers at /account/confirm and admins at /auth/confirm", () => { + expect(authEmailLink(site, "confirm_signup", "abc")).toBe( + "https://shop.example/account/confirm?token_hash=abc&type=signup", + ); + expect(authEmailLink(site, "customer_reset", "abc")).toBe( + "https://shop.example/account/confirm?token_hash=abc&type=recovery", + ); + expect(authEmailLink(site, "admin_reset", "abc")).toBe( + "https://shop.example/auth/confirm?token_hash=abc&type=recovery", + ); + }); + + it("encodes the token hash", () => { + expect(authEmailLink(site, "confirm_signup", "a&b=c")).toContain("token_hash=a%26b%3Dc"); + }); +}); + +describe("authIdempotencyKey", () => { + it("is the type plus the SHA 256 of the token hash, never the hash itself", () => { + const key = authIdempotencyKey("signup", "pkce_secret"); + const digest = createHash("sha256").update("pkce_secret").digest("hex"); + + expect(key).toBe(`auth:signup:${digest}`); + expect(key).not.toContain("pkce_secret"); + }); +}); + +describe("hookSecretKey", () => { + it("strips the v1,whsec_ prefix Supabase shows", () => { + expect(hookSecretKey("v1,whsec_abc123==")).toBe("abc123=="); + }); +}); + +describe("hookPayloadSchema", () => { + it("needs a user id and email and a token hash", () => { + const valid = { + user: { id: "8b4a7e2c-3f6d-4c1a-9e8b-2d5f7a1c3e9b", email: "ada@example.com" }, + email_data: { token_hash: "abc", email_action_type: "signup", redirect_to: "x" }, + }; + expect(hookPayloadSchema.safeParse(valid).success).toBe(true); + expect( + hookPayloadSchema.safeParse({ ...valid, email_data: { email_action_type: "signup" } }) + .success, + ).toBe(false); + }); +}); diff --git a/src/lib/email/auth-hook.ts b/src/lib/email/auth-hook.ts new file mode 100644 index 0000000..b009589 --- /dev/null +++ b/src/lib/email/auth-hook.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +// Pure: the Send Email Hook's payload, which email it sends, and where the link points +// (spec 0012, AC-8). The signature is checked before anything here runs. + +export const hookPayloadSchema = z.object({ + user: z.object({ id: z.uuid(), email: z.email() }), + email_data: z.object({ + token_hash: z.string().min(1), + email_action_type: z.string().min(1), + }), +}); + +export type HookPayload = z.output; + +// The only Supabase email types this store uses. Every other (magiclink, invite, email_change, +// reauthentication, notifications) is refused. +export type AuthActionType = "signup" | "recovery"; + +export function usedActionType(type: string): AuthActionType | null { + return type === "signup" || type === "recovery" ? type : null; +} + +export type AuthEmailKind = "confirm_signup" | "customer_reset" | "admin_reset"; + +// Admin first: whether the user runs the store cannot be steered by the caller, unlike +// redirect_to, which anyone with the anon key can set. So an inbox alone never resets a password +// that guards /admin. +export function authEmailKind(type: AuthActionType, runsTheStore: boolean): AuthEmailKind { + if (type === "signup") return "confirm_signup"; + return runsTheStore ? "admin_reset" : "customer_reset"; +} + +const linkPaths: Record = { + confirm_signup: "/account/confirm", + customer_reset: "/account/confirm", + admin_reset: "/auth/confirm", +}; + +const linkTypes: Record = { + confirm_signup: "signup", + customer_reset: "recovery", + admin_reset: "recovery", +}; + +// The token hash link pattern: the page shows Continue first, and only that POST spends it. +export function authEmailLink(siteUrl: string, kind: AuthEmailKind, tokenHash: string): string { + const url = new URL(linkPaths[kind], siteUrl); + url.searchParams.set("token_hash", tokenHash); + url.searchParams.set("type", linkTypes[kind]); + return url.toString(); +} + +// One send per link: Supabase retrying a hook call never mails the same link twice. Hashed, so +// the key never carries the token hash itself. +export function authIdempotencyKey(type: AuthActionType, tokenHash: string): string { + return `auth:${type}:${createHash("sha256").update(tokenHash).digest("hex")}`; +} + +// The secret as Supabase shows it (`v1,whsec_`), as the standardwebhooks package takes it. +export function hookSecretKey(secret: string): string { + return secret.replace(/^v1,whsec_/, ""); +} + +// The error body Supabase reads from a failed hook. +export function hookErrorBody(httpCode: number, message: string) { + return { error: { http_code: httpCode, message } }; +} diff --git a/src/lib/email/log.ts b/src/lib/email/log.ts index 7da8f82..6b65f68 100644 --- a/src/lib/email/log.ts +++ b/src/lib/email/log.ts @@ -2,6 +2,8 @@ import type { EmailKind } from "@/generated/prisma/client"; import { pgErrorCode } from "@/lib/db-errors"; import { logger } from "@/lib/logger"; +import type { TransportName } from "./transport"; + // spec 0011, AC-17: the row id, kind, order id and number, refund id, attempts and Resend's // error name. Never a recipient address, a subject, a body, a name, an address or a tracking // number. `errorName`, never `code`: the logger redacts any key named code. @@ -19,7 +21,7 @@ export function logEmailQueued(fields: EmailLogFields) { logger.info({ event: "email.queued", ...fields }, "email.queued"); } -export function logEmailSent(fields: EmailLogFields & { readonly transport: "log" | "resend" }) { +export function logEmailSent(fields: EmailLogFields & { readonly transport: TransportName }) { logger.info({ event: "email.sent", ...fields }, "email.sent"); } diff --git a/src/lib/email/render-auth.tsx b/src/lib/email/render-auth.tsx new file mode 100644 index 0000000..ac00e4f --- /dev/null +++ b/src/lib/email/render-auth.tsx @@ -0,0 +1,42 @@ +import { render } from "react-email"; + +import { + AdminResetPasswordEmail, + adminResetPasswordSubject, +} from "../../../emails/admin-reset-password"; +import type { EmailFrame } from "../../../emails/components/layout"; +import { ConfirmSignupEmail, confirmSignupSubject } from "../../../emails/confirm-signup"; +import { ResetPasswordEmail, resetPasswordSubject } from "../../../emails/reset-password"; +import type { AuthEmailKind } from "./auth-hook"; +import type { RenderedEmail } from "./render"; + +function element(kind: AuthEmailKind, frame: EmailFrame, link: string) { + switch (kind) { + case "confirm_signup": + return { + subject: confirmSignupSubject, + node: , + }; + case "customer_reset": + return { + subject: resetPasswordSubject, + node: , + }; + case "admin_reset": + return { + subject: adminResetPasswordSubject, + node: , + }; + } +} + +// spec 0012, AC-8: like the order emails, the plain text version comes from the same component. +export async function renderAuthEmail( + kind: AuthEmailKind, + frame: EmailFrame, + link: string, +): Promise { + const { subject, node } = element(kind, frame, link); + const [html, text] = await Promise.all([render(node), render(node, { plainText: true })]); + return { subject, html, text }; +} diff --git a/src/lib/email/transport.test.ts b/src/lib/email/transport.test.ts index c5d917b..343aac7 100644 --- a/src/lib/email/transport.test.ts +++ b/src/lib/email/transport.test.ts @@ -6,8 +6,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ env: { - EMAIL_TRANSPORT: "log" as "log" | "resend", + EMAIL_TRANSPORT: "log" as "log" | "resend" | "mailpit", RESEND_API_KEY: "re_test_key" as string | undefined, + MAILPIT_URL: "http://127.0.0.1:55324", EMAIL_FROM: "BeStore ", }, constructed: vi.fn(), @@ -127,3 +128,52 @@ describe("the resend transport", () => { }); }); }); + +// spec 0012, AC-23: every email lands in Mailpit, so e2e can read it. +describe("the mailpit transport", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + mocks.env.EMAIL_TRANSPORT = "mailpit"; + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ID: "abc" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + }); + + it("posts the email to Mailpit's send API and never reaches Resend", async () => { + const transport = currentTransport(); + const result = await transport.send(email); + + expect(transport.name).toBe("mailpit"); + expect(result).toEqual({ ok: true, resendId: null }); + expect(mocks.send).not.toHaveBeenCalled(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(String(url)).toBe("http://127.0.0.1:55324/api/v1/send"); + expect(JSON.parse(init.body)).toEqual({ + From: { Email: "orders@example.com", Name: "BeStore" }, + To: [{ Email: "ada@example.com" }], + Subject: "Order #1042 confirmed", + HTML: "

Hi Ada,

", + Text: "Hi Ada,", + }); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("answers a failure with Mailpit's status", async () => { + fetchMock.mockResolvedValue(new Response("nope", { status: 500 })); + + expect(await currentTransport().send(email)).toEqual({ + ok: false, + failure: { name: "mailpit_error", statusCode: 500 }, + }); + }); + + it("answers a network error as a failure with no status", async () => { + fetchMock.mockRejectedValue(new TypeError("fetch failed")); + + expect(await currentTransport().send(email)).toEqual({ + ok: false, + failure: { name: "TypeError", statusCode: null }, + }); + }); +}); diff --git a/src/lib/email/transport.ts b/src/lib/email/transport.ts index 786a337..130cd6c 100644 --- a/src/lib/email/transport.ts +++ b/src/lib/email/transport.ts @@ -6,9 +6,10 @@ import { env } from "@/lib/env"; import type { SendFailure } from "./classify"; -// The two ways an email leaves (spec 0011, AC-16): `resend` sends it, `log` sends nothing so -// local runs, tests and CI never reach a customer. Open and click tracking are domain settings in -// Resend and stay off there (AC-7). +// The ways an email leaves (spec 0011, AC-16): `resend` sends it, `log` sends nothing so local +// runs, tests and CI never reach a customer. `mailpit` (spec 0012, AC-23) hands it to the local +// stack's Mailpit, so e2e can read it; the env schema refuses it in production. Open and click +// tracking are domain settings in Resend and stay off there (AC-7). export type OutgoingEmail = { readonly to: string; @@ -23,9 +24,15 @@ export type SendResult = | { readonly ok: true; readonly resendId: string | null } | { readonly ok: false; readonly failure: SendFailure }; +export type TransportName = "log" | "resend" | "mailpit"; + export type Transport = { - readonly name: "log" | "resend"; - readonly send: (email: OutgoingEmail) => Promise; + readonly name: TransportName; + // timeoutMs caps the one call; the auth email hook passes a shorter one than the default. + readonly send: ( + email: OutgoingEmail, + options?: { readonly timeoutMs?: number }, + ) => Promise; }; export const EMAIL_SEND_TIMEOUT_MS = 10_000; @@ -37,7 +44,7 @@ const logTransport: Transport = { const resendTransport: Transport = { name: "resend", - send: async (email) => { + send: async (email, options) => { // Built per send (the constructor only sets headers), so importing this file never needs // the key. The SDK answers { error } instead of throwing; an abort comes back as statusCode // null. @@ -51,7 +58,7 @@ const resendTransport: Transport = { }, { idempotencyKey: email.idempotencyKey, - signal: AbortSignal.timeout(EMAIL_SEND_TIMEOUT_MS), + signal: AbortSignal.timeout(options?.timeoutMs ?? EMAIL_SEND_TIMEOUT_MS), }, ); if (error) return { ok: false, failure: { name: error.name, statusCode: error.statusCode } }; @@ -59,6 +66,50 @@ const resendTransport: Transport = { }, }; +// `Name
` split for Mailpit's JSON; a bare address has no name. +function mailpitSender(from: string): { readonly Email: string; readonly Name?: string } { + const match = /^\s*([^<>]*?)\s*<([^<>\s]+)>\s*$/.exec(from); + if (!match?.[2]) return { Email: from.trim() }; + return match[1] ? { Email: match[2], Name: match[1] } : { Email: match[2] }; +} + +// Mailpit's send API has no idempotency: every call lands, which e2e relies on for resends. +// Failures map onto the Resend shape, so the classifier reads them the same way. +const mailpitTransport: Transport = { + name: "mailpit", + send: async (email, options) => { + try { + const response = await fetch(new URL("/api/v1/send", env.MAILPIT_URL), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + From: mailpitSender(env.EMAIL_FROM), + To: [{ Email: email.to }], + Subject: email.subject, + HTML: email.html, + Text: email.text, + }), + signal: AbortSignal.timeout(options?.timeoutMs ?? EMAIL_SEND_TIMEOUT_MS), + }); + if (!response.ok) { + return { ok: false, failure: { name: "mailpit_error", statusCode: response.status } }; + } + // Mailpit's message id is not a Resend id, so none is recorded. + return { ok: true, resendId: null }; + } catch (error) { + const name = error instanceof Error ? error.name : "mailpit_error"; + return { ok: false, failure: { name, statusCode: null } }; + } + }, +}; + export function currentTransport(): Transport { - return env.EMAIL_TRANSPORT === "resend" ? resendTransport : logTransport; + switch (env.EMAIL_TRANSPORT) { + case "resend": + return resendTransport; + case "mailpit": + return mailpitTransport; + case "log": + return logTransport; + } } diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index 1e1d9a2..abc81e8 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -326,6 +326,41 @@ describe("env", () => { expect(String(error)).not.toContain("sk_test_not_resend"); }); + // spec 0012, AC-23. + it("refuses the mailpit transport in production", async () => { + stubEnv({ EMAIL_TRANSPORT: "mailpit" }); + vi.stubEnv("MAILPIT_URL", "http://127.0.0.1:55324"); + vi.stubEnv("VERCEL_ENV", "production"); + + await expect(loadEnv()).rejects.toThrow("EMAIL_TRANSPORT"); + }); + + it.each([undefined, ""])("requires MAILPIT_URL for mailpit (%j)", async (url) => { + stubEnv({ EMAIL_TRANSPORT: "mailpit" }); + vi.stubEnv("MAILPIT_URL", url); + + await expect(loadEnv()).rejects.toThrow("MAILPIT_URL"); + }); + + it("accepts mailpit with a URL", async () => { + stubEnv({ EMAIL_TRANSPORT: "mailpit" }); + vi.stubEnv("MAILPIT_URL", "http://127.0.0.1:55324"); + + await expect(loadEnv()).resolves.toMatchObject({ + EMAIL_TRANSPORT: "mailpit", + MAILPIT_URL: "http://127.0.0.1:55324", + }); + }); + + it.each(["whsec_abc", "v1,abc", "v1,whsec_not base64!"])( + "rejects a hook secret that is not a Standard Webhooks secret (%j)", + async (secret) => { + stubEnv({ SEND_EMAIL_HOOK_SECRET: secret }); + + await expect(loadEnv()).rejects.toThrow("SEND_EMAIL_HOOK_SECRET"); + }, + ); + it("rejects an unknown transport", async () => { stubEnv({ EMAIL_TRANSPORT: "smtp" }); diff --git a/src/lib/env.ts b/src/lib/env.ts index 693dbc4..77296dd 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -45,7 +45,14 @@ const envSchema = z // A blank line (as copied from .env.example) means unset, not an invalid URL. TEST_DATABASE_URL: z.preprocess((v) => (v === "" ? undefined : v), postgresUrl().optional()), // spec 0011, AC-16: `log` sends nothing (local, tests, CI); production must use `resend`. - EMAIL_TRANSPORT: z.enum(["log", "resend"]), + // spec 0012, AC-23: `mailpit` hands every email to the local stack's Mailpit (e2e, CI). + EMAIL_TRANSPORT: z.enum(["log", "resend", "mailpit"]), + // Mailpit's base URL; required when EMAIL_TRANSPORT is mailpit. + MAILPIT_URL: z.preprocess((v) => (v === "" ? undefined : v), z.url().optional()), + // spec 0012, AC-8: the Standard Webhooks secret Supabase signs the Send Email Hook with. + SEND_EMAIL_HOOK_SECRET: z + .string() + .regex(/^v1,whsec_[A-Za-z0-9+/]+={0,2}$/, "Standard Webhooks secret, e.g. v1,whsec_..."), // A sending only key; required when EMAIL_TRANSPORT is resend. RESEND_API_KEY: z.preprocess( (v) => (v === "" ? undefined : v), @@ -60,13 +67,20 @@ const envSchema = z }), }) .superRefine((value, ctx) => { - if (value.EMAIL_TRANSPORT === "log" && value.VERCEL_ENV === "production") { + if (value.EMAIL_TRANSPORT !== "resend" && value.VERCEL_ENV === "production") { ctx.addIssue({ code: "custom", path: ["EMAIL_TRANSPORT"], message: "Must be resend in production, or customers get no email", }); } + if (value.EMAIL_TRANSPORT === "mailpit" && value.MAILPIT_URL === undefined) { + ctx.addIssue({ + code: "custom", + path: ["MAILPIT_URL"], + message: "Required when EMAIL_TRANSPORT is mailpit", + }); + } if (value.EMAIL_TRANSPORT === "resend" && value.RESEND_API_KEY === undefined) { ctx.addIssue({ code: "custom", diff --git a/src/lib/form-errors.test.ts b/src/lib/form-errors.test.ts new file mode 100644 index 0000000..7f747fc --- /dev/null +++ b/src/lib/form-errors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { applyFieldErrors, fieldErrors } from "./form-errors"; + +describe("fieldErrors", () => { + it("keys every message by its field", () => { + const schema = z.object({ email: z.string().min(1, "Enter it"), code: z.string() }); + const parsed = schema.safeParse({ email: "", code: 1 }); + if (parsed.success) throw new Error("expected a failed parse"); + + const errors = fieldErrors<"email" | "code">(parsed.error); + + expect(errors.email).toEqual(["Enter it"]); + expect(errors.code).toHaveLength(1); + }); +}); + +describe("applyFieldErrors", () => { + it("sets the first message of each field and focuses only the first field", () => { + const setError = vi.fn(); + + applyFieldErrors(setError, { email: ["Enter it", "Second"], password: ["Too short"] }); + + expect(setError.mock.calls).toEqual([ + ["email", { type: "server", message: "Enter it" }, { shouldFocus: true }], + ["password", { type: "server", message: "Too short" }, { shouldFocus: false }], + ]); + }); +}); diff --git a/src/features/admin-auth/components/form-errors.ts b/src/lib/form-errors.ts similarity index 61% rename from src/features/admin-auth/components/form-errors.ts rename to src/lib/form-errors.ts index c34ad4b..c8f9963 100644 --- a/src/features/admin-auth/components/form-errors.ts +++ b/src/lib/form-errors.ts @@ -1,6 +1,15 @@ import type { FieldValues, Path, UseFormSetError } from "react-hook-form"; +import { z } from "zod"; -// Server side field errors (the same Zod schema, parsed again in the action) onto the form. +// Field errors from a failed Zod parse in a server action, keyed by field name. +export function fieldErrors( + error: z.ZodError, +): Partial> { + return z.flattenError(error).fieldErrors as Partial>; +} + +// Server side field errors (the same Zod schema, parsed again in the action) onto the form, +// focusing the first invalid field. export function applyFieldErrors( setError: UseFormSetError, fields: Partial, readonly string[]>> | undefined, diff --git a/src/lib/orders/order-number.ts b/src/lib/orders/order-number.ts new file mode 100644 index 0000000..e45f15a --- /dev/null +++ b/src/lib/orders/order-number.ts @@ -0,0 +1,2 @@ +// orders.number is a Postgres int: a larger number from a URL or a form can never match a row. +export const maxOrderNumber = 2_147_483_647; diff --git a/src/lib/orders/status-labels.ts b/src/lib/orders/status-labels.ts new file mode 100644 index 0000000..17c555c --- /dev/null +++ b/src/lib/orders/status-labels.ts @@ -0,0 +1,12 @@ +import type { OrderStatus } from "@/generated/prisma/enums"; + +// One wording for every page that names an order's status, admin and storefront alike. A customer +// never sees pending_payment or expired (spec 0012, AC-11), so only the admin reads those two. +export const orderStatusLabels: Record = { + pending_payment: "Awaiting payment", + paid: "Paid", + shipped: "Shipped", + delivered: "Delivered", + cancelled: "Cancelled", + expired: "Expired", +}; diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index 583d296..27032b4 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -4,8 +4,9 @@ import { createClient } from "@supabase/supabase-js"; import { env } from "@/lib/env"; -// Service role client for Storage only (signed upload URLs, existence checks). It bypasses -// every policy, so it never leaves the server and is created only after requireAdmin(). +// Service role client for Storage (signed upload URLs, existence checks, after requireAdmin()) and +// for deleting a customer's own login (spec 0012, AC-20, after requireCustomer() and their +// password). It bypasses every policy, so it never leaves the server. export function createSupabaseAdminClient() { return createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index a51e3ca..85d2a83 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -20,7 +20,10 @@ export async function createSupabaseServerClient() { } } catch { // Server components cannot write cookies. The proxy has already refreshed the - // session for this request, so a render never needs to. + // session on the routes it matches. /cart is not one of them (its account cart owner + // check, spec 0012 AC-7): a refresh there is dropped, and the browser's next request + // spends its old refresh token, which Supabase still honours as the parent of the + // newest one. } }, }, diff --git a/src/features/admin-auth/session.test.ts b/src/lib/supabase/session.test.ts similarity index 63% rename from src/features/admin-auth/session.test.ts rename to src/lib/supabase/session.test.ts index 725098a..91c4411 100644 --- a/src/features/admin-auth/session.test.ts +++ b/src/lib/supabase/session.test.ts @@ -1,13 +1,12 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { z } from "zod"; -import { endLocalSession, fieldErrors } from "./session"; +import { endLocalSession } from "./session"; const { clearAuthCookies } = vi.hoisted(() => ({ clearAuthCookies: vi.fn() })); vi.mock("server-only", () => ({})); -vi.mock("@/lib/supabase/server", () => ({ clearAuthCookies })); +vi.mock("./server", () => ({ clearAuthCookies })); function clientSigningOut(result: { error: unknown }) { const signOut = vi.fn().mockResolvedValue(result); @@ -36,16 +35,3 @@ describe("endLocalSession", () => { expect(clearAuthCookies).toHaveBeenCalledOnce(); }); }); - -describe("fieldErrors", () => { - it("keys every message by its field", () => { - const schema = z.object({ email: z.string().min(1, "Enter it"), code: z.string() }); - const parsed = schema.safeParse({ email: "", code: 1 }); - if (parsed.success) throw new Error("expected a failed parse"); - - const errors = fieldErrors<"email" | "code">(parsed.error); - - expect(errors.email).toEqual(["Enter it"]); - expect(errors.code).toHaveLength(1); - }); -}); diff --git a/src/features/admin-auth/session.ts b/src/lib/supabase/session.ts similarity index 52% rename from src/features/admin-auth/session.ts rename to src/lib/supabase/session.ts index 6b9ebe6..1033c0c 100644 --- a/src/features/admin-auth/session.ts +++ b/src/lib/supabase/session.ts @@ -1,21 +1,13 @@ import "server-only"; import type { SupabaseClient } from "@supabase/supabase-js"; -import { z } from "zod"; -import { clearAuthCookies } from "@/lib/supabase/server"; +import { clearAuthCookies } from "./server"; // Ends the session on this device only. Never the default `global` scope, which would also end -// a customer's sessions on their other devices (spec 0004). +// the user's sessions on their other devices (spec 0004). export async function endLocalSession(supabase: SupabaseClient) { const { error } = await supabase.auth.signOut({ scope: "local" }); // auth-js keeps the local session when the Auth server call fails, so clear it by hand. if (error) await clearAuthCookies(); } - -// Field errors from a failed Zod parse, keyed by field name. -export function fieldErrors( - error: z.ZodError, -): Partial> { - return z.flattenError(error).fieldErrors as Partial>; -} diff --git a/supabase/config.toml b/supabase/config.toml index f703bbc..29444eb 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -166,7 +166,8 @@ site_url = "http://localhost:3000" # The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. # external_url = "" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["http://localhost:3000/**"] +# `/account/**` is the customer area (spec 0012, AC-23); production lists it too (AC-26). +additional_redirect_urls = ["http://localhost:3000/**", "http://localhost:3000/account/**"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # JWT issuer URL. If not set, defaults to auth.external_url. @@ -179,8 +180,9 @@ enable_refresh_token_rotation = true # Requires enable_refresh_token_rotation = true. refresh_token_reuse_interval = 10 # Allow/disallow new user signups to your project. -# Off until customer accounts (feature 12): admins are created only by `pnpm admin:create` (spec 0004). -enable_signup = false +# On for customer accounts (spec 0012, AC-23). Admins are still created only by +# `pnpm admin:create` (spec 0004): signing up never makes an admin_users row. +enable_signup = true # Allow/disallow anonymous sign-ins to your project. enable_anonymous_sign_ins = false # Allow/disallow testing manual linking of accounts @@ -230,16 +232,19 @@ web3 = 30 [auth.email] # Allow/disallow new user signups via email to your project. # Stays true: in the CLI this switch turns the whole email provider off, password sign in -# included. `[auth] enable_signup = false` above is what blocks public sign up. +# included. `[auth] enable_signup` above is the public sign up switch, on for customer accounts +# (spec 0012, AC-23). enable_signup = true # If enabled, a user will be required to confirm any email change on both the old, and new email # addresses. If disabled, only the new email is required to confirm. double_confirm_changes = true # If enabled, users need to confirm their email address before signing in. -enable_confirmations = false +# spec 0012, AC-1 and AC-2: a customer confirms their email before choosing a password. +enable_confirmations = true # If enabled, users will need to reauthenticate or have logged in recently to change their password. secure_password_change = false # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +# Lowered locally so e2e can resend at once (spec 0012, AC-23); production keeps the default. max_frequency = "1s" # Number of characters used in the email OTP. otp_length = 6 @@ -256,12 +261,6 @@ otp_expiry = 3600 # admin_email = "admin@email.com" # sender_name = "Admin" -# The recovery link is fixed to the token hash pattern, so it never depends on `redirectTo` -# matching the allow list (spec 0004). Production uses the same template in the dashboard. -[auth.email.template.recovery] -subject = "Reset your admin password" -content_path = "./supabase/templates/recovery.html" - # Uncomment to customize email template # [auth.email.template.invite] # subject = "You have been invited" @@ -294,6 +293,15 @@ max_frequency = "5s" # Force log out if the user has been inactive longer than the specified duration. # inactivity_timeout = "8h" +# Every auth email (confirm sign up, reset password) goes to the app's Send Email Hook, which picks +# the template and sends it through the app's email transport (spec 0012, AC-8). Both values come +# from .env.local (or the CI env): the URI must reach the app from inside Docker, e.g. +# http://host.docker.internal:3000/api/auth/send-email, on the port the app runs on. +[auth.hook.send_email] +enabled = true +uri = "env(SEND_EMAIL_HOOK_URI)" +secrets = "env(SEND_EMAIL_HOOK_SECRET)" + # This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. # [auth.hook.before_user_created] # enabled = true diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html deleted file mode 100644 index 5497e79..0000000 --- a/supabase/templates/recovery.html +++ /dev/null @@ -1,16 +0,0 @@ - -

Reset your admin password

-

- 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. -

-

- Reset your password -

-

- 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. -

diff --git a/tests/db/account-addresses.db.test.ts b/tests/db/account-addresses.db.test.ts new file mode 100644 index 0000000..4b985c3 --- /dev/null +++ b/tests/db/account-addresses.db.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { resetDatabaseBeforeEach, testDb } from "./client"; +import { createCustomer } from "./fixtures"; + +// The address book (spec 0012, AC-14) against a real Postgres, through its server actions. + +const mocks = vi.hoisted(() => ({ + customer: { id: "", email: "" }, + refresh: vi.fn(), + info: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); +vi.mock("@/lib/env", () => ({ env: { STORE_COUNTRY: "PL" } })); +vi.mock("next/cache", () => ({ refresh: mocks.refresh })); +vi.mock("@/lib/logger", () => ({ logger: { info: mocks.info, warn: vi.fn(), error: vi.fn() } })); +vi.mock("@/lib/customer/session", () => ({ requireCustomer: async () => mocks.customer })); + +const { createAddress, deleteAddress, setDefaultAddress, updateAddress } = + await import("@/features/account/actions/addresses"); + +resetDatabaseBeforeEach(); + +beforeEach(async () => { + vi.clearAllMocks(); + const customer = await createCustomer(); + mocks.customer = { id: customer.id, email: customer.email }; +}); + +function typed(overrides: Record = {}) { + return { + fullName: "Ada Lovelace", + line1: "ul. Długa 1", + line2: "", + postalCode: "00950", + city: "Warsaw", + phone: "", + ...overrides, + }; +} + +async function book() { + return testDb.customerAddress.findMany({ + where: { customerId: mocks.customer.id }, + orderBy: [{ isDefault: "desc" }, { createdAt: "desc" }, { id: "desc" }], + }); +} + +describe("createAddress", () => { + it("saves the address in the stored form, the first one as the default", async () => { + expect(await createAddress(typed())).toEqual({ ok: true, data: null }); + await createAddress(typed({ city: "Kraków" })); + + const rows = await book(); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + city: "Warsaw", + isDefault: true, + postalCode: "00-950", + line2: null, + phone: null, + countryCode: "PL", + }); + expect(rows[1]).toMatchObject({ city: "Kraków", isDefault: false }); + expect(mocks.refresh).toHaveBeenCalled(); + }); + + it("adds nothing for an address equal to a saved one, with null and empty alike", async () => { + await createAddress(typed()); + await createAddress(typed({ line2: " ", postalCode: "00-950" })); + + expect(await book()).toHaveLength(1); + }); + + it("refuses an 11th address", async () => { + for (let i = 0; i < 10; i += 1) await createAddress(typed({ line1: `ul. Długa ${i + 1}` })); + + expect(await createAddress(typed({ line1: "ul. Krótka 1" }))).toEqual({ + ok: false, + error: { code: "limit_reached" }, + }); + expect(await book()).toHaveLength(10); + }); + + it("returns the field messages of checkout for a bad address", async () => { + const result = await createAddress(typed({ postalCode: "123", fullName: "" })); + + expect(result).toEqual({ + ok: false, + error: { + code: "validation", + fields: { + fullName: ["Enter your full name."], + postalCode: ["Enter a postal code like 00-950."], + }, + }, + }); + }); + + it("keeps one default and at most 10 addresses when saves race", async () => { + await Promise.all( + Array.from({ length: 12 }, (_, i) => createAddress(typed({ line1: `ul. Długa ${i + 1}` }))), + ); + + const rows = await book(); + expect(rows).toHaveLength(10); + expect(rows.filter((row) => row.isDefault)).toHaveLength(1); + }); +}); + +describe("updateAddress, deleteAddress and setDefaultAddress", () => { + it("edits only the customer's own address", async () => { + await createAddress(typed()); + const [own] = await book(); + const other = await createCustomer(); + const theirs = await testDb.customerAddress.create({ + data: { ...typed(), postalCode: "00-950", customerId: other.id, countryCode: "PL" }, + }); + + expect(await updateAddress(own!.id, typed({ city: "Gdańsk" }))).toEqual({ + ok: true, + data: null, + }); + expect(await updateAddress(theirs.id, typed({ city: "Gdańsk" }))).toEqual({ + ok: false, + error: { code: "not_found" }, + }); + expect((await book())[0]?.city).toBe("Gdańsk"); + expect((await testDb.customerAddress.findUnique({ where: { id: theirs.id } }))?.city).toBe( + "Warsaw", + ); + }); + + it("moves the default to the newest remaining address when the default is deleted", async () => { + await createAddress(typed({ line1: "First 1" })); + await createAddress(typed({ line1: "Second 2" })); + await createAddress(typed({ line1: "Third 3" })); + const [first] = await book(); + + expect(await deleteAddress(first!.id)).toEqual({ ok: true, data: null }); + + const rows = await book(); + expect(rows[0]).toMatchObject({ line1: "Third 3", isDefault: true }); + expect(rows.filter((row) => row.isDefault)).toHaveLength(1); + }); + + it("changes the default without ever breaking the one default rule, also when racing", async () => { + await createAddress(typed({ line1: "First 1" })); + await createAddress(typed({ line1: "Second 2" })); + await createAddress(typed({ line1: "Third 3" })); + const rows = await book(); + + const results = await Promise.all([ + setDefaultAddress(rows[1]!.id), + setDefaultAddress(rows[2]!.id), + ]); + + expect(results.every((result) => result.ok)).toBe(true); + expect((await book()).filter((row) => row.isDefault)).toHaveLength(1); + }); + + it("answers not found for someone else's address or a bad id", async () => { + const other = await createCustomer(); + const theirs = await testDb.customerAddress.create({ + data: { ...typed(), postalCode: "00-950", customerId: other.id, countryCode: "PL" }, + }); + + for (const id of [theirs.id, "not-a-uuid"]) { + expect(await deleteAddress(id)).toEqual({ ok: false, error: { code: "not_found" } }); + expect(await setDefaultAddress(id)).toEqual({ ok: false, error: { code: "not_found" } }); + } + expect(await testDb.customerAddress.count({ where: { customerId: other.id } })).toBe(1); + }); +}); diff --git a/tests/db/account-orders.db.test.ts b/tests/db/account-orders.db.test.ts new file mode 100644 index 0000000..0afad4b --- /dev/null +++ b/tests/db/account-orders.db.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resetDatabaseBeforeEach, testDb } from "./client"; +import { createCustomer, createOrder, createSimpleProduct } from "./fixtures"; + +// The account order queries (spec 0012, AC-11 to AC-13) against a real Postgres. + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); +vi.mock("@/lib/env", () => ({ env: { STORE_LOCALE: "en" } })); + +const { getAccountOrder, listAccountOrders } = await import("@/features/account/queries"); + +resetDatabaseBeforeEach(); + +async function paidOrder( + customerId: string | undefined, + paidAt: Date, + overrides: Parameters[0] = {}, +) { + const order = await createOrder({ customerId, status: "paid", ...overrides }); + return testDb.order.update({ where: { id: order.id }, data: { paidAt } }); +} + +describe("listAccountOrders (AC-11)", () => { + it("lists only this customer's paid orders, newest paid first, with item counts", async () => { + const customer = await createCustomer(); + const other = await createCustomer(); + const { product, variant } = await createSimpleProduct("a", 5); + const older = await paidOrder(customer.id, new Date("2026-10-01T10:00:00Z")); + const newer = await paidOrder(customer.id, new Date("2026-10-05T10:00:00Z"), { + status: "shipped", + }); + const cancelled = await paidOrder(customer.id, new Date("2026-10-03T10:00:00Z"), { + status: "cancelled", + }); + await testDb.orderLine.createMany({ + data: [1, 2].map((quantity) => ({ + orderId: newer.id, + variantId: variant.id, + productId: product.id, + productName: "Tee", + sku: "SKU", + unitPriceCents: 2500, + quantity, + lineTotalCents: 2500 * quantity, + })), + }); + // Hidden: pending, expired, cancelled before payment, and someone else's. + await createOrder({ customerId: customer.id }); + await createOrder({ customerId: customer.id, status: "expired" }); + await createOrder({ customerId: customer.id, status: "cancelled" }); + await paidOrder(other.id, new Date()); + await paidOrder(undefined, new Date()); + + const page = await listAccountOrders(customer.id, 1); + + expect(page.hasNext).toBe(false); + expect(page.orders.map((order) => order.number)).toEqual([ + newer.number, + cancelled.number, + older.number, + ]); + expect(page.orders[0]).toMatchObject({ status: "shipped", itemCount: 3, currency: "EUR" }); + }); + + it("pages 20 at a time and orders ties by id", async () => { + const customer = await createCustomer(); + const sameMoment = new Date("2026-10-01T10:00:00Z"); + const orders = []; + for (let i = 0; i < 21; i += 1) orders.push(await paidOrder(customer.id, sameMoment)); + + const first = await listAccountOrders(customer.id, 1); + const second = await listAccountOrders(customer.id, 2); + + expect(first.orders).toHaveLength(20); + expect(first.hasNext).toBe(true); + expect(second.orders).toHaveLength(1); + expect(second.hasNext).toBe(false); + // uuid v7 ids grow with time, so the newest created comes first among equal paid_at. + expect(first.orders[0]!.number).toBe(orders[20]!.number); + expect(second.orders[0]!.number).toBe(orders[0]!.number); + }); +}); + +describe("getAccountOrder (AC-12, AC-13)", () => { + it("returns the customer's own paid order with its snapshot and address", async () => { + const customer = await createCustomer(); + const order = await paidOrder(customer.id, new Date("2026-10-01T10:00:00Z")); + await testDb.order.update({ + where: { id: order.id }, + data: { + shipFullName: "Ada Lovelace", + shipLine1: "1 Main St", + shipPostalCode: "00-001", + shipCity: "Warsaw", + shipCountryCode: "PL", + carrier: "InPost", + trackingNumber: "TRACK1", + refundedCents: 1000, + }, + }); + + const found = await getAccountOrder(customer.id, order.number); + + expect(found).toMatchObject({ + number: order.number, + status: "paid", + carrier: "InPost", + trackingNumber: "TRACK1", + refundedCents: 1000, + address: { fullName: "Ada Lovelace", countryName: "Poland" }, + }); + }); + + it("reads another customer's order, no one's, a hidden one and a missing one all as null", async () => { + const customer = await createCustomer(); + const other = await createCustomer(); + const theirs = await paidOrder(other.id, new Date()); + const guest = await paidOrder(undefined, new Date()); + const pending = await createOrder({ customerId: customer.id }); + + for (const number of [theirs.number, guest.number, pending.number, 99_999]) { + expect(await getAccountOrder(customer.id, number)).toBeNull(); + } + }); +}); diff --git a/tests/db/account-settings.db.test.ts b/tests/db/account-settings.db.test.ts new file mode 100644 index 0000000..643b4fa --- /dev/null +++ b/tests/db/account-settings.db.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { addAuthUser, addVerifiedFactor, resetAuthStub } from "./auth-stub"; +import { resetDatabaseBeforeEach, testDb } from "./client"; +import { createCustomer, createOrder, createSimpleProduct, inThirtyDays } from "./fixtures"; + +// Settings (spec 0012, AC-18 to AC-20) against a real Postgres; Supabase is mocked at the edge. + +const mocks = vi.hoisted(() => { + class NavigationSignal extends Error {} + return { + NavigationSignal, + redirect: vi.fn((url: string) => { + throw new NavigationSignal(`redirect:${url}`); + }), + customer: { id: "", email: "" }, + refresh: vi.fn(), + statelessSignIn: vi.fn(), + statelessSignOut: vi.fn(), + updateUser: vi.fn(), + signOut: vi.fn(), + deleteUser: vi.fn(), + clearAuthCookies: vi.fn(), + clearCartCookie: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); +vi.mock("next/cache", () => ({ refresh: mocks.refresh })); +vi.mock("next/navigation", () => ({ redirect: mocks.redirect })); +vi.mock("@/lib/logger", () => ({ + logger: { info: mocks.info, warn: mocks.warn, error: mocks.error }, +})); +vi.mock("@/lib/customer/session", () => ({ requireCustomer: async () => mocks.customer })); +vi.mock("@/lib/cart/cookie", () => ({ clearCartCookie: mocks.clearCartCookie })); +vi.mock("@/lib/supabase/admin", () => ({ + createSupabaseAdminClient: () => ({ auth: { admin: { deleteUser: mocks.deleteUser } } }), +})); +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseStatelessClient: () => ({ + auth: { signInWithPassword: mocks.statelessSignIn, signOut: mocks.statelessSignOut }, + }), + createSupabaseServerClient: async () => ({ + auth: { updateUser: mocks.updateUser, signOut: mocks.signOut }, + }), + clearAuthCookies: mocks.clearAuthCookies, +})); + +const { changePassword, deleteAccount, updateProfile } = + await import("@/features/account/actions/settings"); + +resetDatabaseBeforeEach(); + +async function outcome(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof mocks.NavigationSignal) return error.message; + throw error; + } +} + +beforeEach(async () => { + vi.clearAllMocks(); + await resetAuthStub(); + const customer = await createCustomer("ada@example.com"); + await addAuthUser(customer.id); + mocks.customer = { id: customer.id, email: customer.email }; + mocks.statelessSignIn.mockResolvedValue({ data: { user: { id: customer.id } }, error: null }); + mocks.statelessSignOut.mockResolvedValue({ error: null }); + mocks.updateUser.mockResolvedValue({ data: { user: {} }, error: null }); + mocks.signOut.mockResolvedValue({ error: null }); + mocks.deleteUser.mockResolvedValue({ data: {}, error: null }); +}); + +describe("updateProfile (AC-18)", () => { + it("saves a trimmed name and a checked phone, and clears them when empty", async () => { + expect(await updateProfile({ name: " Ada Lovelace ", phone: "+48 600 100 200" })).toEqual({ + ok: true, + data: { name: "Ada Lovelace", phone: "+48 600 100 200" }, + }); + expect(await updateProfile({ name: "", phone: "" })).toEqual({ + ok: true, + data: { name: null, phone: null }, + }); + }); + + it("refuses a phone checkout would refuse", async () => { + const result = await updateProfile({ name: "Ada", phone: "123" }); + + expect(result).toEqual({ + ok: false, + error: { + fields: { phone: ["Enter a phone number with 7 to 15 digits, or leave it empty."] }, + }, + }); + }); +}); + +describe("changePassword (AC-19)", () => { + const values = { + currentPassword: "Old-pass-11", + password: "New-pass-22!", + confirm: "New-pass-22!", + }; + + it("checks the current password on a throwaway session, then changes it and ends other sessions", async () => { + expect(await changePassword(values)).toEqual({ ok: true, data: null }); + expect(mocks.statelessSignIn).toHaveBeenCalledWith({ + email: "ada@example.com", + password: "Old-pass-11", + }); + expect(mocks.statelessSignOut).toHaveBeenCalledWith({ scope: "local" }); + expect(mocks.updateUser).toHaveBeenCalledWith({ password: "New-pass-22!" }); + expect(mocks.signOut).toHaveBeenCalledWith({ scope: "others" }); + }); + + it("answers a wrong current password and changes nothing", async () => { + mocks.statelessSignIn.mockResolvedValue({ + data: { user: null }, + error: { code: "invalid_credentials", status: 400 }, + }); + + expect(await changePassword(values)).toEqual({ ok: false, error: { form: "wrong_password" } }); + expect(mocks.updateUser).not.toHaveBeenCalled(); + }); + + it("refuses an admin and anyone with an authenticator", async () => { + await testDb.adminUser.create({ + data: { id: mocks.customer.id, email: "ada@example.com", name: "Ada" }, + }); + expect(await changePassword(values)).toEqual({ ok: false, error: { form: "runs_the_store" } }); + + await testDb.adminUser.deleteMany(); + await addVerifiedFactor(mocks.customer.id); + expect(await changePassword(values)).toEqual({ ok: false, error: { form: "runs_the_store" } }); + expect(mocks.statelessSignIn).not.toHaveBeenCalled(); + }); +}); + +describe("deleteAccount (AC-20)", () => { + it("deletes the row, its addresses and cart, unlinks orders, deletes the login, and clears cookies", async () => { + const { id } = mocks.customer; + const { variant } = await createSimpleProduct("a", 5); + await testDb.customerAddress.create({ + data: { + customerId: id, + fullName: "Ada", + line1: "1 Main St", + city: "Warsaw", + postalCode: "00-001", + countryCode: "PL", + }, + }); + await testDb.cart.create({ + data: { + customerId: id, + expiresAt: inThirtyDays(), + items: { create: [{ variantId: variant.id, quantity: 1 }] }, + }, + }); + const order = await createOrder({ customerId: id, email: "ada@example.com", status: "paid" }); + + expect(await outcome(() => deleteAccount({ password: "Pass-word-1" }))).toBe( + "redirect:/?notice=account_deleted", + ); + + expect(await testDb.customer.findUnique({ where: { id } })).toBeNull(); + expect(await testDb.customerAddress.count()).toBe(0); + expect(await testDb.cart.count()).toBe(0); + const kept = await testDb.order.findUniqueOrThrow({ where: { id: order.id } }); + expect(kept).toMatchObject({ customerId: null, email: "ada@example.com" }); + expect(mocks.deleteUser).toHaveBeenCalledWith(id); + expect(mocks.clearAuthCookies).toHaveBeenCalled(); + expect(mocks.clearCartCookie).toHaveBeenCalled(); + }); + + it("rolls everything back when Supabase refuses the delete", async () => { + mocks.deleteUser.mockResolvedValue({ + data: null, + error: { name: "AuthApiError", status: 500 }, + }); + + expect(await deleteAccount({ password: "Pass-word-1" })).toEqual({ + ok: false, + error: { form: "delete_failed" }, + }); + expect(await testDb.customer.findUnique({ where: { id: mocks.customer.id } })).not.toBeNull(); + expect(mocks.clearAuthCookies).not.toHaveBeenCalled(); + expect(mocks.error).toHaveBeenCalledWith( + expect.objectContaining({ customerId: mocks.customer.id, errorName: "AuthApiError" }), + "account.delete_failed", + ); + }); + + it("refuses an admin before asking for anything, and logs it", async () => { + await testDb.adminUser.create({ + data: { + id: mocks.customer.id, + email: "ada@example.com", + name: "Ada", + disabledAt: new Date(), + }, + }); + + expect(await deleteAccount({ password: "Pass-word-1" })).toEqual({ + ok: false, + error: { form: "runs_the_store" }, + }); + expect(mocks.statelessSignIn).not.toHaveBeenCalled(); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + expect(mocks.warn).toHaveBeenCalledWith( + { event: "account.delete_refused_admin", customerId: mocks.customer.id }, + "account.delete_refused_admin", + ); + }); + + it("refuses anyone with an authenticator, like change password", async () => { + await addVerifiedFactor(mocks.customer.id); + + expect(await deleteAccount({ password: "Pass-word-1" })).toEqual({ + ok: false, + error: { form: "runs_the_store" }, + }); + expect(mocks.statelessSignIn).not.toHaveBeenCalled(); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + it("needs the right password", async () => { + mocks.statelessSignIn.mockResolvedValue({ + data: { user: null }, + error: { code: "invalid_credentials", status: 400 }, + }); + + expect(await deleteAccount({ password: "Wrong-1" })).toEqual({ + ok: false, + error: { form: "wrong_password" }, + }); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/db/account-setup.db.test.ts b/tests/db/account-setup.db.test.ts new file mode 100644 index 0000000..f75c5b8 --- /dev/null +++ b/tests/db/account-setup.db.test.ts @@ -0,0 +1,389 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { signCartId } from "@/lib/cart/signature"; + +import { addAuthUser, resetAuthStub } from "./auth-stub"; +import { resetDatabaseBeforeEach, testDb } from "./client"; +import { createCustomer, createOrder, createSimpleProduct, inThirtyDays } from "./fixtures"; + +// Account setup (spec 0012, AC-10) and the cart merge (AC-5) against a real Postgres. + +const secret = "c".repeat(32); + +const mocks = vi.hoisted(() => ({ + jar: new Map(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); +vi.mock("@/lib/env", () => ({ env: { CART_COOKIE_SECRET: "c".repeat(32) } })); +vi.mock("@/lib/logger", () => ({ + logger: { info: mocks.info, warn: mocks.warn, error: mocks.error }, +})); +vi.mock("next/headers", () => ({ + cookies: async () => ({ + get: (name: string) => { + const entry = mocks.jar.get(name); + return entry ? { name, value: entry.value } : undefined; + }, + set: (name: string, value: string, options?: unknown) => + mocks.jar.set(name, { value, options }), + delete: (name: string) => mocks.jar.delete(name), + }), +})); + +const { accountSetup, mergeCartForCustomer } = await import("@/lib/customer/setup"); +const { lockCustomer } = await import("@/lib/customer/lock"); +const { mergeCarts } = await import("@/lib/cart/merge"); + +resetDatabaseBeforeEach(); + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.jar.clear(); + await resetAuthStub(); +}); + +function setCookieCart(cartId: string) { + mocks.jar.set("bestore_cart", { value: signCartId(cartId, secret) }); +} + +function cookieCartId(): string | undefined { + return mocks.jar.get("bestore_cart")?.value.split(".")[0]; +} + +function identity(email = "ada@example.com") { + return { id: crypto.randomUUID(), email }; +} + +async function cartWith(lines: { variantId: string; quantity: number }[], customerId?: string) { + return testDb.cart.create({ + data: { + customerId, + expiresAt: inThirtyDays(), + items: { create: lines }, + }, + include: { items: true }, + }); +} + +async function linesOf(cartId: string) { + const items = await testDb.cartItem.findMany({ + where: { cartId }, + select: { variantId: true, quantity: true }, + orderBy: { createdAt: "asc" }, + }); + return items; +} + +describe("accountSetup: the row and order linking (AC-10)", () => { + it("creates the customers row and links every guest order with the email", async () => { + const customer = identity(); + await addAuthUser(customer.id); + const mine = await createOrder({ email: "ada@example.com", status: "paid" }); + const pending = await createOrder({ email: "ada@example.com" }); + const other = await createOrder({ email: "bob@example.com", status: "paid" }); + + const outcome = await accountSetup(customer); + + expect(outcome).toMatchObject({ ok: true, linked: 2 }); + expect(await testDb.customer.findUnique({ where: { id: customer.id } })).toMatchObject({ + email: "ada@example.com", + }); + const orders = await testDb.order.findMany({ select: { id: true, customerId: true } }); + const byId = new Map(orders.map((order) => [order.id, order.customerId])); + expect(byId.get(mine.id)).toBe(customer.id); + // Linked but still hidden from history until paid (AC-11). + expect(byId.get(pending.id)).toBe(customer.id); + expect(byId.get(other.id)).toBeNull(); + expect(mocks.info).toHaveBeenCalledWith( + { event: "account.orders_linked", customerId: customer.id, count: 2 }, + "account.orders_linked", + ); + }); + + it("never moves an order that belongs to another customer", async () => { + const owner = await createCustomer("owner@example.com"); + const theirs = await createOrder({ email: "ada@example.com", customerId: owner.id }); + const customer = identity(); + + await accountSetup(customer); + + expect((await testDb.order.findUnique({ where: { id: theirs.id } }))?.customerId).toBe( + owner.id, + ); + }); + + it("is safe to run again: nothing changes the second time", async () => { + const customer = identity(); + await createOrder({ email: "ada@example.com", status: "paid" }); + + await accountSetup(customer); + const again = await accountSetup(customer); + + expect(again).toMatchObject({ ok: true, linked: 0 }); + expect(await testDb.customer.count()).toBe(1); + }); + + it("removes a leftover row whose auth user is gone, then links its orders to the new account", async () => { + const stale = await createCustomer("ada@example.com"); + const order = await createOrder({ email: "ada@example.com", customerId: stale.id }); + await testDb.customerAddress.create({ + data: { + customerId: stale.id, + fullName: "Ada", + line1: "1 Main St", + city: "Warsaw", + postalCode: "00-001", + countryCode: "PL", + }, + }); + const customer = identity(); + await addAuthUser(customer.id); + + const outcome = await accountSetup(customer); + + expect(outcome).toMatchObject({ ok: true, linked: 1 }); + expect(await testDb.customer.findUnique({ where: { id: stale.id } })).toBeNull(); + expect(await testDb.customerAddress.count()).toBe(0); + expect((await testDb.order.findUnique({ where: { id: order.id } }))?.customerId).toBe( + customer.id, + ); + }); + + it("stops on a live account holding the email and logs the conflict", async () => { + const live = await createCustomer("ada@example.com"); + await addAuthUser(live.id); + const order = await createOrder({ email: "ada@example.com" }); + const customer = identity(); + + const outcome = await accountSetup(customer); + + expect(outcome).toEqual({ ok: false, error: "email_conflict" }); + expect(await testDb.customer.findUnique({ where: { id: customer.id } })).toBeNull(); + expect((await testDb.order.findUnique({ where: { id: order.id } }))?.customerId).toBeNull(); + expect(mocks.error).toHaveBeenCalledWith( + { event: "account.email_conflict", customerId: customer.id }, + "account.email_conflict", + ); + }); +}); + +describe("the cart merge (AC-5)", () => { + it("gives the guest cart to a customer who has none", async () => { + const { variant } = await createSimpleProduct("a", 8); + const guest = await cartWith([{ variantId: variant.id, quantity: 2 }]); + setCookieCart(guest.id); + const customer = identity(); + + await accountSetup(customer); + + expect((await testDb.cart.findUnique({ where: { id: guest.id } }))?.customerId).toBe( + customer.id, + ); + expect(cookieCartId()).toBe(guest.id); + }); + + it("points the cookie at the account cart when there is no guest cart", async () => { + const customer = identity(); + await testDb.customer.create({ data: customer }); + const account = await cartWith([], customer.id); + + await accountSetup(customer); + + expect(cookieCartId()).toBe(account.id); + }); + + it("sums lines of one variant up to the cap, moves the rest, and deletes the guest cart", async () => { + const { variant: shared } = await createSimpleProduct("shared", 7); + const { variant: onlyGuest } = await createSimpleProduct("guest", 20); + const { variant: onlyAccount } = await createSimpleProduct("acct", 20); + const customer = identity(); + await testDb.customer.create({ data: customer }); + const account = await cartWith( + [ + { variantId: shared.id, quantity: 5 }, + { variantId: onlyAccount.id, quantity: 1 }, + ], + customer.id, + ); + const guest = await cartWith([ + { variantId: shared.id, quantity: 4 }, + { variantId: onlyGuest.id, quantity: 3 }, + ]); + const pendingOnGuest = await createOrder({ cartId: guest.id }); + setCookieCart(guest.id); + + const merge = await mergeCartForCustomer(customer.id); + + expect(merge).toMatchObject({ cartId: account.id, linesMerged: 2, accountHadLines: true }); + expect(await testDb.cart.findUnique({ where: { id: guest.id } })).toBeNull(); + const lines = new Map((await linesOf(account.id)).map((l) => [l.variantId, l.quantity])); + // 5 + 4 capped at stock 7. + expect(lines.get(shared.id)).toBe(7); + expect(lines.get(onlyGuest.id)).toBe(3); + expect(lines.get(onlyAccount.id)).toBe(1); + expect(cookieCartId()).toBe(account.id); + // A pending order on the deleted guest cart keeps its row. + expect( + (await testDb.order.findUnique({ where: { id: pendingOnGuest.id } }))?.cartId, + ).toBeNull(); + expect(mocks.info).toHaveBeenCalledWith( + { event: "account.cart_merged", customerId: customer.id, lines: 2 }, + "account.cart_merged", + ); + }); + + it("caps a moved line at 10 and moves sold out or archived lines as they are", async () => { + const { variant: plenty } = await createSimpleProduct("plenty", 50); + const { variant: soldOut } = await createSimpleProduct("soldout", 0); + const { variant: archived } = await createSimpleProduct("archived", 5); + await testDb.productVariant.update({ where: { id: archived.id }, data: { archived: true } }); + const customer = identity(); + await testDb.customer.create({ data: customer }); + const account = await cartWith([{ variantId: soldOut.id, quantity: 1 }], customer.id); + const guest = await cartWith([ + { variantId: plenty.id, quantity: 10 }, + { variantId: soldOut.id, quantity: 2 }, + { variantId: archived.id, quantity: 4 }, + ]); + await testDb.cartItem.updateMany({ + where: { cartId: account.id, variantId: soldOut.id }, + data: { quantity: 1 }, + }); + setCookieCart(guest.id); + + await mergeCartForCustomer(customer.id); + + const lines = new Map((await linesOf(account.id)).map((l) => [l.variantId, l.quantity])); + expect(lines.get(plenty.id)).toBe(10); + // The account's sold out line stays as it was; the guest's archived line moves untouched. + expect(lines.get(soldOut.id)).toBe(1); + expect(lines.get(archived.id)).toBe(4); + }); + + // AC-24: a dropped guest line (its variant sold out, beside an account line) is not counted. + it("counts only guest lines moved or summed into the account cart", async () => { + const { variant: shared } = await createSimpleProduct("shared", 7); + const { variant: soldOut } = await createSimpleProduct("soldout", 0); + const { variant: onlyGuest } = await createSimpleProduct("guest", 20); + const customer = identity(); + await testDb.customer.create({ data: customer }); + await cartWith( + [ + { variantId: shared.id, quantity: 1 }, + { variantId: soldOut.id, quantity: 1 }, + ], + customer.id, + ); + const guest = await cartWith([ + { variantId: shared.id, quantity: 1 }, + { variantId: soldOut.id, quantity: 2 }, + { variantId: onlyGuest.id, quantity: 1 }, + ]); + setCookieCart(guest.id); + + const merge = await mergeCartForCustomer(customer.id); + + expect(merge.linesMerged).toBe(2); + expect(mocks.info).toHaveBeenCalledWith( + { event: "account.cart_merged", customerId: customer.id, lines: 2 }, + "account.cart_merged", + ); + }); + + it("counts every line of an adopted guest cart", async () => { + const { variant: a } = await createSimpleProduct("a", 5); + const { variant: b } = await createSimpleProduct("b", 0); + const guest = await cartWith([ + { variantId: a.id, quantity: 1 }, + { variantId: b.id, quantity: 1 }, + ]); + setCookieCart(guest.id); + const customer = identity(); + await testDb.customer.create({ data: customer }); + + const merge = await mergeCartForCustomer(customer.id); + + expect(merge).toMatchObject({ cartId: guest.id, linesMerged: 2 }); + expect(mocks.info).toHaveBeenCalledWith( + { event: "account.cart_merged", customerId: customer.id, lines: 2 }, + "account.cart_merged", + ); + }); + + it("logs no merge when there was no guest cart", async () => { + const { variant } = await createSimpleProduct("x", 5); + const customer = identity(); + await testDb.customer.create({ data: customer }); + const account = await cartWith([{ variantId: variant.id, quantity: 1 }], customer.id); + + const merge = await mergeCartForCustomer(customer.id); + + expect(merge).toMatchObject({ cartId: account.id, linesMerged: null }); + expect(mocks.info).not.toHaveBeenCalledWith( + expect.objectContaining({ event: "account.cart_merged" }), + "account.cart_merged", + ); + }); + + it("ignores a cookie naming another customer's cart and drops it when there is nothing else", async () => { + const { variant } = await createSimpleProduct("x", 5); + const other = await createCustomer("other@example.com"); + const theirs = await cartWith([{ variantId: variant.id, quantity: 1 }], other.id); + setCookieCart(theirs.id); + const customer = identity(); + await testDb.customer.create({ data: customer }); + + const merge = await mergeCartForCustomer(customer.id); + + expect(merge.cartId).toBeNull(); + expect(cookieCartId()).toBeUndefined(); + expect(await linesOf(theirs.id)).toHaveLength(1); + expect((await testDb.cart.findUnique({ where: { id: theirs.id } }))?.customerId).toBe(other.id); + }); + + it("replaces an expired account cart with the guest one", async () => { + const { variant } = await createSimpleProduct("x", 5); + const customer = identity(); + await testDb.customer.create({ data: customer }); + const expired = await testDb.cart.create({ + data: { customerId: customer.id, expiresAt: new Date(Date.now() - 1000) }, + }); + const guest = await cartWith([{ variantId: variant.id, quantity: 1 }]); + setCookieCart(guest.id); + + const merge = await mergeCartForCustomer(customer.id); + + expect(merge.cartId).toBe(guest.id); + expect(await testDb.cart.findUnique({ where: { id: expired.id } })).toBeNull(); + }); + + it("leaves one account cart when two sign ins of one account race", async () => { + const { variant: a } = await createSimpleProduct("a", 10); + const { variant: b } = await createSimpleProduct("b", 10); + const customer = identity(); + const first = await cartWith([{ variantId: a.id, quantity: 1 }]); + const second = await cartWith([{ variantId: b.id, quantity: 2 }]); + + await testDb.customer.create({ data: customer }); + + // Two browsers, each with its own guest cookie, merging at once under the customer lock. + const merge = (cookieCartId: string) => + testDb.$transaction(async (tx) => { + await lockCustomer(tx, customer.id); + return mergeCarts(tx, customer.id, cookieCartId, Date.now()); + }); + const results = await Promise.all([merge(first.id), merge(second.id)]); + + // The first one in made its guest cart the account cart; the second merged into it. + expect(results[0]!.cartId).toBe(results[1]!.cartId); + const carts = await testDb.cart.findMany({ where: { customerId: customer.id } }); + expect(carts).toHaveLength(1); + const lines = await linesOf(carts[0]!.id); + expect(lines).toHaveLength(2); + }); +}); diff --git a/tests/db/admin-orders.db.test.ts b/tests/db/admin-orders.db.test.ts index 82d5540..d5a1a02 100644 --- a/tests/db/admin-orders.db.test.ts +++ b/tests/db/admin-orders.db.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetDatabaseBeforeEach, testDb } from "./client"; -import { createOrder } from "./fixtures"; +import { createCustomer, createOrder } from "./fixtures"; import { seedPendingOrder } from "./stripe-support"; // The admin order list and detail queries (spec 0006, AC-12 and AC-13). The pages' requireAdmin() @@ -120,6 +120,23 @@ describe("getAdminOrders", () => { }); }); +// spec 0012, AC-21. +describe("the Account or Guest badge", () => { + it("tells account orders from guest orders on the list and the order page", async () => { + const customer = await createCustomer(); + const mine = await createOrder({ status: "paid", customerId: customer.id }); + const guest = await createOrder({ status: "paid" }); + + const { rows } = await getAdminOrders(view()); + const byNumber = new Map(rows.map((row) => [row.number, row.hasAccount])); + + expect(byNumber.get(mine.number)).toBe(true); + expect(byNumber.get(guest.number)).toBe(false); + expect((await getAdminOrder(String(mine.number)))?.hasAccount).toBe(true); + expect((await getAdminOrder(String(guest.number)))?.hasAccount).toBe(false); + }); +}); + describe("getAdminOrders on purged orders (spec 0008)", () => { it("lists a purged order beside the others, with no email and its purge time (AC-8)", async () => { await createOrder({ status: "paid", email: "kept@example.com" }); diff --git a/tests/db/auth-stub.ts b/tests/db/auth-stub.ts new file mode 100644 index 0000000..5035a10 --- /dev/null +++ b/tests/db/auth-stub.ts @@ -0,0 +1,41 @@ +import { testDb } from "./client"; + +// The test database is plain Postgres, without Supabase's auth schema. Account code reads two of +// its tables (auth.users, auth.mfa_factors) read only, so the suites that need them get a minimal +// stand in. It is marked, and only a marked stand in is ever cleared, so a database with the real +// auth schema is never touched. +const marker = "bestore test stub"; + +export async function ensureAuthStub() { + await testDb.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS auth`); + await testDb.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS auth.users (id uuid PRIMARY KEY)`); + await testDb.$executeRawUnsafe( + `CREATE TABLE IF NOT EXISTS auth.mfa_factors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + factor_type text NOT NULL DEFAULT 'totp', + status text NOT NULL + )`, + ); + const [row] = await testDb.$queryRaw<{ comment: string | null }[]>` + SELECT obj_description('auth.users'::regclass, 'pg_class') AS comment`; + if (row?.comment === null) { + await testDb.$executeRawUnsafe(`COMMENT ON TABLE auth.users IS '${marker}'`); + } else if (row?.comment !== marker) { + throw new Error("auth.users is not the test stand in; refusing to use this database."); + } +} + +export async function resetAuthStub() { + await ensureAuthStub(); + await testDb.$executeRawUnsafe(`TRUNCATE auth.users, auth.mfa_factors`); +} + +export async function addAuthUser(id: string) { + await testDb.$executeRaw`INSERT INTO auth.users (id) VALUES (${id}::uuid)`; +} + +export async function addVerifiedFactor(userId: string) { + await testDb.$executeRaw` + INSERT INTO auth.mfa_factors (user_id, status) VALUES (${userId}::uuid, 'verified')`; +} diff --git a/tests/db/cart-actions.db.test.ts b/tests/db/cart-actions.db.test.ts index 8f1b42e..2f2563c 100644 --- a/tests/db/cart-actions.db.test.ts +++ b/tests/db/cart-actions.db.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { signCartId } from "@/lib/cart/signature"; import { testDb, resetDatabaseBeforeEach } from "./client"; -import { createSimpleProduct } from "./fixtures"; +import { createCustomer, createSimpleProduct } from "./fixtures"; // addToCart against a real Postgres (spec 0005, AC-8, AC-9, AC-15). @@ -13,12 +13,23 @@ const mocks = vi.hoisted(() => ({ jar: new Map(), refresh: vi.fn(), warn: vi.fn(), + customerId: null as string | null, + sessionReads: 0, + sessionFails: false, })); vi.mock("server-only", () => ({})); vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); vi.mock("@/lib/env", () => ({ env: { CART_COOKIE_SECRET: "c".repeat(32) } })); vi.mock("next/cache", () => ({ refresh: mocks.refresh })); +// spec 0012, AC-7: the signed in customer, when an add must create a cart. +vi.mock("@/lib/customer/session", () => ({ + getCustomerId: async () => { + mocks.sessionReads += 1; + if (mocks.sessionFails) throw new Error("Auth server unreachable"); + return mocks.customerId; + }, +})); vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: mocks.warn } })); vi.mock("next/headers", () => ({ cookies: async () => ({ @@ -38,6 +49,9 @@ resetDatabaseBeforeEach(); beforeEach(() => { vi.clearAllMocks(); mocks.jar.clear(); + mocks.customerId = null; + mocks.sessionReads = 0; + mocks.sessionFails = false; }); function cookieCartId(): string | undefined { @@ -183,6 +197,80 @@ describe("addToCart", () => { expect(await testDb.cartItem.count({ where: { cartId: expired.id } })).toBe(0); }); + // spec 0012, AC-7. + describe("for a signed in customer", () => { + it("adds to their account cart when there is no cookie cart, and points the cookie at it", async () => { + const { variant } = await createSimpleProduct("a", 5); + const customer = await createCustomer(); + const account = await testDb.cart.create({ + data: { customerId: customer.id, expiresAt: new Date(Date.now() + 86_400_000) }, + }); + mocks.customerId = customer.id; + + await addToCart({ variantId: variant.id, quantity: 2 }); + + expect(cookieCartId()).toBe(account.id); + expect(await testDb.cart.count()).toBe(1); + expect(await testDb.cartItem.count({ where: { cartId: account.id } })).toBe(1); + }); + + it("creates the cart with their id when they have none", async () => { + const { variant } = await createSimpleProduct("a", 5); + const customer = await createCustomer(); + mocks.customerId = customer.id; + + await addToCart({ variantId: variant.id, quantity: 1 }); + + const cart = await testDb.cart.findFirstOrThrow(); + expect(cart.customerId).toBe(customer.id); + expect(cookieCartId()).toBe(cart.id); + }); + + it("reads the session only when the add must create a cart", async () => { + const { variant } = await createSimpleProduct("a", 5); + await addToCart({ variantId: variant.id, quantity: 1 }); + await addToCart({ variantId: variant.id, quantity: 1 }); + + expect(mocks.sessionReads).toBe(1); + }); + + it("still gets their own cart when the cookie cart expires between the read and the lock", async () => { + const { variant } = await createSimpleProduct("a", 5); + const customer = await createCustomer(); + mocks.customerId = customer.id; + const guest = await testDb.cart.create({ + data: { expiresAt: new Date(Date.now() + 86_400_000) }, + }); + mocks.jar.set("bestore_cart", { value: signCartId(guest.id, secret) }); + // The cookie read sees the cart live; it expires before the transaction locks it. + const read = testDb.cart.findFirst.bind(testDb.cart); + vi.spyOn(testDb.cart, "findFirst").mockImplementationOnce((async ( + args: Parameters[0], + ) => { + const found = await read(args); + await testDb.cart.update({ + where: { id: guest.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + return found; + }) as unknown as typeof testDb.cart.findFirst); + + expect(await addToCart({ variantId: variant.id, quantity: 1 })).toMatchObject({ ok: true }); + + const cart = await testDb.cart.findUniqueOrThrow({ where: { id: cookieCartId()! } }); + expect(cart.id).not.toBe(guest.id); + expect(cart.customerId).toBe(customer.id); + }); + + it("gives a visitor with no valid session a guest cart as before", async () => { + const { variant } = await createSimpleProduct("a", 5); + + await addToCart({ variantId: variant.id, quantity: 1 }); + + expect((await testDb.cart.findFirstOrThrow()).customerId).toBeNull(); + }); + }); + it("logs a bad signature without the cookie value", async () => { const { variant } = await createSimpleProduct("a", 5); const forged = `${crypto.randomUUID()}.forged`; @@ -303,3 +391,89 @@ describe("removeCartItem", () => { expect(await testDb.cartItem.count({ where: { id: other.item.id } })).toBe(1); }); }); + +// spec 0012, AC-7: a cookie naming an account cart works only for that customer's session (a +// session that ended without our sign out leaves the cookie behind). +describe("an account cart named by the cookie", () => { + async function accountCartWithLine() { + const owner = await createCustomer(); + const { variant } = await createSimpleProduct("owned", 5); + const cart = await testDb.cart.create({ + data: { + customerId: owner.id, + expiresAt: new Date(Date.now() + 86_400_000), + items: { create: { variantId: variant.id, quantity: 1 } }, + }, + include: { items: true }, + }); + mocks.jar.set("bestore_cart", { value: signCartId(cart.id, secret) }); + return { owner, variant, cart, item: cart.items[0]! }; + } + + it("works as before for its own customer", async () => { + const { owner, variant, cart, item } = await accountCartWithLine(); + mocks.customerId = owner.id; + + await addToCart({ variantId: variant.id, quantity: 1 }); + expect(cookieCartId()).toBe(cart.id); + expect((await setCartItemQuantity({ itemId: item.id, quantity: 3 })).ok).toBe(true); + expect(await removeCartItem({ itemId: item.id })).toEqual({ ok: true, data: {} }); + }); + + it("gives a signed out visitor a new guest cart on add and leaves the account cart alone", async () => { + const { variant, cart } = await accountCartWithLine(); + + await addToCart({ variantId: variant.id, quantity: 2 }); + + const guest = await testDb.cart.findFirstOrThrow({ where: { customerId: null } }); + expect(cookieCartId()).toBe(guest.id); + expect(await testDb.cartItem.findMany({ where: { cartId: cart.id } })).toEqual([ + expect.objectContaining({ quantity: 1 }), + ]); + }); + + it("adds to the signed in customer's own cart when the cookie names someone else's", async () => { + const { variant, cart } = await accountCartWithLine(); + const me = await createCustomer(); + const mine = await testDb.cart.create({ + data: { customerId: me.id, expiresAt: new Date(Date.now() + 86_400_000) }, + }); + mocks.customerId = me.id; + + await addToCart({ variantId: variant.id, quantity: 1 }); + + expect(cookieCartId()).toBe(mine.id); + expect(await testDb.cartItem.count({ where: { cartId: mine.id } })).toBe(1); + expect(await testDb.cartItem.count({ where: { cartId: cart.id } })).toBe(1); + }); + + it("answers not found to quantity and remove for anyone but its customer, cookie untouched", async () => { + const { item, cart } = await accountCartWithLine(); + const cookie = mocks.jar.get("bestore_cart")?.value; + mocks.customerId = (await createCustomer()).id; + + expect(await setCartItemQuantity({ itemId: item.id, quantity: 3 })).toEqual({ + ok: false, + error: "not_found", + }); + mocks.customerId = null; + expect(await removeCartItem({ itemId: item.id })).toEqual({ ok: false, error: "not_found" }); + expect(await testDb.cartItem.findUniqueOrThrow({ where: { id: item.id } })).toMatchObject({ + cartId: cart.id, + quantity: 1, + }); + expect(mocks.jar.get("bestore_cart")?.value).toBe(cookie); + }); + + it("treats a session it cannot read as no cart, never an error", async () => { + const { owner, item } = await accountCartWithLine(); + mocks.customerId = owner.id; + mocks.sessionFails = true; + + expect(await removeCartItem({ itemId: item.id })).toEqual({ ok: false, error: "not_found" }); + expect(mocks.warn).toHaveBeenCalledWith( + { event: "cart.owner_unknown", error: "Error" }, + "cart.owner_unknown", + ); + }); +}); diff --git a/tests/db/load-cart.db.test.ts b/tests/db/load-cart.db.test.ts index ec9f8b3..59fefec 100644 --- a/tests/db/load-cart.db.test.ts +++ b/tests/db/load-cart.db.test.ts @@ -3,14 +3,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { signCartId } from "@/lib/cart/signature"; import { resetDatabaseBeforeEach, testDb } from "./client"; -import { createProductWithOptions, createSimpleProduct, inThirtyDays } from "./fixtures"; +import { + createCustomer, + createProductWithOptions, + createSimpleProduct, + inThirtyDays, +} from "./fixtures"; // Loading the cart against a real Postgres: live prices and flags (AC-10, AC-11), a bad or // expired cookie read as no cart (AC-9), and rendering never writes (AC-11). const secret = "c".repeat(32); -const mocks = vi.hoisted(() => ({ cookie: undefined as string | undefined, warn: vi.fn() })); +const mocks = vi.hoisted(() => ({ + cookie: undefined as string | undefined, + warn: vi.fn(), + customerId: null as string | null, +})); vi.mock("server-only", () => ({})); vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); @@ -18,6 +27,7 @@ vi.mock("@/lib/env", () => ({ env: { CART_COOKIE_SECRET: "c".repeat(32), NEXT_PUBLIC_SUPABASE_URL: "http://127.0.0.1:55321" }, })); vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: mocks.warn } })); +vi.mock("@/lib/customer/session", () => ({ getCustomerId: async () => mocks.customerId })); vi.mock("next/headers", () => ({ cookies: async () => ({ get: (name: string) => @@ -31,12 +41,14 @@ vi.mock("next/headers", () => ({ })); const { loadCart, loadCartById } = await import("@/lib/cart/load-cart"); +const { loadOwnedCart } = await import("@/lib/cart/owned"); resetDatabaseBeforeEach(); beforeEach(() => { vi.clearAllMocks(); mocks.cookie = undefined; + mocks.customerId = null; }); async function cartWith( @@ -174,3 +186,48 @@ describe("loadCart", () => { ); }); }); + +// spec 0012, AC-7: the cart page and checkout show an account cart only to its customer; the +// header count (loadCart) keeps the raw read. +describe("loadOwnedCart", () => { + async function accountCart() { + const owner = await createCustomer(); + const cart = await testDb.cart.create({ + data: { customerId: owner.id, expiresAt: inThirtyDays() }, + }); + mocks.cookie = signCartId(cart.id, secret); + return { owner, cart }; + } + + it("loads a guest cart for anyone", async () => { + const cart = await cartWith([]); + mocks.cookie = signCartId(cart.id, secret); + + expect((await loadOwnedCart())?.id).toBe(cart.id); + }); + + it("loads an account cart for its own customer", async () => { + const { owner, cart } = await accountCart(); + mocks.customerId = owner.id; + + expect((await loadOwnedCart())?.id).toBe(cart.id); + }); + + it("is null for a signed out visitor or another customer, and writes nothing", async () => { + const { cart } = await accountCart(); + const before = await snapshot(); + + expect(await loadOwnedCart()).toBeNull(); + mocks.customerId = (await createCustomer()).id; + expect(await loadOwnedCart()).toBeNull(); + expect((await loadCart())?.id).toBe(cart.id); + expect(await snapshot()).toEqual(before); + }); + + it("is null for an expired cart", async () => { + const cart = await cartWith([], new Date(Date.now() - 1000)); + mocks.cookie = signCartId(cart.id, secret); + + expect(await loadOwnedCart()).toBeNull(); + }); +}); diff --git a/tests/db/order-sign-up.db.test.ts b/tests/db/order-sign-up.db.test.ts new file mode 100644 index 0000000..c5dc120 --- /dev/null +++ b/tests/db/order-sign-up.db.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { resetDatabaseBeforeEach, testDb } from "./client"; +import { createCustomer, createOrder } from "./fixtures"; + +// spec 0012, AC-17: when the confirmation page offers an account, and signUpFromOrder's check. + +const mocks = vi.hoisted(() => ({ + user: null as { id: string } | null, + after: vi.fn(), + signUp: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", async () => ({ db: (await import("./client")).testDb })); +vi.mock("next/server", () => ({ after: mocks.after })); +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: async () => ({ + auth: { getUser: async () => ({ data: { user: mocks.user } }) }, + }), +})); +vi.mock("@/features/account/sign-up-email", () => ({ sendSignUp: mocks.signUp })); + +const { orderSignUpEmail } = await import("@/features/account/order-sign-up"); +const { signUpFromOrder } = await import("@/features/account/actions/sign-up-from-order"); + +resetDatabaseBeforeEach(); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.user = null; +}); + +let counter = 0; + +async function orderWithSession( + overrides: Parameters[0] = {}, +): Promise { + counter += 1; + const sessionId = `cs_test_offer${counter}`; + const order = await createOrder({ email: "guest@example.com", status: "paid", ...overrides }); + await testDb.order.update({ + where: { id: order.id }, + data: { stripeCheckoutSessionId: sessionId }, + }); + return sessionId; +} + +describe("orderSignUpEmail", () => { + it.each(["paid", "shipped", "delivered"] as const)( + "offers an account for a %s guest order to a visitor with no session", + async (status) => { + const sessionId = await orderWithSession({ status }); + + expect(await orderSignUpEmail(sessionId)).toBe("guest@example.com"); + }, + ); + + it("never offers for a pending, cancelled or expired order", async () => { + for (const status of ["pending_payment", "cancelled", "expired"] as const) { + expect(await orderSignUpEmail(await orderWithSession({ status }))).toBeNull(); + } + }); + + it("never offers for an order an account already holds, or to a signed in visitor", async () => { + const customer = await createCustomer(); + expect(await orderSignUpEmail(await orderWithSession({ customerId: customer.id }))).toBeNull(); + + const sessionId = await orderWithSession(); + mocks.user = { id: customer.id }; + expect(await orderSignUpEmail(sessionId)).toBeNull(); + }); + + it("refuses anything that is not a Checkout Session id, or an unknown one", async () => { + for (const value of [undefined, "", "pi_123", "cs_test_unknown"]) { + expect(await orderSignUpEmail(value)).toBeNull(); + } + }); +}); + +describe("signUpFromOrder", () => { + it("signs up the order's email after answering", async () => { + const sessionId = await orderWithSession(); + + expect(await signUpFromOrder(sessionId)).toEqual({ ok: true, data: null }); + expect(mocks.signUp).not.toHaveBeenCalled(); + for (const [task] of mocks.after.mock.calls) await (task as () => Promise)(); + expect(mocks.signUp).toHaveBeenCalledWith("guest@example.com"); + }); + + // covers: AC-17 (value sourcing): a tampered request body cannot choose the email. + it("ignores an email added to the request, signing up no one", async () => { + const sessionId = await orderWithSession(); + + expect(await signUpFromOrder({ sessionId, email: "attacker@example.com" })).toEqual({ + ok: false, + error: "not_eligible", + }); + expect(mocks.after).not.toHaveBeenCalled(); + }); + + it("answers not_eligible and signs up no one otherwise", async () => { + const sessionId = await orderWithSession({ status: "pending_payment" }); + + expect(await signUpFromOrder(sessionId)).toEqual({ ok: false, error: "not_eligible" }); + expect(mocks.after).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/db/start-checkout.db.test.ts b/tests/db/start-checkout.db.test.ts index 60ea6eb..ba762f4 100644 --- a/tests/db/start-checkout.db.test.ts +++ b/tests/db/start-checkout.db.test.ts @@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { signCartId } from "@/lib/cart/signature"; import { resetDatabaseBeforeEach, testDb } from "./client"; -import { createProductWithOptions, createSimpleProduct, inThirtyDays } from "./fixtures"; +import { + createCustomer, + createProductWithOptions, + createSimpleProduct, + inThirtyDays, +} from "./fixtures"; import { eventsOf, seedPendingOrder } from "./stripe-support"; // startCheckout against a real Postgres; Stripe is stubbed at src/lib/stripe.ts @@ -19,6 +24,7 @@ const mocks = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), + customer: null as { id: string; email: string } | null, })); vi.mock("server-only", () => ({})); @@ -49,8 +55,15 @@ vi.mock("next/headers", () => ({ const value = mocks.jar.get(name); return value === undefined ? undefined : { name, value }; }, + set: (name: string, value: string) => mocks.jar.set(name, value), + delete: (name: string) => mocks.jar.delete(name), }), })); +// spec 0012, AC-15: the signed in customer, or null for a guest. +vi.mock("@/lib/customer/session", () => ({ + getCustomer: async () => mocks.customer, + getCustomerId: async () => mocks.customer?.id ?? null, +})); const { startCheckout } = await import("@/features/checkout/actions/start-checkout"); @@ -61,6 +74,7 @@ let sessionCounter = 0; beforeEach(() => { vi.clearAllMocks(); mocks.jar.clear(); + mocks.customer = null; mocks.create.mockImplementation(async () => { sessionCounter += 1; const id = `cs_test_new${sessionCounter}`; @@ -250,7 +264,8 @@ describe("delivery (spec 0007)", () => { shipPostalCode: "00-950", shipCountryCode: "PL", phone: "+48 600 100 200", - customerName: null, + // spec 0012, AC-15: a guest order is greeted by the delivery full name. + customerName: "Anna Kowalska", subtotalCents: 5000, shippingCents: 1500, totalCents: 6500, @@ -403,6 +418,20 @@ describe("refusals create nothing", () => { }); }); + // spec 0012, AC-7: a leftover cookie naming an account cart, with no session, pays for nothing. + it("treats an account cart as no cart for a visitor who is not its customer", async () => { + const { variant } = await createSimpleProduct("a", 5); + const owner = await createCustomer("owner@example.com"); + const cart = await cartWith([{ variantId: variant.id, quantity: 1 }]); + await testDb.cart.update({ where: { id: cart.id }, data: { customerId: owner.id } }); + + expect(await startCheckout(checkout("a@example.com"))).toEqual({ + ok: false, + error: { code: "cart_changed" }, + }); + expect(await testDb.order.count()).toBe(0); + }); + it.each([ ["an empty cart", async () => cartWith([])], [ @@ -756,3 +785,197 @@ describe("logs", () => { } }); }); + +// spec 0012, AC-15. +describe("a signed in checkout", () => { + // A second Pay for one cart ends the first session at Stripe (spec 0006, AC-9). + beforeEach(() => { + mocks.retrieve.mockResolvedValue({ status: "open", payment_status: "unpaid" }); + }); + + async function signedIn(email = "ada@example.com") { + const customer = await createCustomer(email); + mocks.customer = { id: customer.id, email: customer.email }; + return customer; + } + + async function savedAddresses(customerId: string) { + return testDb.customerAddress.findMany({ + where: { customerId }, + orderBy: { createdAt: "asc" }, + }); + } + + it("gives the order the customer and the account email, whatever was typed", async () => { + const customer = await signedIn(); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + const result = await startCheckout(checkout("someone.else@example.com")); + + expect(result.ok).toBe(true); + const order = await testDb.order.findFirstOrThrow(); + expect(order).toMatchObject({ customerId: customer.id, email: "ada@example.com" }); + expect(mocks.create.mock.calls[0]![0]).toMatchObject({ customer_email: "ada@example.com" }); + }); + + it("saves the ticked address as the first, default one, and never twice", async () => { + const customer = await signedIn(); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout(checkout("x@example.com", { saveAddress: true, line2: " " })); + expect((await startCheckout(checkout("x@example.com", { saveAddress: true }))).ok).toBe(true); + + const rows = await savedAddresses(customer.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + fullName: "Anna Kowalska", + postalCode: "00-950", + line2: null, + isDefault: true, + countryCode: "PL", + }); + }); + + it("saves nothing when the box is not ticked, and skips silently at 10", async () => { + const customer = await signedIn(); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout(checkout("x@example.com", { saveAddress: false })); + expect(await savedAddresses(customer.id)).toHaveLength(0); + + await testDb.customerAddress.createMany({ + data: Array.from({ length: 10 }, (_, i) => ({ + customerId: customer.id, + fullName: "Ada", + line1: `Line ${i}`, + city: "Warsaw", + postalCode: "00-001", + countryCode: "PL", + isDefault: i === 0, + })), + }); + const result = await startCheckout(checkout("x@example.com", { saveAddress: true })); + + expect(result.ok).toBe(true); + expect(await savedAddresses(customer.id)).toHaveLength(10); + }); + + it("fills a blank profile phone from the order and never overwrites one", async () => { + const customer = await signedIn(); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout(checkout("x@example.com", { phone: "+48 600 100 200" })); + expect((await testDb.customer.findUniqueOrThrow({ where: { id: customer.id } })).phone).toBe( + "+48 600 100 200", + ); + + expect((await startCheckout(checkout("x@example.com", { phone: "+48 700 200 300" }))).ok).toBe( + true, + ); + expect((await testDb.customer.findUniqueOrThrow({ where: { id: customer.id } })).phone).toBe( + "+48 600 100 200", + ); + }); + + it("names the order after a set profile name, even when shipping to someone else", async () => { + const customer = await signedIn(); + await testDb.customer.update({ where: { id: customer.id }, data: { name: "Ada Lovelace" } }); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout(checkout("x@example.com", { fullName: "Grace Hopper" })); + + const order = await testDb.order.findFirstOrThrow(); + expect(order).toMatchObject({ customerName: "Ada Lovelace", shipFullName: "Grace Hopper" }); + expect((await testDb.customer.findUniqueOrThrow({ where: { id: customer.id } })).name).toBe( + "Ada Lovelace", + ); + }); + + it.each([null, ""])( + "with a blank profile name (%j) names the order after the delivery name and fills the profile once, ticked or not", + async (blank) => { + const customer = await signedIn(); + await testDb.customer.update({ where: { id: customer.id }, data: { name: blank } }); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout( + checkout("x@example.com", { fullName: "Grace Hopper", saveAddress: false }), + ); + expect((await testDb.order.findFirstOrThrow()).customerName).toBe("Grace Hopper"); + expect((await testDb.customer.findUniqueOrThrow({ where: { id: customer.id } })).name).toBe( + "Grace Hopper", + ); + + // The next order uses the now set profile name, and the fill never overwrites it. + await startCheckout(checkout("x@example.com", { fullName: "Alan Turing" })); + const latest = await testDb.order.findFirstOrThrow({ orderBy: { createdAt: "desc" } }); + expect(latest).toMatchObject({ customerName: "Grace Hopper", shipFullName: "Alan Turing" }); + expect((await testDb.customer.findUniqueOrThrow({ where: { id: customer.id } })).name).toBe( + "Grace Hopper", + ); + }, + ); + + it("never logs the order's customer name", async () => { + const customer = await signedIn(); + await testDb.customer.update({ where: { id: customer.id }, data: { name: "Ada Lovelace" } }); + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout(checkout("x@example.com", { saveAddress: true })); + + const logged = JSON.stringify([ + mocks.info.mock.calls, + mocks.warn.mock.calls, + mocks.error.mock.calls, + ]); + expect(logged).not.toContain("Lovelace"); + expect(logged).not.toContain("Kowalska"); + }); + + it("merges a guest cart into an account cart with lines and asks the customer to look again", async () => { + const customer = await signedIn(); + const { variant: a } = await createSimpleProduct("a", 5); + const { variant: b } = await createSimpleProduct("b", 5); + const account = await testDb.cart.create({ + data: { + customerId: customer.id, + expiresAt: inThirtyDays(), + items: { create: [{ variantId: a.id, quantity: 1 }] }, + }, + }); + const guest = await cartWith([{ variantId: b.id, quantity: 2 }]); + + const first = await startCheckout(checkout()); + + expect(first).toEqual({ ok: false, error: { code: "cart_changed", merged: true } }); + expect(await testDb.order.count()).toBe(0); + expect(await testDb.cart.findUnique({ where: { id: guest.id } })).toBeNull(); + expect(mocks.jar.get("bestore_cart")?.split(".")[0]).toBe(account.id); + + const second = await startCheckout(checkout()); + expect(second.ok).toBe(true); + const order = await testDb.order.findFirstOrThrow({ include: { lines: true } }); + expect(order.cartId).toBe(account.id); + expect(order.lines).toHaveLength(2); + }); + + it("goes straight on when the guest cart simply becomes the account cart", async () => { + const customer = await signedIn(); + const { variant } = await createSimpleProduct("a", 5); + const guest = await cartWith([{ variantId: variant.id, quantity: 1 }]); + + const result = await startCheckout(checkout()); + + expect(result.ok).toBe(true); + expect((await testDb.cart.findUniqueOrThrow({ where: { id: guest.id } })).customerId).toBe( + customer.id, + ); + }); +}); diff --git a/tests/e2e/account/a11y.spec.ts b/tests/e2e/account/a11y.spec.ts new file mode 100644 index 0000000..f681e47 --- /dev/null +++ b/tests/e2e/account/a11y.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from "@playwright/test"; + +import { expectNoA11yViolations, tabTo } from "../a11y"; +import { createTestUser, testPassword } from "../admin/support"; +import { + accountLinkFor, + seedPaidOrder, + signInAsCustomer, + signUpWithEmail, + uniqueEmail, +} from "./support"; + +// spec 0012, AC-25: every account page and dialog passes axe (desktop and phone projects), works +// by keyboard, and moves focus to the first invalid field. + +test("the public account pages pass axe, empty, with errors and after sending", async ({ + page, +}) => { + for (const path of ["/account/sign-in", "/account/sign-up", "/account/forgot-password"]) { + await page.goto(path); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expectNoA11yViolations(page); + } + + // A refused submit focuses the first invalid field. + await page.goto("/account/sign-in"); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByLabel("Email")).toBeFocused(); + await expect(page.getByText("Enter your email address.")).toBeVisible(); + await expectNoA11yViolations(page); + + await page.goto("/account/forgot-password"); + await page.getByLabel("Email").fill(uniqueEmail("nobody")); + await page.getByRole("button", { name: "Send reset link" }).click(); + await expect( + page.getByRole("status").filter({ hasText: "a reset link is on its way" }), + ).toBeVisible(); + await expectNoA11yViolations(page); + + const email = uniqueEmail("a11y"); + await signUpWithEmail(page, email); + await expect(page.getByRole("heading", { name: "Check your email" })).toBeFocused(); + await expectNoA11yViolations(page); + + await page.goto(await accountLinkFor(email)); + await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); + await expectNoA11yViolations(page); + + await page.goto("/account/confirm?token_hash=x&type=signup&failed=1"); + await expect(page.getByText("This link has expired or was already used.")).toBeVisible(); + await expectNoA11yViolations(page); +}); + +test("the signed in account pages and dialogs pass axe", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + const order = await seedPaidOrder(customer.email); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await expectNoA11yViolations(page); + + await page.goto(`/account/orders/${order.number}`); + await expect(page.getByRole("heading", { name: `Order #${order.number}` })).toBeVisible(); + await expectNoA11yViolations(page); + + await page.goto("/account/addresses"); + await page.getByRole("button", { name: "Add an address" }).click(); + const add = page.getByRole("dialog", { name: "Add an address" }); + await add.getByRole("button", { name: "Save address" }).click(); + await expect(add.getByText("Enter your full name.")).toBeVisible(); + await expectNoA11yViolations(page); + await add.getByRole("button", { name: "Cancel" }).click(); + + await page.goto("/account/settings"); + await expect(page.getByRole("heading", { name: "Your details" })).toBeVisible(); + await expectNoA11yViolations(page); + await page.getByRole("button", { name: "Delete my account" }).click(); + await expect(page.getByRole("dialog", { name: "Delete your account?" })).toBeVisible(); + await expectNoA11yViolations(page); +}); + +test("a customer signs in and opens an order by keyboard alone", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + const order = await seedPaidOrder(customer.email); + await page.goto("/account/sign-in"); + + await tabTo(page, page.getByLabel("Email")); + await page.keyboard.type(customer.email); + await tabTo(page, page.getByLabel("Password", { exact: true })); + await page.keyboard.type(testPassword); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL("/account"); + + const link = page.getByRole("link", { name: new RegExp(`Order #${order.number}`) }); + await tabTo(page, link); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(`/account/orders/${order.number}`); +}); diff --git a/tests/e2e/account/addresses.spec.ts b/tests/e2e/account/addresses.spec.ts new file mode 100644 index 0000000..c52d779 --- /dev/null +++ b/tests/e2e/account/addresses.spec.ts @@ -0,0 +1,82 @@ +import { expect, type Page, test } from "@playwright/test"; + +import { createTestUser, testPassword, withDb } from "../admin/support"; +import { signInAsCustomer } from "./support"; + +// spec 0012, AC-14: the address book. + +async function addAddress(page: Page, line1: string, city = "Warsaw") { + await page.getByRole("button", { name: "Add an address" }).click(); + const dialog = page.getByRole("dialog", { name: "Add an address" }); + await dialog.getByLabel("Full name").fill("Anna Kowalska"); + await dialog.getByLabel("Address line 1").fill(line1); + await dialog.getByLabel("Postal code").fill("00950"); + await dialog.getByLabel("City").fill(city); + await dialog.getByRole("button", { name: "Save address" }).click(); + await expect(dialog).toBeHidden(); +} + +const cards = (page: Page) => + page.getByRole("list", { name: "Saved addresses" }).getByRole("listitem"); + +test("a customer adds, edits, defaults and deletes addresses", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + await signInAsCustomer(page, customer.email, testPassword); + await page.getByRole("link", { name: "Addresses" }).click(); + await expect(page).toHaveURL("/account/addresses"); + + // Every field is checked with checkout's rules, and the first invalid one gets focus. + await page.getByRole("button", { name: "Add an address" }).click(); + const dialog = page.getByRole("dialog", { name: "Add an address" }); + await dialog.getByRole("button", { name: "Save address" }).click(); + await expect(dialog.getByText("Enter your full name.")).toBeVisible(); + await expect(dialog.getByLabel("Full name")).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await addAddress(page, "ul. Długa 1"); + await addAddress(page, "ul. Krótka 2", "Kraków"); + await expect(cards(page)).toHaveCount(2); + await expect(cards(page).first()).toContainText("Default"); + await expect(cards(page).first()).toContainText("ul. Długa 1"); + await expect(cards(page).first()).toContainText("00-950"); + + await page + .getByRole("button", { name: "Make default: Anna Kowalska, ul. Krótka 2, Kraków" }) + .click(); + await expect(cards(page).first()).toContainText("ul. Krótka 2"); + await expect(cards(page).first()).toContainText("Default"); + + await page.getByRole("button", { name: "Edit: Anna Kowalska, ul. Długa 1, Warsaw" }).click(); + const edit = page.getByRole("dialog", { name: "Edit address" }); + await edit.getByLabel("City").fill("Gdańsk"); + await edit.getByRole("button", { name: "Save address" }).click(); + // The dialog's description repeats the address label, so look in the list once it closes. + await expect(edit).toBeHidden(); + await expect(cards(page).filter({ hasText: "Gdańsk" })).toHaveCount(1); + + await page.getByRole("button", { name: "Delete: Anna Kowalska, ul. Krótka 2, Kraków" }).click(); + await page.getByRole("button", { name: "Delete address" }).click(); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page).first()).toContainText("Default"); +}); + +test("an address the rules now refuse is flagged, and at 10 there is no Add", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await withDb(async (db) => { + for (let i = 0; i < 10; i += 1) { + await db.query( + `INSERT INTO customer_addresses (id, customer_id, full_name, line1, city, postal_code, + country_code, is_default, updated_at) + VALUES (gen_random_uuid(), $1, 'Anna Kowalska', $2, 'Warsaw', $3, 'PL', $4, now())`, + [customer.id, `ul. Długa ${i + 1}`, i === 0 ? "12345" : "00-950", i === 0], + ); + } + }); + + await page.goto("/account/addresses"); + await expect(page.getByText("You can save up to 10 addresses.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add an address" })).toHaveCount(0); + await expect(cards(page).filter({ hasText: "Check this address" })).toHaveCount(1); +}); diff --git a/tests/e2e/account/checkout.spec.ts b/tests/e2e/account/checkout.spec.ts new file mode 100644 index 0000000..0409ff9 --- /dev/null +++ b/tests/e2e/account/checkout.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser, testPassword, withDb } from "../admin/support"; +import { seedProduct } from "../catalog/support"; +import { + addToCart, + cartIdOf, + hasRealStripeKey, + payButton, + postPaidEvent, + seedPendingOrder, +} from "../checkout/support"; +import { signInAsCustomer } from "./support"; + +// spec 0012: checkout with an account (AC-15, AC-16) and the account offer after a guest order +// (AC-17). + +async function saveDefaultAddress(customerId: string) { + await withDb((db) => + db.query( + `INSERT INTO customer_addresses (id, customer_id, full_name, line1, city, postal_code, + country_code, is_default, updated_at) + VALUES (gen_random_uuid(), $1, 'Anna Kowalska', 'ul. Długa 7', 'Gdańsk', '80-001', 'PL', + true, now())`, + [customerId], + ), + ); +} + +test("a guest signs in from checkout and comes back with the merged cart and their address", async ({ + page, +}) => { + const customer = await createTestUser({ admin: false }); + // The customers row is made on first sign in; the address book needs it. + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await saveDefaultAddress(customer.id); + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page).toHaveURL("/"); + + const socks = await seedProduct({ stock: 10, priceCents: 1500 }); + await addToCart(page, socks, 2); + await page.goto("/checkout"); + await page.getByRole("link", { name: "Sign in" }).click(); + await expect(page).toHaveURL("/account/sign-in?next=/checkout"); + // The checkout page stays in the DOM, hidden, after a client navigation: query by role. + await page.getByRole("textbox", { name: "Email" }).fill(customer.email); + await page.getByRole("textbox", { name: "Password" }).fill(testPassword); + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect(page).toHaveURL("/checkout"); + await expect(page.getByText("2 × €15.00")).toBeVisible(); + const email = page.getByRole("textbox", { name: "Email" }); + await expect(email).toHaveValue(customer.email); + await expect(email).toHaveAttribute("readonly", ""); + await expect(page.getByLabel("Address line 1")).toHaveValue("ul. Długa 7"); + await expect(page.getByLabel("Postal code")).toHaveValue("80-001"); + await expect( + page.getByRole("checkbox", { name: "Save this address to my account" }), + ).toBeChecked(); + await expect(page.getByRole("link", { name: "Sign in" })).toHaveCount(0); + + // A saved address can be swapped in. + await page.getByLabel("Address line 1").fill("somewhere else"); + await page + .getByLabel("Saved addresses") + .selectOption({ label: "Anna Kowalska, ul. Długa 7, 80-001 Gdańsk" }); + await expect(page.getByLabel("Address line 1")).toHaveValue("ul. Długa 7"); +}); + +test("a signed in Pay goes to Stripe with the account on the order and the address saved", async ({ + page, +}) => { + test.skip(!hasRealStripeKey, "needs a real Stripe test key (STRIPE_SECRET_KEY)"); + const customer = await createTestUser({ admin: false }); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + const socks = await seedProduct({ stock: 10, priceCents: 1500 }); + await addToCart(page, socks); + await page.goto("/checkout"); + await expect(payButton(page)).toBeEnabled(); + await page.getByLabel("Full name").fill("Anna Kowalska"); + await page.getByLabel("Address line 1").fill("ul. Marszałkowska 1"); + await page.getByLabel("Postal code").fill("00950"); + await page.getByLabel("City").fill("Warsaw"); + await payButton(page).click(); + await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//, { timeout: 30_000 }); + + const row = await withDb((db) => + db.query<{ customer_id: string; email: string; saved: number }>( + `SELECT o.customer_id, o.email, + (SELECT count(*)::int FROM customer_addresses a WHERE a.customer_id = $1) AS saved + FROM orders o WHERE o.customer_id = $1`, + [customer.id], + ), + ); + expect(row.rows[0]).toMatchObject({ customer_id: customer.id, email: customer.email, saved: 1 }); +}); + +test("a guest's paid order offers an account, which sends the confirm email to the order's email", async ({ + page, + request, +}) => { + const socks = await seedProduct({ stock: 10, priceCents: 1500 }); + await addToCart(page, socks); + const email = `e2e-offer-${Date.now()}@example.com`; + const order = await seedPendingOrder(await cartIdOf(page), socks, { + quantity: 1, + priceCents: 1500, + email, + shippingCents: 0, + }); + expect((await postPaidEvent(request, order)).status()).toBe(200); + + await page.goto(`/checkout/complete?session_id=${order.sessionId}`); + await expect(page.getByRole("heading", { name: "Thank you for your order" })).toBeVisible(); + await page.getByRole("button", { name: "Create an account to track this order" }).click(); + await expect(page.getByText(/Check your email/)).toBeVisible(); + + await expect + .poll(async () => { + const search = await fetch( + `http://127.0.0.1:55324/api/v1/search?query=${encodeURIComponent(`to:"${email}"`)}`, + ); + return ((await search.json()) as { messages_count: number }).messages_count; + }) + .toBe(1); +}); + +test("a signed in visitor sees no account offer on the confirmation page", async ({ + page, + request, +}) => { + const customer = await createTestUser({ admin: false }); + const socks = await seedProduct({ stock: 10, priceCents: 1500 }); + await addToCart(page, socks); + const order = await seedPendingOrder(await cartIdOf(page), socks, { + quantity: 1, + priceCents: 1500, + email: `e2e-other-${Date.now()}@example.com`, + }); + expect((await postPaidEvent(request, order)).status()).toBe(200); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + + await page.goto(`/checkout/complete?session_id=${order.sessionId}`); + await expect(page.getByRole("heading", { name: "Thank you for your order" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create an account to track this order" }), + ).toHaveCount(0); +}); diff --git a/tests/e2e/account/orders.spec.ts b/tests/e2e/account/orders.spec.ts new file mode 100644 index 0000000..84d2f55 --- /dev/null +++ b/tests/e2e/account/orders.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser, signInFully, testPassword } from "../admin/support"; +import { seedPaidOrder, signInAsCustomer } from "./support"; + +// spec 0012: order history and one order (AC-10, AC-11, AC-12, AC-13). + +test("a customer sees their paid orders, opens one, and never sees anyone else's", async ({ + page, +}) => { + const customer = await createTestUser({ admin: false }); + const someoneElse = await createTestUser({ admin: false }); + const paid = await seedPaidOrder(customer.email, { quantity: 3 }); + const pending = await seedPaidOrder(customer.email, { status: "pending_payment" }); + const theirs = await seedPaidOrder(someoneElse.email); + + // Sign in links the guest orders placed with this email (AC-10). + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + + const row = page.getByRole("link", { name: new RegExp(`Order #${paid.number}`) }); + await expect(row).toContainText("Paid"); + await expect(row).toContainText("3 items"); + await expect(row).toContainText("€45.00"); + await expect( + page.getByRole("link", { name: new RegExp(`Order #${pending.number}`) }), + ).toHaveCount(0); + + await row.click(); + await expect(page).toHaveURL(`/account/orders/${paid.number}`); + await expect(page.getByRole("heading", { name: `Order #${paid.number}` })).toBeVisible(); + await expect(page.getByText("3 × €15.00")).toBeVisible(); + await expect(page.getByText("Anna Kowalska")).toBeVisible(); + await expect(page.getByText("Free", { exact: true })).toBeVisible(); + + // Another customer's order and a pending one read exactly like a missing one (AC-13). + for (const number of [theirs.number, pending.number, 99_999_999]) { + await page.goto(`/account/orders/${number}`); + await expect(page.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + } +}); + +test("a new account with no orders sees the empty state", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + await signInAsCustomer(page, customer.email, testPassword); + + await expect(page.getByText("No orders yet.")).toBeVisible(); + await page.getByRole("link", { name: "Go to the shop" }).click(); + await expect(page).toHaveURL("/"); +}); + +// AC-21: the admin sees whether an order belongs to an account. +test("the admin order pages show Account for a linked order and Guest for the rest", async ({ + page, + browser, +}) => { + const customer = await createTestUser({ admin: false }); + const linked = await seedPaidOrder(customer.email); + const guest = await seedPaidOrder(`e2e-guest-${Date.now()}@example.com`); + const customerPage = await (await browser.newContext()).newPage(); + await signInAsCustomer(customerPage, customer.email, testPassword); + await expect(customerPage).toHaveURL("/account"); + + const admin = await createTestUser({ enrolled: true }); + await signInFully(page, admin); + for (const [order, label] of [ + [linked, "Account"], + [guest, "Guest"], + ] as const) { + await page.goto(`/admin/orders/${order.number}`); + const row = page.getByRole("term").filter({ hasText: /^Customer$/ }); + await expect(row.locator("xpath=following-sibling::dd[1]")).toHaveText(label); + } +}); diff --git a/tests/e2e/account/password-reset.spec.ts b/tests/e2e/account/password-reset.spec.ts new file mode 100644 index 0000000..f7dcf63 --- /dev/null +++ b/tests/e2e/account/password-reset.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser, latestEmailHtml, testPassword } from "../admin/support"; +import { accountLinkFor, choosePassword, signInAsCustomer, uniqueEmail } from "./support"; + +// spec 0012: forgot password through the Send Email Hook (AC-8, AC-9, AC-2, AC-3). + +const sent = "If there is an account for that email, a reset link is on its way."; + +async function requestReset(page: import("@playwright/test").Page, email: string) { + await page.goto("/account/forgot-password"); + await page.getByLabel("Email").fill(email); + await page.getByRole("button", { name: "Send reset link" }).click(); + await expect(page.getByRole("status").filter({ hasText: sent })).toBeVisible(); +} + +test("a customer resets their password from the email and every old session ends", async ({ + page, + browser, +}) => { + const customer = await createTestUser({ admin: false }); + // A session on another device, which the reset signs out. + const other = await browser.newContext(); + const otherPage = await other.newPage(); + await signInAsCustomer(otherPage, customer.email, testPassword); + await expect(otherPage).toHaveURL("/account"); + + await requestReset(page, customer.email); + const link = await accountLinkFor(customer.email); + expect(link).toContain("type=recovery"); + await page.goto(link); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/account/set-password?from=reset"); + + await choosePassword(page, testPassword); + await expect(page.getByText("Choose a password you have not used here before.")).toBeVisible(); + await choosePassword(page, "Fresh-start-77"); + await expect(page).toHaveURL("/account?notice=password_changed"); + await expect(page.getByText("Your password is changed.")).toBeVisible(); + + await otherPage.goto("/account"); + await expect(otherPage).toHaveURL(/\/account\/sign-in/); + await other.close(); + + await page.context().clearCookies(); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page.getByText("That email and password do not match.")).toBeVisible(); + await signInAsCustomer(page, customer.email, "Fresh-start-77"); + await expect(page).toHaveURL("/account"); +}); + +test("an unknown email gets the same reply and no email", async ({ page }) => { + const email = uniqueEmail("nobody"); + await requestReset(page, email); + + await page.waitForTimeout(1_500); + const search = await fetch( + `http://127.0.0.1:55324/api/v1/search?query=${encodeURIComponent(`to:"${email}"`)}`, + ); + expect(((await search.json()) as { messages_count: number }).messages_count).toBe(0); +}); + +test("an admin asking here gets the admin email, and its link never skips the authenticator", async ({ + page, +}) => { + const admin = await createTestUser({ enrolled: true }); + await requestReset(page, admin.email); + + const html = await latestEmailHtml(admin.email); + expect(html).toContain("Reset your admin password"); + expect(html).not.toContain("/account/confirm"); + const adminLink = html + .match(/href="([^"]*\/auth\/confirm\?[^"]*)"/)?.[1] + ?.replaceAll("&", "&"); + expect(adminLink).toBeDefined(); + + // The same token pasted into the customer confirm page still lands on the authenticator step. + const pasted = adminLink!.replace("/auth/confirm", "/account/confirm"); + await page.goto(pasted); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/admin/mfa?next=%2Fadmin%2Freset-password"); +}); + +test("an expired reset link offers a new one", async ({ page }) => { + await page.goto("/account/confirm?token_hash=not-a-real-token&type=recovery"); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("This link has expired or was already used.")).toBeVisible(); + await page.getByRole("link", { name: "Ask for a new reset link" }).click(); + await expect(page).toHaveURL("/account/forgot-password"); +}); diff --git a/tests/e2e/account/session-boundaries.spec.ts b/tests/e2e/account/session-boundaries.spec.ts new file mode 100644 index 0000000..86f7571 --- /dev/null +++ b/tests/e2e/account/session-boundaries.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser, testPassword, withDb } from "../admin/support"; +import { signInAsCustomer } from "./support"; + +// spec 0012, Critical test scenarios: what a customer session can never reach (AC-13, AC-20, +// AC-22). + +test("a customer session cannot open /admin", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + + for (const path of ["/admin", "/admin/orders", "/admin/products"]) { + const response = await page.goto(path); + expect(response?.status(), path).toBe(404); + await expect(page.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + await expect(page.getByText(/Signed in as/)).toHaveCount(0); + } +}); + +test("Back after sign out shows no account data", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await page.goto("/account/settings"); + await expect(page.getByText(customer.email)).toBeVisible(); + + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page).toHaveURL("/"); + + // Back, and Back again past the replaced entry: no page shows the account. + await page.goBack(); + await expect(page.getByText(customer.email)).toHaveCount(0); + await page.goBack(); + await expect(page.getByText(customer.email)).toHaveCount(0); + await expect(page).not.toHaveURL(/\/account\/settings$/); +}); + +test("a deleted account's session on another device cannot read or write anything", async ({ + page, + browser, +}) => { + const customer = await createTestUser({ admin: false }); + const other = await browser.newContext(); + const otherPage = await other.newPage(); + await signInAsCustomer(otherPage, customer.email, testPassword); + await expect(otherPage).toHaveURL("/account"); + await otherPage.goto("/account/addresses"); + await otherPage.getByRole("button", { name: "Add an address" }).click(); + const dialog = otherPage.getByRole("dialog", { name: "Add an address" }); + await dialog.getByLabel("Full name").fill("Anna Kowalska"); + await dialog.getByLabel("Address line 1").fill("ul. Długa 1"); + await dialog.getByLabel("Postal code").fill("00950"); + await dialog.getByLabel("City").fill("Warsaw"); + + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await page.goto("/account/settings"); + await page.getByRole("button", { name: "Delete my account" }).click(); + const confirm = page.getByRole("dialog", { name: "Delete your account?" }); + await confirm.getByLabel("Your password").fill(testPassword); + await confirm.getByRole("button", { name: "Delete my account" }).click(); + await expect(page).toHaveURL("/?notice=account_deleted"); + + // The other device's open form writes nothing, and its pages send it to sign in. + await dialog.getByRole("button", { name: "Save address" }).click(); + await expect(otherPage).toHaveURL(/\/account\/sign-in/); + await otherPage.goto("/account"); + await expect(otherPage).toHaveURL(/\/account\/sign-in/); + await otherPage.goto("/account/addresses"); + await expect(otherPage).toHaveURL(/\/account\/sign-in/); + + // Nothing recreated the customer row or saved the address. + const rows = await withDb(async (db) => { + const found = await db.query<{ customers: string; addresses: string }>( + `SELECT (SELECT count(*) FROM customers WHERE id = $1) AS customers, + (SELECT count(*) FROM customer_addresses WHERE customer_id = $1) AS addresses`, + [customer.id], + ); + return found.rows[0]; + }); + expect(rows).toEqual({ customers: "0", addresses: "0" }); + await other.close(); +}); diff --git a/tests/e2e/account/settings.spec.ts b/tests/e2e/account/settings.spec.ts new file mode 100644 index 0000000..73ec506 --- /dev/null +++ b/tests/e2e/account/settings.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser, testPassword } from "../admin/support"; +import { seedPaidOrder, signInAsCustomer } from "./support"; + +// spec 0012: profile, change password and delete account (AC-18, AC-19, AC-20). + +test("a customer edits their details and changes their password", async ({ page, browser }) => { + const customer = await createTestUser({ admin: false }); + const other = await browser.newContext(); + const otherPage = await other.newPage(); + await signInAsCustomer(otherPage, customer.email, testPassword); + await expect(otherPage).toHaveURL("/account"); + + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await page.getByRole("link", { name: "Settings" }).click(); + await expect(page.getByText(customer.email)).toBeVisible(); + await expect(page.getByText("To change your email, contact us.")).toBeVisible(); + + await page.getByLabel("Name (optional)").fill("Ada Lovelace"); + await page.getByLabel("Phone (optional)").fill("123"); + await page.getByRole("button", { name: "Save details" }).click(); + await expect( + page.getByText("Enter a phone number with 7 to 15 digits, or leave it empty."), + ).toBeVisible(); + await page.getByLabel("Phone (optional)").fill("+48 600 100 200"); + await page.getByRole("button", { name: "Save details" }).click(); + await expect(page.getByText("Your details are saved.")).toBeVisible(); + await page.reload(); + await expect(page.getByLabel("Name (optional)")).toHaveValue("Ada Lovelace"); + + await page.getByLabel("Current password").fill("Not-my-password-1"); + await page.getByLabel("New password").fill("Fresh-start-88"); + await page.getByLabel("Type it again").fill("Fresh-start-88"); + await page.getByRole("button", { name: "Change password" }).click(); + await expect(page.getByText("Your current password is not right.")).toBeVisible(); + await expect(page.getByLabel("Current password")).toBeFocused(); + + await page.getByLabel("Current password").fill(testPassword); + await page.getByLabel("New password").fill("Fresh-start-88"); + await page.getByLabel("Type it again").fill("Fresh-start-88"); + await page.getByRole("button", { name: "Change password" }).click(); + await expect(page.getByText("Your password is changed.")).toBeVisible(); + + // This session stays; the other device is signed out. + await page.goto("/account"); + await expect(page).toHaveURL("/account"); + await otherPage.goto("/account"); + await expect(otherPage).toHaveURL(/\/account\/sign-in/); + await other.close(); +}); + +test("a customer deletes their account after the open order warning", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + await seedPaidOrder(customer.email); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await page.goto("/account/settings"); + + await page.getByRole("button", { name: "Delete my account" }).click(); + const dialog = page.getByRole("dialog", { name: "Delete your account?" }); + await expect( + dialog.getByText( + `You have 1 order not shipped yet. They will still be delivered, and emails about them still go to ${customer.email}.`, + ), + ).toBeVisible(); + await dialog.getByLabel("Your password").fill("Wrong-password-1"); + await dialog.getByRole("button", { name: "Delete my account" }).click(); + await expect(dialog.getByText("Your current password is not right.")).toBeVisible(); + + await dialog.getByLabel("Your password").fill(testPassword); + await dialog.getByRole("button", { name: "Delete my account" }).click(); + await expect(page).toHaveURL("/?notice=account_deleted"); + await expect(page.getByText("Your account is deleted.")).toBeVisible(); + + await signInAsCustomer(page, customer.email, testPassword); + await expect(page.getByText("That email and password do not match.")).toBeVisible(); +}); + +test("an admin cannot change their password or delete their account here", async ({ page }) => { + const admin = await createTestUser(); + await signInAsCustomer(page, admin.email, testPassword); + await expect(page).toHaveURL("/account"); + await page.goto("/account/settings"); + + await page.getByLabel("Current password").fill(testPassword); + await page.getByLabel("New password").fill("Fresh-start-88"); + await page.getByLabel("Type it again").fill("Fresh-start-88"); + await page.getByRole("button", { name: "Change password" }).click(); + await expect( + page.getByText( + "This account also runs the store. Use Forgot password on the admin sign in page.", + ), + ).toBeVisible(); + + await page.getByRole("button", { name: "Delete my account" }).click(); + const dialog = page.getByRole("dialog", { name: "Delete your account?" }); + await dialog.getByLabel("Your password").fill(testPassword); + await dialog.getByRole("button", { name: "Delete my account" }).click(); + await expect( + dialog.getByText("This account also runs the store, so it cannot be deleted here."), + ).toBeVisible(); +}); diff --git a/tests/e2e/account/sign-in.spec.ts b/tests/e2e/account/sign-in.spec.ts new file mode 100644 index 0000000..35a9237 --- /dev/null +++ b/tests/e2e/account/sign-in.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser, signInFully, testPassword } from "../admin/support"; +import { seedProduct } from "../catalog/support"; +import { addToCart } from "../checkout/support"; +import { signInAsCustomer, uniqueEmail } from "./support"; + +// spec 0012: sign in, sign out and the cart merge (AC-4, AC-5, AC-6, AC-7). + +const mismatch = "That email and password do not match."; + +test("a wrong password, an unknown email and an unconfirmed account all get one message", async ({ + page, +}) => { + const customer = await createTestUser({ admin: false }); + const unconfirmed = uniqueEmail("unconfirmed"); + await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/signup`, { + method: "POST", + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", + "content-type": "application/json", + }, + body: JSON.stringify({ email: unconfirmed, password: testPassword }), + }); + + for (const [email, password] of [ + [customer.email, "Wrong-password-1"], + [uniqueEmail("unknown"), testPassword], + [unconfirmed, testPassword], + ] as const) { + await signInAsCustomer(page, email, password); + await expect(page.getByRole("alert").filter({ hasText: mismatch })).toBeVisible(); + await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); + } +}); + +test("sign in goes to a safe next and refuses any other", async ({ page }) => { + const customer = await createTestUser({ admin: false }); + + await page.goto("/account/sign-in?next=%2F%2Fevil.example"); + await page.getByLabel("Email").fill(customer.email); + await page.getByLabel("Password", { exact: true }).fill(testPassword); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page).toHaveURL("/account"); + + // Signed in, the sign in and sign up pages send the visitor on (AC-4). + await page.goto("/account/sign-in"); + await expect(page).toHaveURL("/account"); + await page.goto("/account/sign-up"); + await expect(page).toHaveURL("/account"); + + await page.context().clearCookies(); + await page.goto("/account?page=2"); + await expect(page).toHaveURL("/account/sign-in?next=%2Faccount%3Fpage%3D2"); + await page.getByLabel("Email").fill(customer.email); + await page.getByLabel("Password", { exact: true }).fill(testPassword); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page).toHaveURL("/account?page=2"); +}); + +test("a signed in admin opening the customer sign in page keeps their admin session", async ({ + page, +}) => { + const admin = await createTestUser({ enrolled: true }); + await signInFully(page, admin); + + await page.goto("/account/sign-in"); + await expect(page).toHaveURL("/account"); + await page.goto("/admin"); + await expect(page.getByText(`Signed in as ${admin.name}`)).toBeVisible(); +}); + +test("the guest cart joins the account cart on sign in and comes back after sign out", async ({ + page, +}) => { + const customer = await createTestUser({ admin: false }); + const socks = await seedProduct({ stock: 20, priceCents: 1500 }); + const scarf = await seedProduct({ stock: 20, priceCents: 4000, label: "Scarf" }); + + // An account cart from an earlier visit. + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await addToCart(page, socks, 2); + await page.goto("/account"); + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page).toHaveURL("/"); + await expect(page.getByRole("link", { name: "Cart", exact: true })).toBeVisible(); + + // Sign out replaced the account page in the history, and the account pages are never cached, + // so nothing shows the account again without signing in. + await page.goBack(); + await expect(page).not.toHaveURL(/\/account$/); + await page.goto("/account"); + await expect(page).toHaveURL(/\/account\/sign-in/); + + // Shopping as a guest, then signing in, merges the two carts. + await addToCart(page, scarf); + await addToCart(page, socks, 1); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await expect(page.getByRole("link", { name: "Cart, 4 items" })).toBeVisible(); + + // Sign out forgets the cart in this browser only; the next sign in brings it back. + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page.getByRole("link", { name: "Cart", exact: true })).toBeVisible(); + await signInAsCustomer(page, customer.email, testPassword); + await expect(page.getByRole("link", { name: "Cart, 4 items" })).toBeVisible(); +}); + +// AC-7: a session that ended without our sign out (expiry, another device, an admin sign out) +// leaves the cart cookie behind; the account cart stays its customer's. +test("a leftover account cart cookie with no session shows an empty cart and starts a guest one", async ({ + page, +}) => { + const customer = await createTestUser({ admin: false }); + const socks = await seedProduct({ stock: 20, priceCents: 1500 }); + const scarf = await seedProduct({ stock: 20, priceCents: 4000, label: "Scarf" }); + + await signInAsCustomer(page, customer.email, testPassword); + await expect(page).toHaveURL("/account"); + await addToCart(page, socks, 2); + + // The session ends, the cart cookie stays. + await page.context().clearCookies({ name: /^sb-/ }); + await page.goto("/cart"); + await expect(page.getByRole("heading", { name: "Your cart is empty" })).toBeVisible(); + + await addToCart(page, scarf); + await page.goto("/cart"); + await expect(page.getByRole("link", { name: scarf.name })).toBeVisible(); + await expect(page.getByRole("link", { name: socks.name })).toHaveCount(0); + + // Signing in again brings the account cart back, with the guest line merged in. + await signInAsCustomer(page, customer.email, testPassword); + await expect(page.getByRole("link", { name: "Cart, 3 items" })).toBeVisible(); +}); diff --git a/tests/e2e/account/sign-up.spec.ts b/tests/e2e/account/sign-up.spec.ts new file mode 100644 index 0000000..9c629b5 --- /dev/null +++ b/tests/e2e/account/sign-up.spec.ts @@ -0,0 +1,134 @@ +import { expect, test } from "@playwright/test"; + +import { createTestUser } from "../admin/support"; +import { + accountLinkFor, + choosePassword, + customerPassword, + orderCustomerId, + seedPaidOrder, + signInAsCustomer, + signUpWithEmail, + uniqueEmail, +} from "./support"; + +// spec 0012: the thin thread, sign up to the account page through every layer (AC-1, AC-2, AC-3, +// AC-8, AC-10, AC-11, AC-13). + +test("a guest signs up, confirms from the email, chooses a password and sees an earlier order", async ({ + page, + request, +}) => { + const email = uniqueEmail(); + const earlier = await seedPaidOrder(email); + + await signUpWithEmail(page, email.toUpperCase()); + const link = await accountLinkFor(email); + + // Opening the link, as a mail scanner does, spends nothing (AC-2). + const scanned = await request.get(link, { maxRedirects: 0 }); + expect(scanned.status()).toBe(200); + expect(scanned.headers()["referrer-policy"]).toBe("no-referrer"); + + await page.goto(link); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/account/set-password"); + + // Every missing part is named (AC-3). + await choosePassword(page, "short"); + for (const part of ["at least 8 characters", "an upper case letter", "a digit", "a symbol"]) { + await expect(page.getByText(`Missing: ${part}`)).toBeVisible(); + } + + await choosePassword(page); + await expect(page).toHaveURL("/account?notice=ready"); + await expect(page.getByText("Your account is ready.")).toBeVisible(); + await expect( + page.getByRole("link", { name: new RegExp(`Order #${earlier.number}`) }), + ).toBeVisible(); + expect(await orderCustomerId(earlier.id)).not.toBeNull(); + + // The link works once (AC-2). + await page.context().clearCookies(); + await page.goto(link); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("This link has expired or was already used.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Send the link again" })).toBeVisible(); + + // The chosen password signs in. + await signInAsCustomer(page, email); + await expect(page).toHaveURL("/account"); +}); + +test("signing up with an existing account's email gives the same answer and sends nothing", async ({ + page, +}) => { + const existing = await createTestUser({ admin: false }); + + await signUpWithEmail(page, existing.email); + await expect(page.getByRole("button", { name: "Send the link again" })).toBeVisible(); + + await page.waitForTimeout(1_500); + const search = await fetch( + `http://127.0.0.1:55324/api/v1/search?query=${encodeURIComponent(`to:"${existing.email}"`)}`, + ); + expect(((await search.json()) as { messages_count: number }).messages_count).toBe(0); +}); + +test("a password planted before the confirm never survives it", async ({ page }) => { + const email = uniqueEmail("planted"); + // Someone else signs up with this email straight against the Auth API, with a password they + // know, before the owner confirms. + await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/signup`, { + method: "POST", + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", + "content-type": "application/json", + }, + body: JSON.stringify({ email, password: "Planted-pass-1" }), + }); + await signUpWithEmail(page, email); + await page.goto(await accountLinkFor(email)); + await page.getByRole("button", { name: "Continue" }).click(); + await choosePassword(page); + await expect(page).toHaveURL("/account?notice=ready"); + + await page.context().clearCookies(); + await signInAsCustomer(page, email, "Planted-pass-1"); + await expect(page.getByText("That email and password do not match.")).toBeVisible(); + await signInAsCustomer(page, email, customerPassword); + await expect(page).toHaveURL("/account"); +}); + +test("a planted password stays dead when the owner confirms and leaves without choosing one", async ({ + page, +}) => { + const email = uniqueEmail("abandoned"); + await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/signup`, { + method: "POST", + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "", + "content-type": "application/json", + }, + body: JSON.stringify({ email, password: "Planted-pass-1" }), + }); + await page.goto(await accountLinkFor(email)); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/account/set-password"); + + await page.context().clearCookies(); + await signInAsCustomer(page, email, "Planted-pass-1"); + await expect(page.getByText("That email and password do not match.")).toBeVisible(); +}); + +test("a signed out visitor on an account page is sent to sign in, never from a cache", async ({ + page, + request, +}) => { + const redirect = await request.get("/account/orders/1001", { maxRedirects: 0 }); + expect(redirect.status()).toBe(307); + expect(redirect.headers()["cache-control"]).toBe("no-store"); + + await page.goto("/account/orders/1001"); + await expect(page).toHaveURL("/account/sign-in?next=%2Faccount%2Forders%2F1001"); +}); diff --git a/tests/e2e/account/support.ts b/tests/e2e/account/support.ts new file mode 100644 index 0000000..5f25042 --- /dev/null +++ b/tests/e2e/account/support.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto"; + +import { expect, type Page } from "@playwright/test"; + +import { latestEmailHtml, withDb } from "../admin/support"; +import { seedProduct } from "../catalog/support"; + +// Customer accounts (spec 0012) against the real local stack: emails come through the Send Email +// Hook and the mailpit transport, and are read back from Mailpit. + +export const customerPassword = "Garden-gnome-42"; + +export function uniqueEmail(label = "customer"): string { + return `e2e-${label}-${randomUUID()}@example.com`; +} + +// The /account/confirm link in the newest email for this address. +export async function accountLinkFor(email: string): Promise { + const html = await latestEmailHtml(email); + const match = html.match(/href="([^"]*\/account\/confirm\?[^"]*)"/); + if (!match?.[1]) throw new Error("No account link in the email."); + return match[1].replaceAll("&", "&"); +} + +export async function signUpWithEmail(page: Page, email: string) { + await page.goto("/account/sign-up"); + await page.getByLabel("Email").fill(email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible(); +} + +export async function choosePassword(page: Page, password = customerPassword) { + await page.getByLabel("New password").fill(password); + await page.getByLabel("Type it again").fill(password); + await page.getByRole("button", { name: "Save password" }).click(); +} + +// Sign up, confirm from Mailpit and choose a password: an active account, signed in. +export async function createAccountThroughUi(page: Page, email = uniqueEmail()) { + await signUpWithEmail(page, email); + await page.goto(await accountLinkFor(email)); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/account/set-password"); + await choosePassword(page); + await expect(page).toHaveURL("/account?notice=ready"); + return email; +} + +export async function signInAsCustomer(page: Page, email: string, password = customerPassword) { + await page.goto("/account/sign-in"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); +} + +export type PaidOrder = { readonly id: string; readonly number: number }; + +// A paid guest order for this email, as checkout and the webhook leave it. +export async function seedPaidOrder( + email: string, + options: { + readonly status?: "paid" | "shipped" | "pending_payment"; + readonly quantity?: number; + } = {}, +): Promise { + const product = await seedProduct({ stock: 20, priceCents: 1500, label: "Linen cap" }); + const status = options.status ?? "paid"; + const quantity = options.quantity ?? 2; + const total = 1500 * quantity; + return withDb(async (db) => { + const order = await db.query<{ id: string; number: number }>( + `INSERT INTO orders (id, status, email, currency, subtotal_cents, total_cents, paid_at, + ship_full_name, ship_line1, ship_city, ship_postal_code, ship_country_code, updated_at) + VALUES (gen_random_uuid(), $1, $2, 'EUR', $3, $3, $4, 'Anna Kowalska', 'ul. Długa 1', + 'Warsaw', '00-950', 'PL', now()) + RETURNING id, number`, + [status, email, total, status === "pending_payment" ? null : new Date()], + ); + const { id, number } = order.rows[0]!; + await db.query( + `INSERT INTO order_lines (id, order_id, variant_id, product_id, product_name, sku, + unit_price_cents, quantity, line_total_cents) + VALUES (gen_random_uuid(), $1, $2, $3, $4, 'SKU-E2E', 1500, $5, $6)`, + [id, product.variantId, product.id, product.name, quantity, total], + ); + return { id, number }; + }); +} + +export async function orderCustomerId(orderId: string): Promise { + return withDb(async (db) => { + const row = await db.query<{ customer_id: string | null }>( + "SELECT customer_id FROM orders WHERE id = $1", + [orderId], + ); + return row.rows[0]?.customer_id ?? null; + }); +} diff --git a/tests/valid-env.ts b/tests/valid-env.ts index 92d6176..68a1841 100644 --- a/tests/valid-env.ts +++ b/tests/valid-env.ts @@ -18,6 +18,7 @@ export const validEnv = { STORE_LOCALE: "en", EMAIL_TRANSPORT: "log", EMAIL_FROM: "BeStore ", + SEND_EMAIL_HOOK_SECRET: "v1,whsec_dGVzdC1ob29rLXNlY3JldC0wMTIzNDU2Nzg5", } as const; // What env.ts derives from the values above. @@ -34,6 +35,7 @@ export function stubEnv(overrides: Partial> = vi.stubEnv("TEST_DATABASE_URL", undefined); vi.stubEnv("VERCEL_ENV", undefined); vi.stubEnv("RESEND_API_KEY", undefined); + vi.stubEnv("MAILPIT_URL", undefined); const values = { ...validEnv, ...overrides }; for (const key of Object.keys(values) as EnvKey[]) { vi.stubEnv(key, values[key]);