From 00496f50e98538a2dbe25208d04819259e45b5a6 Mon Sep 17 00:00:00 2001 From: Matthew Volk Date: Fri, 13 Mar 2026 14:28:14 -0500 Subject: [PATCH 1/6] docs: streamline release process in CONTRIBUTING.md (#2923) Simplify Stage 2 of the release process by including the @bigcommerce/catalyst-makeswift changeset directly in the sync merge commit instead of requiring a separate bump-version branch and PR. Also update the tags section with explicit commands for both packages. --- CONTRIBUTING.md | 39 ++++++++++++++++----------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29c9e04c6f..f39d598047 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,40 +103,33 @@ This ensures `integrations/makeswift` remains a faithful mirror of `canary` whil #### Stage 2: Sync and Release `integrations/makeswift` -2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)" +2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)", with one addition: **include a changeset for `@bigcommerce/catalyst-makeswift` in the sync merge commit** rather than opening a separate PR for it afterwards. -3. **IMPORTANT**: After step 6, you'll need to open another PR into `integrations/makeswift` - - Ensure a local `integrations/makeswift` branch exists and is up to date (`git checkout -B integrations/makeswift origin/integrations/makeswift`) - - Run `git fetch origin` and create a new branch from `integrations/makeswift` (`git checkout -B bump-version origin/integrations/makeswift`) - - From this new `bump-version` branch, run `pnpm changeset` - - Select `@bigcommerce/catalyst-makeswift` - - For choosing between a `patch/minor/major` bump, you should copy the bump from Stage 1. (e.g., if `@bigcommerce/catalyst-core` went from `1.1.0` to `1.2.0`, choose `minor`) - - Example changeset: + - Match the bump type from Stage 1 (e.g., if `@bigcommerce/catalyst-core` went from `1.4.2` to `1.5.0`, use `minor`) + - Create a changeset file in `.changeset/` (e.g., `.changeset/sync-canary-1-5-0.md`): ``` --- - "@bigcommerce/catalyst-makeswift": patch + "@bigcommerce/catalyst-makeswift": minor --- - Pulls in changes from the `@bigcommerce/catalyst-core@1.4.1` patch. + Pulls in changes from the `@bigcommerce/catalyst-core@1.5.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob//core/CHANGELOG.md#150). ``` - - Commit the generated changeset file and open a PR to merge this branch into `integrations/makeswift` - - Once merged, you can proceed to the next step + - Replace `` with the merge commit SHA of the Version Packages PR on `canary` so the link remains stable + - Amend this changeset into the merge commit alongside any other sync changes (changeset cleanup, `core/package.json` and `core/CHANGELOG.md` fixes, etc.) -4. Merge the **Version Packages (`integrations/makeswift`)** PR: Changesets will open another PR (similar to Stage 1) bumping `@bigcommerce/catalyst-makeswift`. Merge it following the same process. This cuts a new release of the Makeswift variant. +3. Merge the **Version Packages (`integrations/makeswift`)** PR: After the sync lands, Changesets will open a PR (similar to Stage 1) bumping `@bigcommerce/catalyst-makeswift`. Merge it following the same process. This cuts a new release of the Makeswift variant. -5. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. If needed, update `latest` tags in GitHub manually. +4. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. Update `latest` tags to point to the new releases: -- Push manually: - ``` - git checkout canary - # Make sure you have the latest code - git fetch origin - git pull - git tag @bigcommerce/catalyst-core@latest -f - git push origin @bigcommerce/catalyst-core@latest -f - ``` + ```bash + git fetch origin --tags + git tag @bigcommerce/catalyst-core@latest @bigcommerce/catalyst-core@ -f + git tag @bigcommerce/catalyst-makeswift@latest @bigcommerce/catalyst-makeswift@ -f + git push origin @bigcommerce/catalyst-core@latest -f + git push origin @bigcommerce/catalyst-makeswift@latest -f + ``` ### Additional Notes From fc84210ab8562ce24320d3d0e3284ed318a4cbce Mon Sep 17 00:00:00 2001 From: James Q Quick Date: Fri, 13 Mar 2026 17:46:34 -0500 Subject: [PATCH 2/6] 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 --- .changeset/lazy-rivers-stare.md | 216 +++++++ .../register/_actions/register-customer.ts | 19 +- .../(default)/(auth)/register/page.tsx | 4 + .../product/[slug]/_actions/submit-review.ts | 20 +- .../product/[slug]/_components/reviews.tsx | 3 + .../(default)/product/[slug]/page.tsx | 8 +- .../contact/_actions/submit-contact-form.ts | 16 +- .../(default)/webpages/[id]/contact/page.tsx | 4 + core/lib/recaptcha.ts | 79 +++ core/lib/recaptcha/constants.ts | 6 + core/messages/en.json | 6 +- core/package.json | 2 + core/tests/ui/e2e/auth/register.spec.ts | 59 ++ core/tests/ui/e2e/reviews.spec.ts | 48 ++ core/tests/ui/e2e/webpages.spec.ts | 40 ++ core/vibes/soul/form/dynamic-form/index.tsx | 4 + .../sections/dynamic-form-section/index.tsx | 3 + .../soul/sections/product-detail/index.tsx | 3 + core/vibes/soul/sections/reviews/index.tsx | 7 + .../soul/sections/reviews/review-form.tsx | 8 + pnpm-lock.yaml | 531 +++--------------- 21 files changed, 639 insertions(+), 447 deletions(-) create mode 100644 .changeset/lazy-rivers-stare.md create mode 100644 core/lib/recaptcha.ts create mode 100644 core/lib/recaptcha/constants.ts diff --git a/.changeset/lazy-rivers-stare.md b/.changeset/lazy-rivers-stare.md new file mode 100644 index 0000000000..4e8a253541 --- /dev/null +++ b/.changeset/lazy-rivers-stare.md @@ -0,0 +1,216 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +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. + +## Migration steps + +### Step 1: Install dependencies + +Add `react-google-recaptcha` and its type definitions: + +```bash +pnpm add react-google-recaptcha +pnpm add -D @types/react-google-recaptcha +``` + +### Step 2: Add the reCAPTCHA server library + +Create `core/lib/recaptcha/constants.ts`: + +```ts +export interface ReCaptchaSettings { + isEnabledOnStorefront: boolean; + siteKey: string; +} + +export const RECAPTCHA_TOKEN_FORM_KEY = "g-recaptcha-response"; +``` + +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. + +### Step 3: Add reCAPTCHA translation strings + +Update `core/messages/en.json` to add the `recaptchaRequired` message in each form namespace: + +```diff + "Auth": { + "Register": { ++ "recaptchaRequired": "Please complete the reCAPTCHA verification.", +``` + +```diff + "Product": { + "Reviews": { + "Form": { ++ "recaptchaRequired": "Please complete the reCAPTCHA verification.", +``` + +```diff + "WebPages": { + "ContactUs": { + "Form": { ++ "recaptchaRequired": "Please complete the reCAPTCHA verification.", +``` + +```diff + "Form": { ++ "recaptchaRequired": "Please complete the reCAPTCHA verification.", +``` + +### Step 4: Update GraphQL mutations to accept reCAPTCHA token + +Update `core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts`: + +```diff ++ import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; + ... + const RegisterCustomerMutation = graphql(` +- mutation RegisterCustomerMutation($input: RegisterCustomerInput!) { ++ mutation RegisterCustomerMutation( ++ $input: RegisterCustomerInput! ++ $reCaptchaV2: ReCaptchaV2Input ++ ) { + customer { +- registerCustomer(input: $input) { ++ registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { +``` + +Update `core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts`: + +```diff ++ import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; + ... + const AddProductReviewMutation = graphql(` +- mutation AddProductReviewMutation($input: AddProductReviewInput!) { ++ mutation AddProductReviewMutation( ++ $input: AddProductReviewInput! ++ $reCaptchaV2: ReCaptchaV2Input ++ ) { + catalog { +- addProductReview(input: $input) { ++ addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) { +``` + +Update `core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts`: + +```diff ++ import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; + ... + const SubmitContactUsMutation = graphql(` +- mutation SubmitContactUsMutation($input: SubmitContactUsInput!) { +- submitContactUs(input: $input) { ++ mutation SubmitContactUsMutation($input: SubmitContactUsInput!, $reCaptchaV2: ReCaptchaV2Input) { ++ submitContactUs(input: $input, reCaptchaV2: $reCaptchaV2) { +``` + +### Step 5: Add server-side reCAPTCHA validation to form actions + +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`: + +```diff ++ const { siteKey, token } = await getRecaptchaFromForm(formData); ++ const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); ++ ++ if (!recaptchaValidation.success) { ++ return { ++ lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), ++ }; ++ } + ... + const response = await client.fetch({ + document: RegisterCustomerMutation, + variables: { + input, ++ reCaptchaV2: ++ recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined, + }, +``` + +Apply the same pattern to `submit-review.ts` and `submit-contact-form.ts`. + +### Step 6: Pass `recaptchaSiteKey` to form components + +Fetch the site key in each page and pass it down through the component tree. + +Update `core/app/[locale]/(default)/(auth)/register/page.tsx`: + +```diff ++ import { getRecaptchaSiteKey } from '~/lib/recaptcha'; + ... ++ const recaptchaSiteKey = await getRecaptchaSiteKey(); + ... + { ++ recaptchaSiteKey?: string; + } + ... ++ {recaptchaSiteKey ? : null} +``` + +Update `core/vibes/soul/sections/reviews/review-form.tsx`: + +```diff ++ import RecaptchaWidget from 'react-google-recaptcha'; + ... + interface Props { ++ recaptchaSiteKey?: string; + } + ... ++ {recaptchaSiteKey ? ( ++
++ ++
++ ) : null} +``` + +### Step 8: Thread `recaptchaSiteKey` through intermediate components + +Add the `recaptchaSiteKey?: string` prop and pass it through in: + +- `core/vibes/soul/sections/dynamic-form-section/index.tsx` +- `core/vibes/soul/sections/product-detail/index.tsx` +- `core/vibes/soul/sections/reviews/index.tsx` +- `core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx` + +Each of these accepts the prop and forwards it to the form component that renders the widget. diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts index cd7259ab16..29c5fee3ed 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -14,13 +14,17 @@ import { graphql, VariablesOf } from '~/client/graphql'; import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; import { redirect } from '~/i18n/routing'; import { getCartId } from '~/lib/cart'; +import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './prefixes'; const RegisterCustomerMutation = graphql(` - mutation RegisterCustomerMutation($input: RegisterCustomerInput!) { + mutation RegisterCustomerMutation( + $input: RegisterCustomerInput! + $reCaptchaV2: ReCaptchaV2Input + ) { customer { - registerCustomer(input: $input) { + registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { customer { firstName lastName @@ -356,12 +360,23 @@ export async function registerCustomer( }; } + const { siteKey, token } = await getRecaptchaFromForm(formData); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } + try { const input = parseRegisterCustomerInput(submission.value, fields); const response = await client.fetch({ document: RegisterCustomerMutation, variables: { input, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined, }, fetchOptions: { cache: 'no-store' }, }); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index bf8ebf1357..23004a7b8f 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -13,6 +13,7 @@ import { REGISTER_CUSTOMER_FORM_LAYOUT, transformFieldsToLayout, } from '~/data-transformers/form-field-transformer/utils'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { exists } from '~/lib/utils'; import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './_actions/prefixes'; @@ -63,6 +64,8 @@ export default async function Register({ params }: Props) { const { addressFields, customerFields, countries, passwordComplexitySettings } = registerCustomerData; + const recaptchaSiteKey = await getRecaptchaSiteKey(); + const fields = transformFieldsToLayout( [ ...addressFields.map((field) => { @@ -154,6 +157,7 @@ export default async function Register({ params }: Props) { }} fields={fields} passwordComplexity={passwordComplexitySettings} + recaptchaSiteKey={recaptchaSiteKey} submitLabel={t('cta')} title={t('heading')} /> diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts b/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts index e37c9aa5d9..385bd3813f 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts +++ b/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts @@ -9,11 +9,15 @@ import { schema } from '@/vibes/soul/sections/reviews/schema'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; +import { assertRecaptchaTokenPresent, getRecaptchaFromForm } from '~/lib/recaptcha'; const AddProductReviewMutation = graphql(` - mutation AddProductReviewMutation($input: AddProductReviewInput!) { + mutation AddProductReviewMutation( + $input: AddProductReviewInput! + $reCaptchaV2: ReCaptchaV2Input + ) { catalog { - addProductReview(input: $input) { + addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) { __typename errors { __typename @@ -38,6 +42,16 @@ export async function submitReview( return { ...prevState, lastResult: submission.reply() }; } + const { siteKey, token } = await getRecaptchaFromForm(payload); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } + const { productEntityId, ...input } = submission.value; try { @@ -52,6 +66,8 @@ export async function submitReview( }, productEntityId, }, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined, }, }); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 6429de74be..47a533d5c9 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -82,6 +82,7 @@ interface Props { pageInfo?: { hasNextPage: boolean; endCursor: string | null }; }>; streamableProduct: Streamable>>; + recaptchaSiteKey?: string; } export const Reviews = async ({ @@ -89,6 +90,7 @@ export const Reviews = async ({ searchParams, streamableProduct, streamableImages, + recaptchaSiteKey, }: Props) => { const t = await getTranslations('Product.Reviews'); @@ -189,6 +191,7 @@ export const Reviews = async ({ paginationInfo={streamablePaginationInfo} previousLabel={t('previous')} productId={productId} + recaptchaSiteKey={recaptchaSiteKey} reviews={streamableReviews} reviewsLabel={t('title')} streamableImages={streamableImages} diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index c1e8a96bf7..8d2001ee8c 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -12,6 +12,7 @@ import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { productOptionsTransformer } from '~/data-transformers/product-options-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; @@ -76,7 +77,10 @@ export default async function Product({ params, searchParams }: Props) { const productId = Number(slug); - const { product: baseProduct, settings } = await getProduct(productId, customerAccessToken); + const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([ + getProduct(productId, customerAccessToken), + getRecaptchaSiteKey(), + ]); const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating); const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); @@ -581,6 +585,7 @@ export default async function Product({ params, searchParams }: Props) { backorderDisplayData: streamableBackorderDisplayData, }} quantityLabel={t('ProductDetails.quantity')} + recaptchaSiteKey={recaptchaSiteKey} reviewFormAction={submitReview} thumbnailLabel={t('ProductDetails.thumbnail')} user={streamableUser} @@ -602,6 +607,7 @@ export default async function Product({ params, searchParams }: Props) {
( }; } + const { siteKey, token } = await getRecaptchaFromForm(formData); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } + try { const input = parseContactFormInput(submission.value); const response = await client.fetch({ document: SubmitContactUsMutation, variables: { input, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined, }, fetchOptions: { cache: 'no-store' }, }); diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index b8d1f2e759..2c8896c405 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -12,6 +12,7 @@ import { breadcrumbsTransformer, truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPage, WebPageContent } from '../_components/web-page'; @@ -195,6 +196,8 @@ export default async function ContactPage({ params, searchParams }: Props) { ); } + const recaptchaSiteKey = await getRecaptchaSiteKey(); + return ( getWebPageBreadcrumbs(id))} @@ -204,6 +207,7 @@ export default async function ContactPage({ params, searchParams }: Props) {
diff --git a/core/lib/recaptcha.ts b/core/lib/recaptcha.ts new file mode 100644 index 0000000000..77d5aa1518 --- /dev/null +++ b/core/lib/recaptcha.ts @@ -0,0 +1,79 @@ +import 'server-only'; + +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +import { RECAPTCHA_TOKEN_FORM_KEY, type ReCaptchaSettings } from './recaptcha/constants'; + +export { RECAPTCHA_TOKEN_FORM_KEY } from './recaptcha/constants'; +export type { ReCaptchaSettings } from './recaptcha/constants'; + +export const ReCaptchaSettingsQuery = graphql(` + query ReCaptchaSettingsQuery { + site { + settings { + reCaptcha { + isEnabledOnStorefront + siteKey + } + } + } + } +`); + +export const getReCaptchaSettings = cache(async (): Promise => { + const { data } = await client.fetch({ + document: ReCaptchaSettingsQuery, + fetchOptions: { next: { revalidate } }, + }); + + const reCaptcha = data.site.settings?.reCaptcha; + + if (!reCaptcha?.siteKey) { + return null; + } + + return { + isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront, + siteKey: reCaptcha.siteKey, + }; +}); + +export const getRecaptchaSiteKey = cache(async (): Promise => { + const settings = await getReCaptchaSettings(); + + return settings?.isEnabledOnStorefront === true && settings.siteKey + ? settings.siteKey + : undefined; +}); + +export async function getRecaptchaFromForm( + formData: FormData, +): Promise<{ siteKey: string | undefined; token: string }> { + const siteKey = await getRecaptchaSiteKey(); + const raw = formData.get(RECAPTCHA_TOKEN_FORM_KEY); + const token = typeof raw === 'string' ? raw : ''; + + return { siteKey, token }; +} + +export function assertRecaptchaTokenPresent( + siteKey: string | undefined, + token: string, + recaptchaRequiredMessage: string, +): { success: true; token: string | undefined } | { success: false; formErrors: [string] } { + if (!siteKey) { + return { success: true, token: undefined }; + } + + const tokenValue = token.trim(); + + if (!tokenValue) { + return { success: false, formErrors: [recaptchaRequiredMessage] }; + } + + return { success: true, token: tokenValue }; +} diff --git a/core/lib/recaptcha/constants.ts b/core/lib/recaptcha/constants.ts new file mode 100644 index 0000000000..b9c5045376 --- /dev/null +++ b/core/lib/recaptcha/constants.ts @@ -0,0 +1,6 @@ +export interface ReCaptchaSettings { + isEnabledOnStorefront: boolean; + siteKey: string; +} + +export const RECAPTCHA_TOKEN_FORM_KEY = 'g-recaptcha-response'; diff --git a/core/messages/en.json b/core/messages/en.json index 1f09a50589..7fa3590394 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -98,6 +98,7 @@ "heading": "New account", "cta": "Create account", "somethingWentWrong": "Something went wrong. Please try again later.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "First name is required", "lastNameRequired": "Last name is required", @@ -505,6 +506,7 @@ "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", "somethingWentWrong": "Something went wrong. Please try again later.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Title is required", "authorRequired": "Name is required", @@ -535,7 +537,8 @@ "email": "Email", "comments": "Comments/questions", "cta": "Submit form", - "somethingWentWrong": "Something went wrong. Please try again later." + "somethingWentWrong": "Something went wrong. Please try again later.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -705,6 +708,7 @@ }, "Form": { "optional": "optional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Please check your input and try again", "invalidFormat": "The value entered does not match the required format" diff --git a/core/package.json b/core/package.json index 9315fb9988..b5fe4dfece 100644 --- a/core/package.json +++ b/core/package.json @@ -68,6 +68,7 @@ "react": "19.1.5", "react-day-picker": "^9.7.0", "react-dom": "19.1.5", + "react-google-recaptcha": "^3.1.0", "react-headroom": "^3.2.1", "schema-dts": "^1.1.5", "server-only": "^0.0.1", @@ -91,6 +92,7 @@ "@types/node": "^22.15.30", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", + "@types/react-google-recaptcha": "^2.1.9", "@types/react-headroom": "^3.2.3", "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.21", diff --git a/core/tests/ui/e2e/auth/register.spec.ts b/core/tests/ui/e2e/auth/register.spec.ts index 2d20864188..1a736d672f 100644 --- a/core/tests/ui/e2e/auth/register.spec.ts +++ b/core/tests/ui/e2e/auth/register.spec.ts @@ -37,6 +37,16 @@ test('Registration works as expected', { tag: [TAGS.writesData] }, async ({ page await page.getByRole('combobox', { name: 'Country' }).click(); await page.keyboard.type('United States'); await page.keyboard.press('Enter'); + + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await page.getByRole('button', { name: t('Auth.Register.cta') }).click(); await expect(page).toHaveURL('/account/orders/'); @@ -83,6 +93,16 @@ test('Registration fails if email is already in use', async ({ page, customer }) await page.getByRole('combobox', { name: 'Country' }).click(); await page.keyboard.type('United States'); await page.keyboard.press('Enter'); + + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await page.getByRole('button', { name: t('cta') }).click(); await expect(page).not.toHaveURL('/account/orders/'); @@ -90,3 +110,42 @@ test('Registration fails if email is already in use', async ({ page, customer }) // TODO: Error message needs to be translated await expect(page.getByText('The email address is already in use.')).toBeVisible(); }); + +test('Registration fails if reCAPTCHA is not completed', async ({ page }) => { + const t = await getTranslations('Auth.Register'); + + await page.goto('/register'); + await page.getByRole('heading', { name: t('heading') }).waitFor(); + + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + try { + await recaptchaCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + } catch { + test.skip(); + } + + // Fill form but intentionally skip clicking reCAPTCHA + await page.getByLabel('First Name').fill(faker.person.firstName()); + await page.getByLabel('Last Name').fill(faker.person.lastName()); + await page.getByLabel('Email Address').fill(faker.internet.email({ provider: 'example.com' })); + + const password = faker.internet.password({ pattern: /[a-zA-Z0-9]/, prefix: '1At!', length: 10 }); + + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm Password').fill(password); + await page.getByLabel('Phone').fill(faker.phone.number({ style: 'national' })); + await page.getByLabel('Address Line 1').fill(faker.location.streetAddress()); + await page.getByLabel('Suburb/City').fill(faker.location.city()); + await page.getByLabel('State/Province').fill(faker.location.state()); + await page.getByLabel('Zip/Postcode').fill(faker.location.zipCode()); + await page.getByRole('combobox', { name: 'Country' }).click(); + await page.keyboard.type('United States'); + await page.keyboard.press('Enter'); + + await page.getByRole('button', { name: t('cta') }).click(); + + await expect(page).not.toHaveURL('/account/orders/'); + await expect(page.getByText(t('recaptchaRequired'))).toBeVisible(); +}); diff --git a/core/tests/ui/e2e/reviews.spec.ts b/core/tests/ui/e2e/reviews.spec.ts index 1ac5ffd09d..1199e20496 100644 --- a/core/tests/ui/e2e/reviews.spec.ts +++ b/core/tests/ui/e2e/reviews.spec.ts @@ -44,6 +44,15 @@ test( await expect(modal.getByLabel(t('nameLabel'))).toBeEnabled(); await expect(modal.getByLabel(t('emailLabel'))).toBeEnabled(); + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await modal.getByRole('button', { name: t('submit') }).click(); await page.waitForLoadState('networkidle'); @@ -94,3 +103,42 @@ test('Shows validation errors when submitting review form with empty inputs', as await expect(errorMessages.nth(3)).toBeVisible(); await expect(errorMessages.nth(4)).toBeVisible(); }); + +test('Review submission fails if reCAPTCHA is not completed', async ({ page, catalog }) => { + const t = await getTranslations('Product.Reviews.Form'); + const product = await catalog.getDefaultOrCreateSimpleProduct(); + + await page.goto(product.path); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: t('button') }).click(); + + const modal = page.getByRole('dialog'); + + await expect(modal.getByRole('heading', { name: t('title') })).toBeVisible(); + + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + try { + await recaptchaCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + } catch { + test.skip(); + } + + // Fill all required fields but intentionally skip clicking reCAPTCHA + const rating = faker.number.int({ min: 1, max: 5 }); + const ratingLabel = rating === 1 ? '1 star' : `${rating} stars`; + + await modal.getByLabel(ratingLabel).click(); + await modal.getByLabel(t('titleLabel')).fill(faker.lorem.sentence()); + await modal.getByLabel(t('reviewLabel')).fill(faker.lorem.paragraph()); + await modal.getByLabel(t('nameLabel')).fill(faker.person.fullName()); + await modal.getByLabel(t('emailLabel')).fill(faker.internet.email()); + + await modal.getByRole('button', { name: t('submit') }).click(); + await page.waitForLoadState('networkidle'); + + await expect(modal).toBeVisible(); + await expect(page.getByText(t('recaptchaRequired'))).toBeVisible(); +}); diff --git a/core/tests/ui/e2e/webpages.spec.ts b/core/tests/ui/e2e/webpages.spec.ts index 1727cc85d3..d2c5cd307f 100644 --- a/core/tests/ui/e2e/webpages.spec.ts +++ b/core/tests/ui/e2e/webpages.spec.ts @@ -97,8 +97,48 @@ test('Contact page works with all fields and submits successfully', async ({ pag await page.getByLabel(t('Form.rma')).fill(faker.string.numeric(10)); await page.getByLabel(t('Form.comments')).fill(faker.lorem.paragraph()); + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await page.getByRole('button', { name: t('Form.cta') }).click(); await page.waitForLoadState('networkidle'); await expect(page.getByText(t('Form.success'))).toBeVisible(); await expect(page.getByRole('link', { name: t('Form.successCta') })).toBeVisible(); }); + +test('Contact page fails if reCAPTCHA is not completed', async ({ page, webPage }) => { + const t = await getTranslations('WebPages.ContactUs'); + const contactPage = await webPage.create({ + type: 'contact_form', + body: '

Reach out to us with any questions!

', + email: faker.internet.email({ provider: 'catalyst-example.catalyst' }), + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await page.goto(contactPage.path!); + await page.waitForLoadState('networkidle'); + + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + try { + await recaptchaCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + } catch { + test.skip(); + } + + // Fill required fields but intentionally skip clicking reCAPTCHA + await page.getByLabel(t('Form.email')).fill(faker.internet.email()); + await page.getByLabel(t('Form.comments')).fill(faker.lorem.paragraph()); + + await page.getByRole('button', { name: t('Form.cta') }).click(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText(t('Form.recaptchaRequired'))).toBeVisible(); +}); diff --git a/core/vibes/soul/form/dynamic-form/index.tsx b/core/vibes/soul/form/dynamic-form/index.tsx index 53c21b1d57..706d67a507 100644 --- a/core/vibes/soul/form/dynamic-form/index.tsx +++ b/core/vibes/soul/form/dynamic-form/index.tsx @@ -21,6 +21,7 @@ import { useEffect, } from 'react'; import { useFormStatus } from 'react-dom'; +import RecaptchaWidget from 'react-google-recaptcha'; import { z } from 'zod'; import { ButtonRadioGroup } from '@/vibes/soul/form/button-radio-group'; @@ -77,6 +78,7 @@ export interface DynamicFormProps { onSuccess?: (lastResult: SubmissionResult, successMessage: ReactNode) => void; passwordComplexity?: PasswordComplexitySettings | null; errorTranslations?: FormErrorTranslationMap; + recaptchaSiteKey?: string; } export function DynamicForm({ @@ -92,6 +94,7 @@ export function DynamicForm({ onSuccess, passwordComplexity, errorTranslations, + recaptchaSiteKey, }: DynamicFormProps) { const t = useTranslations('Form'); // Remove options from fields before passing to action to reduce payload size @@ -191,6 +194,7 @@ export function DynamicForm({ return ; })} + {recaptchaSiteKey ? : null}
{onCancel && (