Skip to content

Commit b317eaa

Browse files
authored
fix(templates): ecommerce find my order access functionality to use email (#15736)
Changes the find my order access functionality over to use an email instead
1 parent 7f3c6c8 commit b317eaa

9 files changed

Lines changed: 204 additions & 28 deletions

File tree

packages/plugin-ecommerce/src/payments/adapters/stripe/confirmOrder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const confirmOrder: (props: Props) => NonNullable<PaymentAdapter>['confir
129129
message: 'Payment initiated successfully',
130130
orderID: order.id,
131131
transactionID: transaction.id,
132+
...(order.accessToken ? { accessToken: order.accessToken } : {}),
132133
}
133134
} catch (error) {
134135
payload.logger.error(error, 'Error initiating payment with Stripe')

templates/ecommerce/README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,24 @@ Basic access control is setup to limit access to various content based based on
119119
- `carts`: Customers can access their own saved cart, guest users can access any unclaimed cart by ID.
120120
- `addresses`: Customers can access their own addresses for record keeping.
121121
- `transactions`: Only admins can access these as they're meant for internal tracking.
122-
- `orders`: Only admins and users who own the orders can access these.
122+
- `orders`: Only admins and users who own the orders can access these. Guests require a valid `accessToken` (sent via email) along with the order's email to view order details.
123123

124124
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
125125

126126
## User accounts
127127

128+
Registered users can log in to view their order history, manage saved addresses, and track ongoing orders directly from their account dashboard.
129+
128130
## Guests
129131

132+
Guest checkout allows users to complete purchases without creating an account. When a guest places an order:
133+
134+
1. The order is associated with their email address
135+
2. A unique `accessToken` is generated for secure order lookup
136+
3. An order confirmation email is sent containing a secure link to view the order
137+
138+
To look up an order as a guest, users visit `/find-order`, enter their email and order ID, and receive an email with a secure access link. This prevents order enumeration attacks where malicious users could iterate through sequential order IDs to access other customers' order information.
139+
130140
## Layout Builder
131141

132142
Create unique page layouts for any type of content using a powerful layout builder. This template comes pre-configured with the following layout building blocks:
@@ -173,7 +183,19 @@ This template comes with SSR search features can easily be implemented into Next
173183

174184
Transactions are intended for keeping a record of any payment made, as such it will contain information regarding an order or billing address used or the payment method used and amount. Only admins can access transactions.
175185

176-
An order is created only once a transaction is successfully completed. This is a record that the user who completed the transaction has access so they can keep track of their history. Guests can also access their own orders by providing an order ID and the email associated with that order.
186+
An order is created only once a transaction is successfully completed. This is a record that the user who completed the transaction has access so they can keep track of their history.
187+
188+
### Guest Order Access
189+
190+
Guest users can securely access their orders through the `/find-order` page:
191+
192+
1. Guest enters their email address and order ID
193+
2. If the order exists and matches the email, an access link is sent to their email
194+
3. The link contains a secure `accessToken` that grants temporary access to view the order
195+
196+
This email verification flow prevents unauthorized access to order details. The `accessToken` is a unique UUID generated when the order is created and is required (along with the email) to view order details as a guest.
197+
198+
**Security note:** Order confirmation emails should include the order ID so guests can use the "Find Order" feature. The access token is only sent via the verification email to prevent enumeration attacks.
177199

178200
## Currencies
179201

templates/ecommerce/src/app/(app)/(account)/orders/[id]/page.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const dynamic = 'force-dynamic'
1919

2020
type PageProps = {
2121
params: Promise<{ id: string }>
22-
searchParams: Promise<{ email?: string }>
22+
searchParams: Promise<{ email?: string; accessToken?: string }>
2323
}
2424

2525
export default async function Order({ params, searchParams }: PageProps) {
@@ -28,7 +28,7 @@ export default async function Order({ params, searchParams }: PageProps) {
2828
const { user } = await payload.auth({ headers })
2929

3030
const { id } = await params
31-
const { email = '' } = await searchParams
31+
const { email = '', accessToken = '' } = await searchParams
3232

3333
let order: Order | null = null
3434

@@ -55,16 +55,22 @@ export default async function Order({ params, searchParams }: PageProps) {
5555
},
5656
},
5757
]
58-
: []),
59-
...(email
60-
? [
58+
: [
6159
{
62-
customerEmail: {
63-
equals: email,
60+
accessToken: {
61+
equals: accessToken,
6462
},
6563
},
66-
]
67-
: []),
64+
...(email
65+
? [
66+
{
67+
customerEmail: {
68+
equals: email,
69+
},
70+
},
71+
]
72+
: []),
73+
]),
6874
],
6975
},
7076
select: {
@@ -83,6 +89,7 @@ export default async function Order({ params, searchParams }: PageProps) {
8389
const canAccessAsGuest =
8490
!user &&
8591
email &&
92+
accessToken &&
8693
orderResult &&
8794
orderResult.customerEmail &&
8895
orderResult.customerEmail === email

templates/ecommerce/src/app/(app)/find-order/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default async function FindOrderPage() {
2020
}
2121

2222
export const metadata: Metadata = {
23-
description: 'Find your order with us using your email.',
23+
description: 'Find your order using your email and order ID.',
2424
openGraph: mergeOpenGraph({
2525
title: 'Find order',
2626
url: '/find-order',

templates/ecommerce/src/components/checkout/ConfirmOrder.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,26 @@ export const ConfirmOrder: React.FC = () => {
3232
},
3333
}).then((result) => {
3434
if (result && typeof result === 'object' && 'orderID' in result && result.orderID) {
35-
router.push(`/shop/order/${result.orderID}?email=${email}`)
35+
const accessToken = 'accessToken' in result ? (result.accessToken as string) : ''
36+
const queryParams = new URLSearchParams()
37+
38+
if (email) {
39+
queryParams.set('email', email)
40+
}
41+
if (accessToken) {
42+
queryParams.set('accessToken', accessToken)
43+
}
44+
45+
const queryString = queryParams.toString()
46+
router.push(`/orders/${result.orderID}${queryString ? `?${queryString}` : ''}`)
3647
}
3748
})
3849
}
3950
} else {
4051
// If no payment intent ID is found, redirect to the home
4152
router.push('/')
4253
}
43-
}, [cart, searchParams])
54+
}, [cart, confirmOrder, router, searchParams])
4455

