Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
e55c3f9
docs(accounts): add spec 0012 customer accounts
kris1027 Oct 14, 2026
0b3f746
feat(accounts): sign up through the Send Email Hook to the account page
kris1027 Oct 14, 2026
614761c
feat(accounts): sign in, sign out, cart merge and password reset
kris1027 Oct 14, 2026
4eedb2a
feat(accounts): order history and the order page
kris1027 Oct 14, 2026
97d6f01
feat(accounts): address book
kris1027 Oct 14, 2026
ddbae77
feat(accounts): signed in checkout and the account offer after a gues…
kris1027 Oct 14, 2026
a4f5dad
feat(accounts): profile, change password and delete account
kris1027 Oct 14, 2026
6c5f52e
feat(accounts): Account or Guest on the admin order pages
kris1027 Oct 14, 2026
2b4c7c0
feat(accounts): log redaction test, axe on every account page and the…
kris1027 Oct 14, 2026
7b0bd37
fix(accounts): log only safe codes when deleting an account fails
kris1027 Oct 14, 2026
fb8c9c0
docs(accounts): verify steps and the scope code pointer
kris1027 Oct 14, 2026
ebb3938
docs(accounts): amend AC-15 with the source of the order's customer name
kris1027 Oct 15, 2026
900fd2a
feat(accounts): name the order after the profile or delivery name and…
kris1027 Oct 15, 2026
6146a05
fix(accounts): replace a planted sign up password when the email is c…
kris1027 Oct 15, 2026
a983e07
docs(accounts): fresh model review findings and tick Review it
kris1027 Oct 15, 2026
3f11da2
docs(accounts): verify results and tick Verify it
kris1027 Oct 15, 2026
585bfc6
test(accounts): cover schemas, password check, session and tamper cases
kris1027 Oct 15, 2026
e76919d
docs(accounts): tick Document it
kris1027 Oct 15, 2026
9138438
chore(sync): reconcile docs
kris1027 Oct 15, 2026
797df62
docs(accounts): amend spec 0012 after review
kris1027 Oct 15, 2026
1b97338
fix(accounts): keep account carts to their owner, count merged lines,…
kris1027 Oct 15, 2026
488d41e
docs(accounts): tick the review follow ups and Build it
kris1027 Oct 15, 2026
1d68c22
docs(accounts): mark customer accounts done and spec 0012 accepted
kris1027 Oct 15, 2026
750282d
chore(sync): reconcile docs
kris1027 Oct 15, 2026
ff39f17
chore(ci): ignore test fixtures in secret scanning
kris1027 Oct 15, 2026
e2efbd6
refactor(accounts): parse the order page and number params with Zod
kris1027 Oct 15, 2026
46c24f1
docs(env): note that CLI-only variables live in .env.example alone
kris1027 Oct 15, 2026
b31fdd4
fix(accounts): refuse account delete for a user with a verified authe…
kris1027 Oct 15, 2026
f50d584
refactor(accounts): share the password check error and carry the auth…
kris1027 Oct 15, 2026
86c20e5
refactor(accounts): read the settings page data through queries.ts
kris1027 Oct 15, 2026
85ac0ae
refactor(checkout): move the signed in checkout steps out of createPe…
kris1027 Oct 15, 2026
a14c598
refactor(orders): one order status wording and one max order number i…
kris1027 Oct 15, 2026
37c8330
refactor(emails): build the three auth emails from one AuthLinkEmail
kris1027 Oct 15, 2026
3d1dc68
refactor(accounts): keep EmailFormError beside the email schema
kris1027 Oct 15, 2026
199629d
refactor(accounts): move the Send Email Hook handler into the account…
kris1027 Oct 15, 2026
5972f90
fix(accounts): refuse dot segments and a trailing slash in the sign i…
kris1027 Oct 15, 2026
92faf13
fix(cart): read the session when the cookie cart expires before the a…
kris1027 Oct 15, 2026
bc3d67e
fix(accounts): log a malformed Send Email Hook payload as auth_email.…
kris1027 Oct 15, 2026
f1e6dc8
docs(accounts): fix the stale sign up comment in config.toml and pin …
kris1027 Oct 15, 2026
b299602
test(accounts): e2e for the customer session boundaries the spec names
kris1027 Oct 15, 2026
4ff4ee6
test(accounts): assert the edited address on its card once the dialog…
kris1027 Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,26 @@ 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 <address>` or a bare address, on a domain verified in Resend.
# Replies land here, so it must be read.
EMAIL_FROM=BeStore <orders@example.com>

# 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
5 changes: 5 additions & 0 deletions .github/secret_scanning.yml
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <orders@example.com>"
} >> "$GITHUB_ENV"
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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._
22 changes: 22 additions & 0 deletions app/(store)/account/(area)/addresses/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-labelledby="addresses-heading" className="flex flex-col gap-4">
<h2 id="addresses-heading" className="font-heading text-2xl">
Addresses
</h2>
<Suspense fallback={<AddressBookSkeleton />}>
<AddressBook />
</Suspense>
</section>
);
}
25 changes: 25 additions & 0 deletions app/(store)/account/(area)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn(storeContainer, "flex flex-col gap-8 py-12 md:py-16")}>
<div className="flex flex-wrap items-end justify-between gap-4">
<h1 className="font-heading text-4xl md:text-5xl">Your account</h1>
<SignOutButton />
</div>
<div className="border-b">
<Suspense fallback={<AccountNavList />}>
<AccountNav />
</Suspense>
</div>
{children}
</div>
);
}
17 changes: 17 additions & 0 deletions app/(store)/account/(area)/orders/[number]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Suspense fallback={<OrderDetailSkeleton />}>
<OrderDetail params={params} />
</Suspense>
);
}
22 changes: 22 additions & 0 deletions app/(store)/account/(area)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-labelledby="orders-heading" className="flex flex-col gap-4">
<h2 id="orders-heading" className="font-heading text-2xl">
Orders
</h2>
<Suspense fallback={<OrdersListSkeleton />}>
<OrdersList searchParams={searchParams} />
</Suspense>
</section>
);
}
22 changes: 22 additions & 0 deletions app/(store)/account/(area)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-labelledby="settings-heading" className="flex flex-col gap-4">
<h2 id="settings-heading" className="font-heading text-2xl">
Settings
</h2>
<Suspense fallback={<SettingsSkeleton />}>
<Settings />
</Suspense>
</section>
);
}
21 changes: 21 additions & 0 deletions app/(store)/account/(auth)/confirm/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Suspense fallback={<AuthFormSkeleton />}>
<ConfirmLink searchParams={searchParams} />
</Suspense>
);
}
27 changes: 27 additions & 0 deletions app/(store)/account/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AccountAuthCard
title="Reset your password"
description="Enter your account email. We send a link that works once, within an hour."
footer={
<Link href={accountPaths.signIn} className={footerLinkClass}>
Back to sign in
</Link>
}
>
<ForgotPasswordForm />
</AccountAuthCard>
);
}
11 changes: 11 additions & 0 deletions app/(store)/account/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn(storeContainer, "flex justify-center py-12 md:py-16")}>
<div className="w-full max-w-md">{children}</div>
</div>
);
}
18 changes: 18 additions & 0 deletions app/(store)/account/(auth)/set-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Suspense fallback={<AuthFormSkeleton />}>
<SetPassword searchParams={searchParams} />
</Suspense>
);
}
50 changes: 50 additions & 0 deletions app/(store)/account/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AccountAuthCard
title="Sign in"
description="See your orders and saved addresses, and check out faster."
footer={
<>
<span className="text-muted-foreground">New here?</span>
<Link href={accountPaths.signUp} className={footerLinkClass}>
Create an account
</Link>
</>
}
>
<Suspense fallback={<AuthFormSkeleton />}>
<SignedOutOnly>
<SignInWithNext searchParams={searchParams} />
</SignedOutOnly>
</Suspense>
</AccountAuthCard>
);
}

// `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 <SignInForm next={typeof next === "string" ? next : null} />;
}
40 changes: 40 additions & 0 deletions app/(store)/account/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AccountAuthCard
title="Create an account"
description="Enter your email. We send you a link to confirm it, then you choose a password. You can always check out as a guest instead."
footer={
<>
<span className="text-muted-foreground">Already have an account?</span>
<Link href={accountPaths.signIn} className={footerLinkClass}>
Sign in
</Link>
</>
}
>
<Suspense fallback={<AuthFormSkeleton />}>
<SignedOutOnly>
<SignUpForm />
</SignedOutOnly>
</Suspense>
</AccountAuthCard>
);
}
5 changes: 5 additions & 0 deletions app/(store)/checkout/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +23,10 @@ export default function CheckoutCompletePage({ searchParams }: PageProps<"/check
<Suspense fallback={<CheckoutCompleteSkeleton />}>
<CheckoutComplete searchParams={searchParams} />
</Suspense>
{/* spec 0012, AC-17: a guest may turn this order into an account. */}
<Suspense fallback={null}>
<OrderSignUpOffer searchParams={searchParams} />
</Suspense>
</div>
);
}
Loading
Loading