Files
frontend-app-authn/src/register/RegistrationPage.jsx
2025-09-17 16:03:15 +05:00

476 lines
18 KiB
JavaScript

import React, {
useEffect, useMemo, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form, Spinner, StatefulButton } from '@openedx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
import {
InstitutionLogistration,
PasswordField,
RedirectLogistration,
ThirdPartyAuthAlert,
} from '../common-components';
import ConfigurableRegistrationForm from './components/ConfigurableRegistrationForm';
import RegistrationFailure from './components/RegistrationFailure';
import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
registerNewUser,
setAutoGeneratedUsernameExperimentData,
setEmailSuggestionInStore,
setUserPipelineDataLoaded,
} from './data/actions';
import {
FORM_SUBMISSION_ERROR,
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
import useRecaptchaSubmission from './data/hooks';
import { AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION, NOT_INITIALIZED } from './data/optimizelyExperiment/helper';
import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation';
import getBackendValidations from './data/selectors';
import {
isFormValid, prepareRegistrationPayload,
} from './data/utils';
import messages from './messages';
import { EmailField, NameField, UsernameField } from './RegistrationFields';
import {
ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES,
} from '../cohesion/constants';
import { setCohesionEventStates } from '../cohesion/data/actions';
import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../common-components/data/actions';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import ThirdPartyAuth from '../common-components/ThirdPartyAuth';
import {
APP_NAME, COMPLETE_STATE, PENDING_STATE,
REGISTER_PAGE,
} from '../data/constants';
import {
getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, removeCookie, setCookie,
} from '../data/utils';
import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracking/trackers/register';
/**
* Main Registration Page component
*/
const RegistrationPage = (props) => {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const { executeWithFallback } = useRecaptchaSubmission('submit_registration_form');
const registrationEmbedded = isHostAvailableInQueryParams();
const platformName = getConfig().SITE_NAME;
const flags = {
showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS,
showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS,
showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN,
autoGeneratedUsernameEnabled: getConfig().ENABLE_AUTO_GENERATED_USERNAME,
};
const {
handleInstitutionLogin,
institutionLogin,
} = props;
const backedUpFormData = useSelector(state => state.register.registrationFormData);
const initExpVariation = useSelector(state => state.register.autoGeneratedUsernameExperimentVariation);
const registrationError = useSelector(state => state.register.registrationError);
const registrationErrorCode = registrationError?.errorCode;
const registrationResult = useSelector(state => state.register.registrationResult);
const shouldBackupState = useSelector(state => state.register.shouldBackupState);
const userPipelineDataLoaded = useSelector(state => state.register.userPipelineDataLoaded);
const submitState = useSelector(state => state.register.submitState);
const fieldDescriptions = useSelector(state => state.commonComponents.fieldDescriptions);
const optionalFields = useSelector(state => state.commonComponents.optionalFields);
const thirdPartyAuthApiStatus = useSelector(state => state.commonComponents.thirdPartyAuthApiStatus);
const autoSubmitRegForm = useSelector(state => state.commonComponents.thirdPartyAuthContext.autoSubmitRegForm);
const thirdPartyAuthErrorMessage = useSelector(state => state.commonComponents.thirdPartyAuthContext.errorMessage);
const finishAuthUrl = useSelector(state => state.commonComponents.thirdPartyAuthContext.finishAuthUrl);
const currentProvider = useSelector(state => state.commonComponents.thirdPartyAuthContext.currentProvider);
const providers = useSelector(state => state.commonComponents.thirdPartyAuthContext.providers);
const secondaryProviders = useSelector(state => state.commonComponents.thirdPartyAuthContext.secondaryProviders);
const pipelineUserDetails = useSelector(state => state.commonComponents.thirdPartyAuthContext.pipelineUserDetails);
const countriesCodesList = useSelector(state => state.commonComponents.countriesCodesList);
const backendValidations = useSelector(getBackendValidations);
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
const tpaHint = useMemo(() => getTpaHint(), []);
const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields });
const [configurableFormFields, setConfigurableFormFields] = useState({ ...backedUpFormData.configurableFormFields });
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const [errorCode, setErrorCode] = useState({ type: '', count: 0 });
const [formStartTime, setFormStartTime] = useState(null);
// temporary error state for embedded experience because we don't want to show errors on blur
const [temporaryErrors, setTemporaryErrors] = useState({ ...backedUpFormData.errors });
const { cta, host } = queryParams;
const buttonLabel = cta
? formatMessage(messages['create.account.cta.button'], { label: cta })
: formatMessage(messages['create.account.for.free.button']);
const autoGeneratedUsernameExpVariation = useAutoGeneratedUsernameExperimentVariation(
initExpVariation, registrationEmbedded, tpaHint, currentProvider, thirdPartyAuthApiStatus,
);
const hideUsernameField = flags.autoGeneratedUsernameEnabled
|| autoGeneratedUsernameExpVariation === AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION;
/**
* Set the userPipelineDetails data in formFields for only first time
*/
useEffect(() => {
if (!userPipelineDataLoaded && thirdPartyAuthApiStatus === COMPLETE_STATE) {
if (thirdPartyAuthErrorMessage) {
setErrorCode(prevState => ({ type: TPA_AUTHENTICATION_FAILURE, count: prevState.count + 1 }));
}
if (pipelineUserDetails && Object.keys(pipelineUserDetails).length !== 0) {
const { name = '', username = '', email = '' } = pipelineUserDetails;
setFormFields(prevState => ({
...prevState, name, username, email,
}));
dispatch(setUserPipelineDataLoaded(true));
}
}
}, [ // eslint-disable-line react-hooks/exhaustive-deps
thirdPartyAuthApiStatus,
thirdPartyAuthErrorMessage,
pipelineUserDetails,
userPipelineDataLoaded,
]);
useEffect(() => {
if (!formStartTime) {
trackRegistrationPageViewed();
const payload = { ...queryParams, is_register_page: true };
if (tpaHint) {
payload.tpa_hint = tpaHint;
}
dispatch(getRegistrationDataFromBackend(payload));
setFormStartTime(Date.now());
}
}, [dispatch, formStartTime, queryParams, tpaHint]);
/**
* Backup the registration form in redux when register page is toggled.
*/
useEffect(() => {
if (shouldBackupState) {
dispatch(backupRegistrationFormBegin({
...backedUpFormData,
configurableFormFields: { ...configurableFormFields },
formFields: { ...formFields },
errors: { ...errors },
}));
dispatch(setAutoGeneratedUsernameExperimentData(autoGeneratedUsernameExpVariation));
}
}, [shouldBackupState, configurableFormFields, // eslint-disable-line react-hooks/exhaustive-deps
formFields, errors, dispatch, backedUpFormData]);
useEffect(() => {
if (backendValidations) {
if (registrationEmbedded) {
setTemporaryErrors(prevErrors => ({ ...prevErrors, ...backendValidations }));
} else {
setErrors(prevErrors => ({ ...prevErrors, ...backendValidations }));
}
}
}, [backendValidations, registrationEmbedded]);
useEffect(() => {
if (registrationErrorCode) {
setErrorCode(prevState => ({ type: registrationErrorCode, count: prevState.count + 1 }));
}
}, [registrationErrorCode]);
useEffect(() => {
if (registrationResult.success) {
// This event is used by GTM
trackRegistrationSuccess();
// This is used by the "User Retention Rate Event" on GTM
setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true);
// Remove marketingEmailsOptIn cookie that was set on SSO registration flow
removeCookie('marketingEmailsOptIn');
// Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component
removeCookie('ssoPipelineRedirectionDone');
}
}, [registrationResult]);
const handleOnChange = (event) => {
const { name } = event.target;
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
if (registrationError[name]) {
dispatch(clearRegistrationBackendError(name));
}
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
const handleErrorChange = (fieldName, error) => {
if (registrationEmbedded) {
setTemporaryErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
if (error === '' && errors[fieldName] !== '') {
setErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
}
} else {
setErrors(prevErrors => ({
...prevErrors,
[fieldName]: error,
}));
}
};
const registerUser = async () => {
const totalRegistrationTime = (Date.now() - formStartTime) / 1000;
let payload = { ...formFields, app_name: APP_NAME };
if (currentProvider) {
delete payload.password;
payload.social_auth_provider = currentProvider;
}
if (hideUsernameField) {
delete payload.username;
}
// Validating form data before submitting
const { isValid, fieldErrors, emailSuggestion } = isFormValid(
payload,
registrationEmbedded ? temporaryErrors : errors,
configurableFormFields,
fieldDescriptions,
formatMessage,
);
setErrors({ ...fieldErrors, captchaError: '' });
dispatch(setEmailSuggestionInStore(emailSuggestion));
// returning if not valid
if (!isValid) {
setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 }));
return;
}
let recaptchaToken = null;
try {
recaptchaToken = await executeWithFallback();
} catch (err) {
setErrors(prev => ({
...prev,
captchaError: formatMessage(messages['registration.captcha.verification.label']) || err.message,
}));
return;
}
payload = prepareRegistrationPayload(
payload,
configurableFormFields,
flags.showMarketingEmailOptInCheckbox,
totalRegistrationTime,
queryParams,
);
if (recaptchaToken) {
payload = { ...payload, captcha_token: recaptchaToken };
}
dispatch(registerNewUser(payload));
};
const handleSubmit = (e) => {
e.preventDefault();
const eventData = {
pageType: PAGE_TYPES.ACCOUNT_CREATION,
elementType: ELEMENT_TYPES.BUTTON,
webElementText: ELEMENT_TEXT.CREATE_ACCOUNT,
webElementName: ELEMENT_NAME.CREATE_ACCOUNT,
};
dispatch(setCohesionEventStates(eventData));
registerUser();
};
useEffect(() => {
if (autoSubmitRegForm && userPipelineDataLoaded) {
registerUser();
}
}, [autoSubmitRegForm, userPipelineDataLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
const renderForm = () => {
if (institutionLogin) {
return (
<InstitutionLogistration
secondaryProviders={secondaryProviders}
headingTitle={formatMessage(messages['register.institution.login.page.title'])}
/>
);
}
return (
<>
<Helmet>
<title>{formatMessage(messages['register.page.title'], { siteName: getConfig().SITE_NAME })}</title>
</Helmet>
<RedirectLogistration
host={host}
authenticatedUser={registrationResult.authenticatedUser}
success={registrationResult.success}
redirectUrl={registrationResult.redirectUrl}
finishAuthUrl={finishAuthUrl}
optionalFields={optionalFields}
registrationEmbedded={registrationEmbedded}
redirectToProgressiveProfilingPage={
getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length
}
currentProvider={currentProvider}
/>
{(autoSubmitRegForm && !errorCode.type)
|| (!autoGeneratedUsernameExpVariation && !(
autoGeneratedUsernameExpVariation === NOT_INITIALIZED
|| registrationEmbedded || !!tpaHint || !!currentProvider))
? (
<div className="mw-xs mt-5 text-center">
<Spinner animation="border" variant="primary" id="tpa-spinner" />
</div>
) : (
<div
className={classNames(
'mw-xs mt-3',
{ 'w-100 m-auto pt-4 main-content': registrationEmbedded },
)}
>
<ThirdPartyAuthAlert
currentProvider={currentProvider}
platformName={platformName}
referrer={REGISTER_PAGE}
/>
<RegistrationFailure
errorCode={errorCode.type}
failureCount={errorCode.count}
context={{ provider: currentProvider, errorMessage: thirdPartyAuthErrorMessage }}
/>
<Form id="registration-form" name="registration-form">
<NameField
name="name"
value={formFields.name}
shouldFetchUsernameSuggestions={!formFields.username.trim()}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.name}
helpText={[formatMessage(messages['help.text.name'])]}
floatingLabel={formatMessage(messages['registration.fullname.label'])}
/>
<EmailField
name="email"
value={formFields.email}
confirmEmailValue={configurableFormFields?.confirm_email}
handleErrorChange={handleErrorChange}
handleChange={handleOnChange}
errorMessage={errors.email}
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
/>
{!hideUsernameField && (
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.username}
helpText={[formatMessage(messages['help.text.username.1']), formatMessage(messages['help.text.username.2'])]}
floatingLabel={formatMessage(messages['registration.username.label'])}
/>
)}
{!currentProvider && (
<PasswordField
name="password"
value={formFields.password}
handleChange={handleOnChange}
handleErrorChange={handleErrorChange}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['registration.password.label'])}
/>
)}
<ConfigurableRegistrationForm
email={formFields.email}
fieldErrors={errors}
formFields={configurableFormFields}
setFieldErrors={registrationEmbedded ? setTemporaryErrors : setErrors}
setFormFields={setConfigurableFormFields}
autoSubmitRegisterForm={autoSubmitRegForm}
fieldDescriptions={fieldDescriptions}
countriesCodesList={countriesCodesList}
/>
{errors?.captchaError && (
<div className="mt-3 pgn__form-text-invalid pgn__form-text">
{errors.captchaError}
</div>
)}
<StatefulButton
id="register-user"
name="register-user"
type="submit"
variant="brand"
className="register-button mt-4 mb-4"
state={submitState}
labels={{
default: buttonLabel,
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
{!registrationEmbedded && (
<ThirdPartyAuth
currentProvider={currentProvider}
providers={providers}
secondaryProviders={secondaryProviders}
handleInstitutionLogin={handleInstitutionLogin}
thirdPartyAuthApiStatus={thirdPartyAuthApiStatus}
/>
)}
</Form>
</div>
)}
</>
);
};
if (tpaHint) {
if (thirdPartyAuthApiStatus === PENDING_STATE) {
return <Skeleton height={36} />;
}
const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders);
if (skipHintedLogin) {
window.location.href = getConfig().LMS_BASE_URL + provider.registerUrl;
return null;
}
return provider ? <EnterpriseSSO provider={provider} /> : renderForm();
}
return (
renderForm()
);
};
RegistrationPage.propTypes = {
institutionLogin: PropTypes.bool,
// Actions
handleInstitutionLogin: PropTypes.func,
};
RegistrationPage.defaultProps = {
handleInstitutionLogin: null,
institutionLogin: false,
};
export default RegistrationPage;