4556
return (
4657
<div className="text-center w-full flex flex-col items-center justify-start gap-4">

templates/ecommerce/src/components/forms/CheckoutForm/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,19 @@ export const CheckoutForm: React.FC<Props> = ({
7575
'orderID' in confirmResult &&
7676
confirmResult.orderID
7777
) {
78-
const redirectUrl = `/orders/${confirmResult.orderID}${customerEmail ? `?email=${customerEmail}` : ''}`
78+
const accessToken =
79+
'accessToken' in confirmResult ? (confirmResult.accessToken as string) : ''
80+
const queryParams = new URLSearchParams()
81+
82+
if (customerEmail) {
83+
queryParams.set('email', customerEmail)
84+
}
85+
if (accessToken) {
86+
queryParams.set('accessToken', accessToken)
87+
}
88+
89+
const queryString = queryParams.toString()
90+
const redirectUrl = `/orders/${confirmResult.orderID}${queryString ? `?${queryString}` : ''}`
7991

8092
// Clear the cart after successful payment
8193
clearCart()

templates/ecommerce/src/components/forms/FindOrderForm/index.tsx

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { Button } from '@/components/ui/button'
66
import { Input } from '@/components/ui/input'
77
import { Label } from '@/components/ui/label'
88
import { useAuth } from '@/providers/Auth'
9-
import { useRouter } from 'next/navigation'
10-
import React, { Fragment, useCallback } from 'react'
9+
import React, { Fragment, useCallback, useState } from 'react'
1110
import { useForm } from 'react-hook-form'
11+
import { sendOrderAccessEmail } from './sendOrderAccessEmail'
1212

1313
type FormData = {
1414
email: string
@@ -20,8 +20,10 @@ type Props = {
2020
}
2121

2222
export const FindOrderForm: React.FC<Props> = ({ initialEmail }) => {
23-
const router = useRouter()
2423
const { user } = useAuth()
24+
const [isSubmitting, setIsSubmitting] = useState(false)
25+
const [submitError, setSubmitError] = useState<string | null>(null)
26+
const [success, setSuccess] = useState(false)
2527

2628
const {
2729
formState: { errors },
@@ -33,18 +35,46 @@ export const FindOrderForm: React.FC<Props> = ({ initialEmail }) => {
3335
},
3436
})
3537

36-
const onSubmit = useCallback(
37-
async (data: FormData) => {
38-
router.push(`/orders/${data.orderID}?email=${data.email}`)
39-
},
40-
[router],
41-
)
38+
const onSubmit = useCallback(async (data: FormData) => {
39+
setIsSubmitting(true)
40+
setSubmitError(null)
41+
42+
try {
43+
const result = await sendOrderAccessEmail({
44+
email: data.email,
45+
orderID: data.orderID,
46+
})
47+
48+
if (result.success) {
49+
setSuccess(true)
50+
} else {
51+
setSubmitError(result.error || 'Something went wrong. Please try again.')
52+
}
53+
} catch {
54+
setSubmitError('Something went wrong. Please try again.')
55+
} finally {
56+
setIsSubmitting(false)
57+
}
58+
}, [])
59+
60+
if (success) {
61+
return (
62+
<Fragment>
63+
<h1 className="text-xl mb-4">Check your email</h1>
64+
<div className="prose dark:prose-invert">
65+
<p>
66+
{`If an order exists with the provided email and order ID, we've sent you an email with a link to view your order details.`}
67+
</p>
68+
</div>
69+
</Fragment>
70+
)
71+
}
4272

4373
return (
4474
<Fragment>
4575
<h1 className="text-xl mb-4">Find my order</h1>
4676
<div className="prose dark:prose-invert mb-8">
47-
<p>{`Please enter your email and order ID below.`}</p>
77+
<p>{`Please enter your email and order ID below. We'll send you a link to view your order.`}</p>
4878
</div>
4979
<form className="max-w-lg flex flex-col gap-8" onSubmit={handleSubmit(onSubmit)}>
5080
<FormItem>
@@ -65,14 +95,15 @@ export const FindOrderForm: React.FC<Props> = ({ initialEmail }) => {
6595
<Input
6696
id="orderID"
6797
{...register('orderID', {
68-
required: 'Order ID is required. You can find this in your email.',
98+
required: 'Order ID is required.',
6999
})}
70100
type="text"
71101
/>
72102
{errors.orderID && <FormError message={errors.orderID.message} />}
73103
</FormItem>
74-
<Button type="submit" className="self-start" variant="default">
75-
Find my order
104+
{submitError && <FormError message={submitError} />}
105+
<Button type="submit" className="self-start" variant="default" disabled={isSubmitting}>
106+
{isSubmitting ? 'Sending...' : 'Find order'}
76107
</Button>
77108
</form>
78109
</Fragment>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use server'
2+
3+
import configPromise from '@payload-config'
4+
import { getPayload } from 'payload'
5+
import { getServerSideURL } from '@/utilities/getURL'
6+
7+
type SendOrderAccessEmailArgs = {
8+
email: string
9+
orderID: string
10+
}
11+
12+
type SendOrderAccessEmailResult = {
13+
success: boolean
14+
error?: string
15+
}
16+
17+
export async function sendOrderAccessEmail({
18+
email,
19+
orderID,
20+
}: SendOrderAccessEmailArgs): Promise<SendOrderAccessEmailResult> {
21+
const payload = await getPayload({ config: configPromise })
22+
23+
try {
24+
const { docs: orders } = await payload.find({
25+
collection: 'orders',
26+
where: {
27+
and: [{ id: { equals: orderID } }, { customerEmail: { equals: email } }],
28+
},
29+
limit: 1,
30+
depth: 0,
31+
})
32+
33+
const order = orders[0]
34+
35+
if (!order || !order.accessToken) {
36+
return { success: true }
37+
}
38+
39+
const serverURL = getServerSideURL()
40+
const orderURL = `${serverURL}/orders/${order.id}?email=${encodeURIComponent(email)}&accessToken=${order.accessToken}`
41+
42+
const emailBody = `
43+
<h1>View Your Order</h1>
44+
<p>Click the link below to view your order details:</p>
45+
<p><a href="${orderURL}">View Order #${order.id}</a></p>
46+
<p>Or copy and paste this URL into your browser:</p>
47+
<p>${orderURL}</p>
48+
<p>This link will give you access to view your order details.</p>
49+
`
50+
51+
console.log('[sendOrderAccessEmail] Email body:', emailBody)
52+
53+
await payload.sendEmail({
54+
to: email,
55+
subject: `Access your order #${order.id}`,
56+
html: emailBody,
57+
})
58+
59+
return { success: true }
60+
} catch (err) {
61+
payload.logger.error({ msg: 'Failed to send order access email', err })
62+
return { success: true }
63+
}
64+
}

templates/ecommerce/src/plugins/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,34 @@ export const plugins: Plugin[] = [
7676
customers: {
7777
slug: 'users',
7878
},
79+
orders: {
80+
ordersCollectionOverride: ({ defaultCollection }) => ({
81+
...defaultCollection,
82+
fields: [
83+
...defaultCollection.fields,
84+
{
85+
name: 'accessToken',
86+
type: 'text',
87+
unique: true,
88+
index: true,
89+
admin: {
90+
position: 'sidebar',
91+
readOnly: true,
92+
},
93+
hooks: {
94+
beforeValidate: [
95+
({ value, operation }) => {
96+
if (operation === 'create' || !value) {
97+
return crypto.randomUUID()
98+
}
99+
return value
100+
},
101+
],
102+
},
103+
},
104+
],
105+
}),
106+
},
79107
payments: {
80108
paymentMethods: [
81109
stripeAdapter({

0 commit comments

Comments
 (0)