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, }); }, },