Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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=''
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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=''
2 changes: 2 additions & 0 deletions .env.development-stage
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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=''

1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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=''
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/components/Stepper/CheckoutStepperContainer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,15 +10,15 @@ import {
} from '@/components/Stepper/Steps';
import useCurrentStep from '@/hooks/useCurrentStep';

const Steps: React.FC = () => (
const Steps = (): ReactElement => (
<>
<PlanDetails />
<AccountDetails />
<BillingDetails />
</>
);

const CheckoutStepperContainer: React.FC = () => {
const CheckoutStepperContainer = (): ReactElement => {
const { currentStepKey } = useCurrentStep();
return (
<Stepper activeKey={currentStepKey}>
Expand Down
19 changes: 15 additions & 4 deletions src/components/Stepper/Steps/PlanDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<PlanDetailsPage />
);
const PlanDetails = (): ReactElement => {
const { RECAPTCHA_SITE_KEY_WEB } = getConfig();
return (
<GoogleReCaptchaProvider
reCaptchaKey={RECAPTCHA_SITE_KEY_WEB}
useEnterprise
>
<PlanDetailsPage />
</GoogleReCaptchaProvider>
);
};

export default PlanDetails;
1 change: 1 addition & 0 deletions src/components/app/data/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const createWrapper = () => {
/**
* Helper to create a mock register request payload
*/
const createMockRegisterRequest = (overrides = {}): RegistrationCreateRequestSchema => ({
const createMockRegisterRequest = (overrides = {}): Partial<RegistrationCreateRequestSchema> => ({
name: 'John Doe',
username: 'johndoe',
password: 'Password123!',
Expand Down
82 changes: 82 additions & 0 deletions src/components/app/data/hooks/useRecaptchaToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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',
ERRORED: 'error',
} as const;
export type RecaptchaStatus = typeof RECAPTCHA_STATUS[keyof typeof RECAPTCHA_STATUS];

export const RECAPTCHA_ACTIONS = {
SUBMIT: 'submit',
} 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<string | null>;
status: RecaptchaStatus;
/** Convenience booleans */
isReady: boolean;
isLoading: boolean;
}

const DEFAULT_ACTION: KnownRecaptchaAction = RECAPTCHA_ACTIONS.SUBMIT;

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<string | null> => {
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;
2 changes: 1 addition & 1 deletion src/components/app/data/hooks/useRegisterMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function useRegisterMutation({
return useMutation<
AxiosResponse<RegistrationCreateSuccessResponseSchema>,
AxiosError<RegistrationErrorResponseSchema>,
RegistrationCreateRequestSchema
Partial<RegistrationCreateRequestSchema>
>({
mutationFn: (requestData) => registerRequest(requestData),
onSuccess: (axiosResponse) => onSuccess(axiosResponse.data),
Expand Down
11 changes: 7 additions & 4 deletions src/components/app/data/services/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,17 @@ declare global {
/**
* Account creation (register) request/response schemas.
*/
interface RegistrationCreateRequestSchema {
interface BaseRegistrationCreateRequestSchema {
name: string;
username: string;
password: string;
email: string;
country: string;
honorCode?: boolean;
}

interface RegistrationCreateRequestSchema extends BaseRegistrationCreateRequestSchema {
honorCode: boolean;
recaptchaToken?: string;
}

interface RegistrationCreateSuccessResponseSchema {
Expand Down Expand Up @@ -371,7 +375,7 @@ export async function validateRegistrationFieldsDebounced(
* @throws {AxiosError<RegistrationErrorResponseSchema>} For HTTP/network/server errors
*/
export async function registerRequest(
requestData: RegistrationCreateRequestSchema,
requestData: Partial<RegistrationCreateRequestSchema>,
): Promise<AxiosResponse<RegistrationCreateSuccessResponseSchema>> {
// Ensure honor_code is always sent as true by default
const requestPayload: RegistrationCreateRequestPayload = snakeCaseObject({
Expand All @@ -387,7 +391,6 @@ export async function registerRequest(
Object.entries(requestPayload as Record<string, unknown>).forEach(([key, value]) => {
formParams.append(key, String(value));
});

const response: AxiosResponse<RegistrationCreateSuccessResponseSchema> = (
await getAuthenticatedHttpClient().post<RegistrationCreateSuccessResponsePayload>(
`${getConfig().LMS_BASE_URL}/api/user/v2/account/registration/`,
Expand Down
18 changes: 14 additions & 4 deletions src/components/plan-details-pages/PlanDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -53,6 +54,8 @@ const PlanDetailsPage = () => {
formSchema,
} = useCurrentPageDetails();

const { getToken } = useRecaptchaToken('submit');

const planDetailsSchema = useMemo(() => (
formSchema(formValidationConstraints, planDetailsFormData.stripePriceId)
), [formSchema, formValidationConstraints, planDetailsFormData.stripePriceId]);
Expand Down Expand Up @@ -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<RegistrationCreateRequestSchema> = {
name: data.fullName,
email: data.adminEmail,
username: data.username,
password: data.password,
country: data.country,
});
};

if (recaptchaToken) {
registerMutationPayload.recaptchaToken = recaptchaToken;
}
registerMutation.mutate(registerMutationPayload);
},
};

Expand Down
Loading