diff --git a/.env b/.env
index b0401f3..d667342 100644
--- a/.env
+++ b/.env
@@ -30,3 +30,4 @@ ENTERPRISE_PRODUCT_DESCRIPTIONS_AND_TERMS_URL=''
ENTERPRISE_SALES_TERMS_AND_CONDITIONS_URL=''
COMPARE_ENTERPRISE_PLANS_URL=''
CONTACT_SUPPORT_URL=''
+RECAPTCHA_SITE_KEY_WEB=''
diff --git a/.env.development b/.env.development
index 3f8d303..1c12f61 100644
--- a/.env.development
+++ b/.env.development
@@ -31,3 +31,4 @@ ENTERPRISE_PRODUCT_DESCRIPTIONS_AND_TERMS_URL='https://business.edx.org/product-
ENTERPRISE_SALES_TERMS_AND_CONDITIONS_URL='https://business.edx.org/enterprise-sales-terms/'
COMPARE_ENTERPRISE_PLANS_URL=''
CONTACT_SUPPORT_URL=''
+RECAPTCHA_SITE_KEY_WEB=''
diff --git a/.env.development-stage b/.env.development-stage
index d04a633..157a4d3 100644
--- a/.env.development-stage
+++ b/.env.development-stage
@@ -21,6 +21,7 @@ USER_INFO_COOKIE_NAME='stage-edx-user-info'
MFE_CONFIG_API_URL='https://courses.stage.edx.org/api/mfe_config/v1'
ENABLE_NEW_RELIC='false'
PARAGON_THEMES_URLS={}
+RECAPTCHA_SITE_KEY_WEB=''
# Enterprise
ENTERPRISE_ACCESS_BASE_URL='https://enterprise-access.stage.edx.org'
@@ -35,3 +36,4 @@ ENTERPRISE_PRODUCT_DESCRIPTIONS_AND_TERMS_URL='https://business.edx.org/product-
ENTERPRISE_SALES_TERMS_AND_CONDITIONS_URL='https://business.edx.org/enterprise-sales-terms/'
COMPARE_ENTERPRISE_PLANS_URL=''
CONTACT_SUPPORT_URL=''
+
diff --git a/.env.test b/.env.test
index a6de031..db658a1 100644
--- a/.env.test
+++ b/.env.test
@@ -21,3 +21,4 @@ SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
MFE_CONFIG_API_URL=''
PARAGON_THEMES_URLS={}
+RECAPTCHA_SITE_KEY_WEB=''
diff --git a/package-lock.json b/package-lock.json
index cbb17aa..d18cc21 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.54.2",
"react-intl": "^6.8.9",
@@ -18049,6 +18050,18 @@
}
}
},
+ "node_modules/react-google-recaptcha-v3": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.11.0.tgz",
+ "integrity": "sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "react": "^16.3 || ^17.0 || ^18.0 || ^19.0",
+ "react-dom": "^17.0 || ^18.0 || ^19.0"
+ }
+ },
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
diff --git a/package.json b/package.json
index 91800d6..dbd7c70 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.54.2",
"react-intl": "^6.8.9",
diff --git a/src/components/Stepper/CheckoutStepperContainer.tsx b/src/components/Stepper/CheckoutStepperContainer.tsx
index 4bebe87..b67ab06 100644
--- a/src/components/Stepper/CheckoutStepperContainer.tsx
+++ b/src/components/Stepper/CheckoutStepperContainer.tsx
@@ -1,4 +1,5 @@
import { Col, Row, Stack, Stepper } from '@openedx/paragon';
+import { ReactElement } from 'react';
import { PurchaseSummary } from '@/components/PurchaseSummary';
import { StepperTitle } from '@/components/Stepper/StepperTitle';
@@ -9,7 +10,7 @@ import {
} from '@/components/Stepper/Steps';
import useCurrentStep from '@/hooks/useCurrentStep';
-const Steps: React.FC = () => (
+const Steps = (): ReactElement => (
<>
@@ -17,7 +18,7 @@ const Steps: React.FC = () => (
>
);
-const CheckoutStepperContainer: React.FC = () => {
+const CheckoutStepperContainer = (): ReactElement => {
const { currentStepKey } = useCurrentStep();
return (
diff --git a/src/components/Stepper/Steps/PlanDetails.tsx b/src/components/Stepper/Steps/PlanDetails.tsx
index 5637c60..f45435f 100644
--- a/src/components/Stepper/Steps/PlanDetails.tsx
+++ b/src/components/Stepper/Steps/PlanDetails.tsx
@@ -1,8 +1,19 @@
+import { getConfig } from '@edx/frontend-platform/config';
+import { ReactElement } from 'react';
+import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
+
import PlanDetailsPage from '@/components/plan-details-pages/PlanDetailsPage';
-// TODO: unnecessary layer of abstraction, just move component logic into this file.
-const PlanDetails: React.FC = () => (
-
-);
+const PlanDetails = (): ReactElement => {
+ const { RECAPTCHA_SITE_KEY_WEB } = getConfig();
+ return (
+
+
+
+ );
+};
export default PlanDetails;
diff --git a/src/components/app/data/hooks/index.ts b/src/components/app/data/hooks/index.ts
index d0c5bb8..e192fc7 100644
--- a/src/components/app/data/hooks/index.ts
+++ b/src/components/app/data/hooks/index.ts
@@ -13,3 +13,4 @@ export { default as useCreateCheckoutIntentMutation } from './useCreateCheckoutI
export { default as useCheckoutSessionClientSecret } from './useCheckoutSessionClientSecret';
export { default as useRegisterMutation } from './useRegisterMutation';
export { default as useCountryOptions } from './useCountryOptions';
+export { default as useRecaptchaToken } from './useRecaptchaToken';
diff --git a/src/components/app/data/hooks/tests/useRegisterMutation.test.tsx b/src/components/app/data/hooks/tests/useRegisterMutation.test.tsx
index dcb5d2a..397de05 100644
--- a/src/components/app/data/hooks/tests/useRegisterMutation.test.tsx
+++ b/src/components/app/data/hooks/tests/useRegisterMutation.test.tsx
@@ -35,7 +35,7 @@ const createWrapper = () => {
/**
* Helper to create a mock register request payload
*/
-const createMockRegisterRequest = (overrides = {}): RegistrationCreateRequestSchema => ({
+const createMockRegisterRequest = (overrides = {}): Partial => ({
name: 'John Doe',
username: 'johndoe',
password: 'Password123!',
diff --git a/src/components/app/data/hooks/useRecaptchaToken.tsx b/src/components/app/data/hooks/useRecaptchaToken.tsx
new file mode 100644
index 0000000..f5e389f
--- /dev/null
+++ b/src/components/app/data/hooks/useRecaptchaToken.tsx
@@ -0,0 +1,81 @@
+import { getConfig } from '@edx/frontend-platform';
+import { logError, logInfo } from '@edx/frontend-platform/logging';
+import { useCallback, useMemo } from 'react';
+import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
+
+export const RECAPTCHA_STATUS = {
+ READY: 'ready',
+ LOADING: 'loading',
+ DISABLED: 'disabled',
+} as const;
+export type RecaptchaStatus = typeof RECAPTCHA_STATUS[keyof typeof RECAPTCHA_STATUS];
+
+export const RECAPTCHA_ACTIONS = {
+ SIGNUP: 'signup',
+} as const;
+export type KnownRecaptchaAction = typeof RECAPTCHA_ACTIONS[keyof typeof RECAPTCHA_ACTIONS];
+export type RecaptchaAction = KnownRecaptchaAction | (string & {});
+
+const MSG = {
+ NOT_READY: (action: RecaptchaAction) => `reCAPTCHA not ready for action: ${action}. Proceeding without token.`,
+ TOKEN_FAIL: (action: RecaptchaAction) => `Failed to obtain reCAPTCHA verification token for action: ${action}.
+ Please try again or contact support if the issue persists.`,
+ EXEC_FAIL: 'Failed to execute reCAPTCHA',
+} as const;
+
+/** Return type of the hook */
+export interface UseRecaptchaTokenResult {
+ getToken: () => Promise;
+ status: RecaptchaStatus;
+ /** Convenience booleans */
+ isReady: boolean;
+ isLoading: boolean;
+}
+
+const DEFAULT_ACTION: KnownRecaptchaAction = RECAPTCHA_ACTIONS.SIGNUP;
+
+const useRecaptchaToken = (actionName: RecaptchaAction = DEFAULT_ACTION): UseRecaptchaTokenResult => {
+ const { executeRecaptcha } = useGoogleReCaptcha();
+ const { RECAPTCHA_SITE_KEY_WEB } = getConfig();
+
+ const status: RecaptchaStatus = useMemo(() => {
+ if (!RECAPTCHA_SITE_KEY_WEB) { return RECAPTCHA_STATUS.DISABLED; }
+ if (!executeRecaptcha) { return RECAPTCHA_STATUS.LOADING; }
+ return RECAPTCHA_STATUS.READY;
+ }, [RECAPTCHA_SITE_KEY_WEB, executeRecaptcha]);
+
+ const executeWithFallback = useCallback(async () => {
+ if (status === RECAPTCHA_STATUS.READY) {
+ const token = await executeRecaptcha!(actionName);
+ if (!token) {
+ throw new Error(MSG.TOKEN_FAIL(actionName));
+ }
+ return token;
+ }
+
+ // Fallback: site key exists but recaptcha not initialized yet, or disabled
+ if (status !== RECAPTCHA_STATUS.DISABLED) {
+ logInfo(MSG.NOT_READY(actionName));
+ }
+ return null;
+ }, [status, executeRecaptcha, actionName]);
+
+ const getToken = useCallback(async (): Promise => {
+ try {
+ return await executeWithFallback();
+ } catch (err: unknown) {
+ const message = (err as { message?: string })?.message ?? MSG.EXEC_FAIL;
+ logError(message);
+ return null;
+ }
+ }, [executeWithFallback]);
+
+ return {
+ getToken,
+ status,
+ isReady: status === RECAPTCHA_STATUS.READY,
+ isLoading: status === RECAPTCHA_STATUS.LOADING,
+ };
+};
+
+export default useRecaptchaToken;
diff --git a/src/components/app/data/hooks/useRegisterMutation.ts b/src/components/app/data/hooks/useRegisterMutation.ts
index 0534e85..8826a26 100644
--- a/src/components/app/data/hooks/useRegisterMutation.ts
+++ b/src/components/app/data/hooks/useRegisterMutation.ts
@@ -22,7 +22,7 @@ export default function useRegisterMutation({
return useMutation<
AxiosResponse,
AxiosError,
- RegistrationCreateRequestSchema
+ Partial
>({
mutationFn: (requestData) => registerRequest(requestData),
onSuccess: (axiosResponse) => onSuccess(axiosResponse.data),
diff --git a/src/components/app/data/services/registration.ts b/src/components/app/data/services/registration.ts
index 5cba172..449a43a 100644
--- a/src/components/app/data/services/registration.ts
+++ b/src/components/app/data/services/registration.ts
@@ -68,7 +68,8 @@ declare global {
password: string;
email: string;
country: string;
- honorCode?: boolean;
+ honorCode: boolean;
+ recaptchaToken?: string;
}
interface RegistrationCreateSuccessResponseSchema {
@@ -371,7 +372,7 @@ export async function validateRegistrationFieldsDebounced(
* @throws {AxiosError} For HTTP/network/server errors
*/
export async function registerRequest(
- requestData: RegistrationCreateRequestSchema,
+ requestData: Partial,
): Promise> {
// Ensure honor_code is always sent as true by default
const requestPayload: RegistrationCreateRequestPayload = snakeCaseObject({
@@ -387,7 +388,6 @@ export async function registerRequest(
Object.entries(requestPayload as Record).forEach(([key, value]) => {
formParams.append(key, String(value));
});
-
const response: AxiosResponse = (
await getAuthenticatedHttpClient().post(
`${getConfig().LMS_BASE_URL}/api/user/v2/account/registration/`,
diff --git a/src/components/plan-details-pages/PlanDetailsPage.tsx b/src/components/plan-details-pages/PlanDetailsPage.tsx
index 9d14d82..882de19 100644
--- a/src/components/plan-details-pages/PlanDetailsPage.tsx
+++ b/src/components/plan-details-pages/PlanDetailsPage.tsx
@@ -14,7 +14,7 @@ import { useForm } from 'react-hook-form';
import { useLocation, useNavigate } from 'react-router-dom';
import { z } from 'zod';
-import { useFormValidationConstraints } from '@/components/app/data';
+import { useFormValidationConstraints, useRecaptchaToken } from '@/components/app/data';
import {
useCreateCheckoutIntentMutation,
useLoginMutation,
@@ -42,6 +42,7 @@ import '../Stepper/Steps/css/PriceAlert.css';
const PlanDetailsPage = () => {
const location = useLocation();
const queryClient = useQueryClient();
+
const { data: formValidationConstraints } = useFormValidationConstraints();
const planDetailsFormData = useCheckoutFormStore((state) => state.formData[DataStoreKey.PlanDetails]);
const setFormData = useCheckoutFormStore((state) => state.setFormData);
@@ -53,6 +54,8 @@ const PlanDetailsPage = () => {
formSchema,
} = useCurrentPageDetails();
+ const { getToken } = useRecaptchaToken('signup');
+
const planDetailsSchema = useMemo(() => (
formSchema(formValidationConstraints, planDetailsFormData.stripePriceId)
), [formSchema, formValidationConstraints, planDetailsFormData.stripePriceId]);
@@ -169,14 +172,21 @@ const PlanDetailsPage = () => {
password: data.password,
});
},
- [SubmitCallbacks.PlanDetailsRegister]: (data: PlanDetailsRegisterPageData) => {
- registerMutation.mutate({
+ [SubmitCallbacks.PlanDetailsRegister]: async (data: PlanDetailsRegisterPageData) => {
+ const recaptchaToken: string | null = await getToken();
+
+ const registerMutationPayload: Partial = {
name: data.fullName,
email: data.adminEmail,
username: data.username,
password: data.password,
country: data.country,
- });
+ };
+
+ if (recaptchaToken) {
+ registerMutationPayload.recaptchaToken = recaptchaToken;
+ }
+ registerMutation.mutate(registerMutationPayload);
},
};
diff --git a/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx b/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx
index b705ee0..2b80c77 100644
--- a/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx
+++ b/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx
@@ -6,18 +6,39 @@ import { useFormValidationConstraints } from '@/components/app/data';
import useBFFContext from '@/components/app/data/hooks/useBFFContext';
import { camelCasedCheckoutContextResponseFactory } from '@/components/app/data/services/__factories__';
import { validateFieldDetailed } from '@/components/app/data/services/validation';
-import { CheckoutPageRoute } from '@/constants/checkout';
+import { CheckoutPageRoute, DataStoreKey } from '@/constants/checkout';
+import { checkoutFormStore } from '@/hooks/useCheckoutFormStore';
import { renderStepperRoute } from '@/utils/tests';
jest.mock('@/components/app/data', () => ({
...jest.requireActual('@/components/app/data'),
useFormValidationConstraints: jest.fn(),
+ useRecaptchaToken: jest.fn(() => ({ getToken: jest.fn().mockResolvedValue('test-token'), isLoading: false, isReady: true })),
+ useCheckoutIntent: jest.fn(() => ({ data: {} })),
}));
jest.mock('@/components/app/data/services/validation', () => ({
validateFieldDetailed: jest.fn(),
}));
+// Ensure no network calls are attempted during registration schema validation
+jest.mock('@/components/app/data/services/registration', () => ({
+ ...jest.requireActual('@/components/app/data/services/registration'),
+ validateRegistrationFieldsDebounced: jest.fn().mockResolvedValue({ isValid: true, errors: {} }),
+}));
+
+// Mock useRegisterMutation to capture mutate calls
+let registerMutateSpy: jest.Mock;
+jest.mock('@/components/app/data/hooks/useRegisterMutation', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ get mutate() { return registerMutateSpy; },
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ })),
+}));
+
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -28,6 +49,7 @@ jest.mock('@edx/frontend-platform/config', () => ({
getConfig: jest.fn().mockReturnValue({
TERMS_OF_SERVICE_URL: 'https://example.com/terms',
PRIVACY_POLICY_URL: 'https://example.com/privacy',
+ RECAPTCHA_SITE_KEY_WEB: 'test-recaptcha-key',
}),
}));
@@ -130,6 +152,8 @@ describe('PlanDetailsRegistrationPage', () => {
},
},
});
+ // Ensure BFF context-dependent components (e.g., PurchaseSummary) have data
+ setupBFFContextMock();
});
it('renders the title correctly', () => {
@@ -425,3 +449,74 @@ describe('PlanDetailsPage - Admin Email Validation', () => {
});
});
});
+
+describe('PlanDetailsRegistrationPage - reCAPTCHA null token behavior', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Provide safe constraints
+ (useFormValidationConstraints as jest.Mock).mockReturnValue({
+ data: {
+ quantity: {
+ min: 5,
+ max: 30,
+ },
+ },
+ });
+
+ // Ensure BFF context-dependent components (e.g., PurchaseSummary) have data
+ setupBFFContextMock();
+
+ // Pre-populate the plan details form data so read-only fields are satisfied
+ checkoutFormStore.setState((state: any) => ({
+ ...state,
+ formData: {
+ ...state.formData,
+ [DataStoreKey.PlanDetails]: {
+ adminEmail: 'admin@example.com',
+ fullName: 'Admin User',
+ country: 'US',
+ },
+ },
+ }));
+
+ // Make reCAPTCHA return null token for this test
+ const { useRecaptchaToken } = jest.requireMock('@/components/app/data');
+ (useRecaptchaToken as jest.Mock).mockReturnValue(
+ { getToken: jest.fn().mockResolvedValue(null), isLoading: false, isReady: true },
+ );
+ });
+
+ it('calls register mutation without recaptchaToken when reCAPTCHA token is null', async () => {
+ registerMutateSpy = jest.fn();
+ const user = userEvent.setup();
+
+ // Set up register mutation mock to capture calls
+ const useRegisterMutation = (await import('@/components/app/data/hooks/useRegisterMutation')).default as unknown as jest.Mock;
+ const mutateSpy = jest.fn();
+ useRegisterMutation.mockReturnValue({ mutate: mutateSpy, isPending: false, isSuccess: false, isError: false });
+
+ renderStepperRoute(CheckoutPageRoute.PlanDetailsRegister);
+
+ // Fill in the required editable fields
+ await user.type(screen.getByLabelText(/public username/i), 'myuser');
+ await user.type(screen.getByLabelText(/^password$/i), 'password-1234');
+ await user.type(screen.getByLabelText(/confirm password/i), 'password-1234');
+
+ // Submit the form
+ await user.click(screen.getByTestId('stepper-submit-button'));
+
+ // Assert the register mutation was called with payload lacking recaptchaToken
+ await waitFor(() => expect(mutateSpy).toHaveBeenCalled());
+ const payload = (mutateSpy as jest.Mock).mock.calls[0][0];
+
+ expect(payload).toMatchObject({
+ name: 'Admin User',
+ email: 'admin@example.com',
+ username: 'myuser',
+ password: 'password-1234',
+ country: 'US',
+ });
+ expect(payload).not.toHaveProperty('recaptchaToken');
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 32c6c2b..f298d16 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -49,6 +49,7 @@ initialize({
ENTERPRISE_SALES_TERMS_AND_CONDITIONS_URL: process.env.ENTERPRISE_SALES_TERMS_AND_CONDITIONS_URL || null,
COMPARE_ENTERPRISE_PLANS_URL: process.env.COMPARE_ENTERPRISE_PLANS_URL || null,
CONTACT_SUPPORT_URL: process.env.CONTACT_SUPPORT_URL || null,
+ RECAPTCHA_SITE_KEY_WEB: process.env.RECAPTCHA_SITE_KEY_WEB || null,
});
},
},