Skip to content

Commit fc84210

Browse files
feat(core): enable recaptcha in forms (#2896)
* added recaptcha v3 * update recaptcha package and make visible on forms * linting * make recaptcha optional for dynamic-form * remove comments * remove comments * remove recaptcha provider since it wasn't used * make recaptcha required if enabled and use getValue to retrieve token * add types for react-google-recaptcha * fix linting * fix linting * fix linting * fix linting * refactor(recaptcha): validate in form actions and separate parse from validate - Add validateRecaptchaToken() to validate reCAPTCHA in form actions and return formErrors so Conform displays them (contact, review, register). - Add getRecaptchaFromForm() to parse site key and token from FormData. - Make validateRecaptchaToken() a pure function (siteKey, token, message) so validation is separate from form parsing and easier to test/reuse. - Contact, review, and register actions: parse via getRecaptchaFromForm(), then validate via validateRecaptchaToken(), and pass token to mutations. Made-with: Cursor * fix linting * remove client side logic and error handling * rename validat function for recaptha token to assertRecaptchaTokenPresent * remove unused recaptcha settings in graphql * fix linting * remove client side logic and validation for recaptcha * remove unused properties from RecaptchaSettings interface * remove unnecessary DynamicFormInner wrapper component Made-with: Cursor * remove unused failedLoginLockoutDurationSeconds and isEnabledOnCheckout from getReCaptchaSettings Made-with: Cursor * add changeset * chore: add tests for recaptcha --------- Co-authored-by: Jorge Moya <jorge.moya@bigcommerce.com>
1 parent 00496f5 commit fc84210

21 files changed

Lines changed: 639 additions & 447 deletions

File tree

.changeset/lazy-rivers-stare.md

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Add reCAPTCHA v2 support to storefront forms. The reCAPTCHA widget is rendered on the registration, contact, and product review forms when enabled in the BigCommerce admin. All validation and error handling is performed server-side in the corresponding form actions. The token is read from the native `g-recaptcha-response` field that the widget injects into the form, eliminating the need for manual token extraction on the client.
6+
7+
## Migration steps
8+
9+
### Step 1: Install dependencies
10+
11+
Add `react-google-recaptcha` and its type definitions:
12+
13+
```bash
14+
pnpm add react-google-recaptcha
15+
pnpm add -D @types/react-google-recaptcha
16+
```
17+
18+
### Step 2: Add the reCAPTCHA server library
19+
20+
Create `core/lib/recaptcha/constants.ts`:
21+
22+
```ts
23+
export interface ReCaptchaSettings {
24+
isEnabledOnStorefront: boolean;
25+
siteKey: string;
26+
}
27+
28+
export const RECAPTCHA_TOKEN_FORM_KEY = "g-recaptcha-response";
29+
```
30+
31+
Create `core/lib/recaptcha.ts` with the server-side helpers for fetching reCAPTCHA settings, extracting the token from form data, and asserting the token is present. See the file in this release for the full implementation.
32+
33+
### Step 3: Add reCAPTCHA translation strings
34+
35+
Update `core/messages/en.json` to add the `recaptchaRequired` message in each form namespace:
36+
37+
```diff
38+
"Auth": {
39+
"Register": {
40+
+ "recaptchaRequired": "Please complete the reCAPTCHA verification.",
41+
```
42+
43+
```diff
44+
"Product": {
45+
"Reviews": {
46+
"Form": {
47+
+ "recaptchaRequired": "Please complete the reCAPTCHA verification.",
48+
```
49+
50+
```diff
51+
"WebPages": {
52+
"ContactUs": {
53+
"Form": {
54+
+ "recaptchaRequired": "Please complete the reCAPTCHA verification.",
55+
```
56+
57+
```diff
58+
"Form": {
59+
+ "recaptchaRequired": "Please complete the reCAPTCHA verification.",
60+
```
61+
62+
### Step 4: Update GraphQL mutations to accept reCAPTCHA token
63+
64+
Update `core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts`:
65+
66+
```diff
67+
+ import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha';
68+
...
69+
const RegisterCustomerMutation = graphql(`
70+
- mutation RegisterCustomerMutation($input: RegisterCustomerInput!) {
71+
+ mutation RegisterCustomerMutation(
72+
+ $input: RegisterCustomerInput!
73+
+ $reCaptchaV2: ReCaptchaV2Input
74+
+ ) {
75+
customer {
76+
- registerCustomer(input: $input) {
77+
+ registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) {
78+
```
79+
80+
Update `core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts`:
81+
82+
```diff
83+
+ import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha';
84+
...
85+
const AddProductReviewMutation = graphql(`
86+
- mutation AddProductReviewMutation($input: AddProductReviewInput!) {
87+
+ mutation AddProductReviewMutation(
88+
+ $input: AddProductReviewInput!
89+
+ $reCaptchaV2: ReCaptchaV2Input
90+
+ ) {
91+
catalog {
92+
- addProductReview(input: $input) {
93+
+ addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) {
94+
```
95+
96+
Update `core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts`:
97+
98+
```diff
99+
+ import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha';
100+
...
101+
const SubmitContactUsMutation = graphql(`
102+
- mutation SubmitContactUsMutation($input: SubmitContactUsInput!) {
103+
- submitContactUs(input: $input) {
104+
+ mutation SubmitContactUsMutation($input: SubmitContactUsInput!, $reCaptchaV2: ReCaptchaV2Input) {
105+
+ submitContactUs(input: $input, reCaptchaV2: $reCaptchaV2) {
106+
```
107+
108+
### Step 5: Add server-side reCAPTCHA validation to form actions
109+
110+
In each of the three server actions above, add the validation block after the `parseWithZod` check and pass the token to the GraphQL mutation. For example in `register-customer.ts`:
111+
112+
```diff
113+
+ const { siteKey, token } = await getRecaptchaFromForm(formData);
114+
+ const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired'));
115+
+
116+
+ if (!recaptchaValidation.success) {
117+
+ return {
118+
+ lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }),
119+
+ };
120+
+ }
121+
...
122+
const response = await client.fetch({
123+
document: RegisterCustomerMutation,
124+
variables: {
125+
input,
126+
+ reCaptchaV2:
127+
+ recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined,
128+
},
129+
```
130+
131+
Apply the same pattern to `submit-review.ts` and `submit-contact-form.ts`.
132+
133+
### Step 6: Pass `recaptchaSiteKey` to form components
134+
135+
Fetch the site key in each page and pass it down through the component tree.
136+
137+
Update `core/app/[locale]/(default)/(auth)/register/page.tsx`:
138+
139+
```diff
140+
+ import { getRecaptchaSiteKey } from '~/lib/recaptcha';
141+
...
142+
+ const recaptchaSiteKey = await getRecaptchaSiteKey();
143+
...
144+
<DynamicFormSection
145+
+ recaptchaSiteKey={recaptchaSiteKey}
146+
```
147+
148+
Update `core/app/[locale]/(default)/product/[slug]/page.tsx`:
149+
150+
```diff
151+
+ import { getRecaptchaSiteKey } from '~/lib/recaptcha';
152+
...
153+
- const { product: baseProduct, settings } = await getProduct(productId, customerAccessToken);
154+
+ const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([
155+
+ getProduct(productId, customerAccessToken),
156+
+ getRecaptchaSiteKey(),
157+
+ ]);
158+
...
159+
<ProductDetail
160+
+ recaptchaSiteKey={recaptchaSiteKey}
161+
...
162+
<Reviews
163+
+ recaptchaSiteKey={recaptchaSiteKey}
164+
```
165+
166+
Update `core/app/[locale]/(default)/webpages/[id]/contact/page.tsx`:
167+
168+
```diff
169+
+ import { getRecaptchaSiteKey } from '~/lib/recaptcha';
170+
...
171+
+ const recaptchaSiteKey = await getRecaptchaSiteKey();
172+
...
173+
<DynamicForm
174+
+ recaptchaSiteKey={recaptchaSiteKey}
175+
```
176+
177+
### Step 7: Render the reCAPTCHA widget in form components
178+
179+
Update `core/vibes/soul/form/dynamic-form/index.tsx`:
180+
181+
```diff
182+
+ import RecaptchaWidget from 'react-google-recaptcha';
183+
...
184+
export interface DynamicFormProps<F extends Field> {
185+
+ recaptchaSiteKey?: string;
186+
}
187+
...
188+
+ {recaptchaSiteKey ? <RecaptchaWidget sitekey={recaptchaSiteKey} /> : null}
189+
```
190+
191+
Update `core/vibes/soul/sections/reviews/review-form.tsx`:
192+
193+
```diff
194+
+ import RecaptchaWidget from 'react-google-recaptcha';
195+
...
196+
interface Props {
197+
+ recaptchaSiteKey?: string;
198+
}
199+
...
200+
+ {recaptchaSiteKey ? (
201+
+ <div>
202+
+ <RecaptchaWidget sitekey={recaptchaSiteKey} />
203+
+ </div>
204+
+ ) : null}
205+
```
206+
207+
### Step 8: Thread `recaptchaSiteKey` through intermediate components
208+
209+
Add the `recaptchaSiteKey?: string` prop and pass it through in:
210+
211+
- `core/vibes/soul/sections/dynamic-form-section/index.tsx`
212+
- `core/vibes/soul/sections/product-detail/index.tsx`
213+
- `core/vibes/soul/sections/reviews/index.tsx`
214+
- `core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx`
215+
216+
Each of these accepts the prop and forwards it to the form component that renders the widget.

core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import { graphql, VariablesOf } from '~/client/graphql';
1414
import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils';
1515
import { redirect } from '~/i18n/routing';
1616
import { getCartId } from '~/lib/cart';
17+
import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha';
1718

1819
import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './prefixes';
1920

2021
const RegisterCustomerMutation = graphql(`
21-
mutation RegisterCustomerMutation($input: RegisterCustomerInput!) {
22+
mutation RegisterCustomerMutation(
23+
$input: RegisterCustomerInput!
24+
$reCaptchaV2: ReCaptchaV2Input
25+
) {
2226
customer {
23-
registerCustomer(input: $input) {
27+
registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) {
2428
customer {
2529
firstName
2630
lastName
@@ -356,12 +360,23 @@ export async function registerCustomer<F extends Field>(
356360
};
357361
}
358362

363+
const { siteKey, token } = await getRecaptchaFromForm(formData);
364+
const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired'));
365+
366+
if (!recaptchaValidation.success) {
367+
return {
368+
lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }),
369+
};
370+
}
371+
359372
try {
360373
const input = parseRegisterCustomerInput(submission.value, fields);
361374
const response = await client.fetch({
362375
document: RegisterCustomerMutation,
363376
variables: {
364377
input,
378+
reCaptchaV2:
379+
recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined,
365380
},
366381
fetchOptions: { cache: 'no-store' },
367382
});

core/app/[locale]/(default)/(auth)/register/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
REGISTER_CUSTOMER_FORM_LAYOUT,
1414
transformFieldsToLayout,
1515
} from '~/data-transformers/form-field-transformer/utils';
16+
import { getRecaptchaSiteKey } from '~/lib/recaptcha';
1617
import { exists } from '~/lib/utils';
1718

1819
import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './_actions/prefixes';
@@ -63,6 +64,8 @@ export default async function Register({ params }: Props) {
6364
const { addressFields, customerFields, countries, passwordComplexitySettings } =
6465
registerCustomerData;
6566

67+
const recaptchaSiteKey = await getRecaptchaSiteKey();
68+
6669
const fields = transformFieldsToLayout(
6770
[
6871
...addressFields.map((field) => {
@@ -154,6 +157,7 @@ export default async function Register({ params }: Props) {
154157
}}
155158
fields={fields}
156159
passwordComplexity={passwordComplexitySettings}
160+
recaptchaSiteKey={recaptchaSiteKey}
157161
submitLabel={t('cta')}
158162
title={t('heading')}
159163
/>

core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ import { schema } from '@/vibes/soul/sections/reviews/schema';
99
import { getSessionCustomerAccessToken } from '~/auth';
1010
import { client } from '~/client';
1111
import { graphql } from '~/client/graphql';
12+
import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha';
1213

1314
const AddProductReviewMutation = graphql(`
14-
mutation AddProductReviewMutation($input: AddProductReviewInput!) {
15+
mutation AddProductReviewMutation(
16+
$input: AddProductReviewInput!
17+
$reCaptchaV2: ReCaptchaV2Input
18+
) {
1519
catalog {
16-
addProductReview(input: $input) {
20+
addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) {
1721
__typename
1822
errors {
1923
__typename
@@ -38,6 +42,16 @@ export async function submitReview(
3842
return { ...prevState, lastResult: submission.reply() };
3943
}
4044

45+
const { siteKey, token } = await getRecaptchaFromForm(payload);
46+
const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired'));
47+
48+
if (!recaptchaValidation.success) {
49+
return {
50+
...prevState,
51+
lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }),
52+
};
53+
}
54+
4155
const { productEntityId, ...input } = submission.value;
4256

4357
try {
@@ -52,6 +66,8 @@ export async function submitReview(
5266
},
5367
productEntityId,
5468
},
69+
reCaptchaV2:
70+
recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined,
5571
},
5672
});
5773

core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,15 @@ interface Props {
8282
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
8383
}>;
8484
streamableProduct: Streamable<Awaited<ReturnType<typeof getStreamableProduct>>>;
85+
recaptchaSiteKey?: string;
8586
}
8687

8788
export const Reviews = async ({
8889
productId,
8990
searchParams,
9091
streamableProduct,
9192
streamableImages,
93+
recaptchaSiteKey,
9294
}: Props) => {
9395
const t = await getTranslations('Product.Reviews');
9496

@@ -189,6 +191,7 @@ export const Reviews = async ({
189191
paginationInfo={streamablePaginationInfo}
190192
previousLabel={t('previous')}
191193
productId={productId}
194+
recaptchaSiteKey={recaptchaSiteKey}
192195
reviews={streamableReviews}
193196
reviewsLabel={t('title')}
194197
streamableImages={streamableImages}

core/app/[locale]/(default)/product/[slug]/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { pricesTransformer } from '~/data-transformers/prices-transformer';
1212
import { productCardTransformer } from '~/data-transformers/product-card-transformer';
1313
import { productOptionsTransformer } from '~/data-transformers/product-options-transformer';
1414
import { getPreferredCurrencyCode } from '~/lib/currency';
15+
import { getRecaptchaSiteKey } from '~/lib/recaptcha';
1516
import { getMetadataAlternates } from '~/lib/seo/canonical';
1617

1718
import { addToCart } from './_actions/add-to-cart';
@@ -76,7 +77,10 @@ export default async function Product({ params, searchParams }: Props) {
7677

7778
const productId = Number(slug);
7879

79-
const { product: baseProduct, settings } = await getProduct(productId, customerAccessToken);
80+
const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([
81+
getProduct(productId, customerAccessToken),
82+
getRecaptchaSiteKey(),
83+
]);
8084

8185
const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating);
8286
const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating);
@@ -581,6 +585,7 @@ export default async function Product({ params, searchParams }: Props) {
581585
backorderDisplayData: streamableBackorderDisplayData,
582586
}}
583587
quantityLabel={t('ProductDetails.quantity')}
588+
recaptchaSiteKey={recaptchaSiteKey}
584589
reviewFormAction={submitReview}
585590
thumbnailLabel={t('ProductDetails.thumbnail')}
586591
user={streamableUser}
@@ -602,6 +607,7 @@ export default async function Product({ params, searchParams }: Props) {
602607
<div id="reviews">
603608
<Reviews
604609
productId={productId}
610+
recaptchaSiteKey={recaptchaSiteKey}
605611
searchParams={searchParams}
606612
streamableImages={streamableImages}
607613
streamableProduct={streamableProduct}

0 commit comments

Comments
 (0)