feat: add support for skip_registration_form setting for SSO (#789)

VAN-1318

Co-authored-by: Syed Sajjad  Hussain Shah <syed.sajjad@H7FKF7K6XD.local>
This commit is contained in:
Syed Sajjad Hussain Shah
2023-04-03 10:11:50 +05:00
committed by GitHub
parent e26620e350
commit 6a84e2d5b6
7 changed files with 356 additions and 118 deletions

View File

@@ -21,6 +21,9 @@ const RedirectLogistration = (props) => {
let finalRedirectUrl = '';
if (success) {
// After successful registeration remove the tpaHintedAuthentication flag from local storage if set
localStorage.removeItem('tpaHintedAuthentication');
// If we're in a third party auth pipeline, we must complete the pipeline
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.
// Note: For multiple enterprise use case, we need to make sure that user first visits the

View File

@@ -13,9 +13,13 @@ const SocialAuthProviders = (props) => {
const { formatMessage } = useIntl();
const { referrer, socialAuthProviders } = props;
function handleSubmit(e) {
function handleSubmit(e, skipRegistrationForm) {
e.preventDefault();
if (skipRegistrationForm) {
localStorage.setItem('tpaHintedAuthentication', 'true');
}
const url = e.currentTarget.dataset.providerUrl;
window.location.href = getConfig().LMS_BASE_URL + url;
}
@@ -27,7 +31,7 @@ const SocialAuthProviders = (props) => {
type="button"
className={`btn-social btn-${provider.id} ${index % 2 === 0 ? 'mr-3' : ''}`}
data-provider-url={referrer === LOGIN_PAGE ? provider.loginUrl : provider.registerUrl}
onClick={handleSubmit}
onClick={(e) => handleSubmit(e, provider.skipRegistrationForm)}
>
{provider.iconImage ? (
<div aria-hidden="true">
@@ -68,6 +72,7 @@ SocialAuthProviders.propTypes = {
iconImage: PropTypes.string,
loginUrl: PropTypes.string,
registerUrl: PropTypes.string,
skipRegistrationForm: PropTypes.bool,
})),
};

View File

@@ -244,4 +244,26 @@ describe('Logistration', () => {
logistration.find('a[data-rb-event-key="/login"]').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(backupRegistrationForm());
});
it('should remove tpaHintedAuthentication from localStorage on registeration success', () => {
localStorage.setItem('tpaHintedAuthentication', 'true');
mergeConfig({
ALLOW_PUBLIC_ACCOUNT_CREATION: true,
});
store = mockStore({
register: {
registrationResult: { success: true, redirectUrl: '' },
registrationError: {},
},
commonComponents: {
thirdPartyAuthContext: {
providers: [],
secondaryProviders: [],
},
},
});
mount(reduxWrapper(<IntlLogistration />));
expect(localStorage.getItem('tpaHintedAuthentication')).toEqual(null);
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { mount } from 'enzyme';
import renderer from 'react-test-renderer';
import registerIcons from '../RegisterFaIcons';
@@ -74,4 +75,46 @@ describe('SocialAuthProviders', () => {
expect(tree).toMatchSnapshot();
});
it('should set tpaHintedAuthentication in localStorage if skipRegistrationForm is true in provider', () => {
localStorage.clear();
props = {
socialAuthProviders: [{
...appleProvider,
iconClass: 'default',
iconImage: null,
skipRegistrationForm: true,
}],
};
const tree = mount(
<IntlProvider locale="en">
<SocialAuthProviders {...props} />
</IntlProvider>,
);
tree.find(`button#${appleProvider.id}`).simulate('click');
expect(localStorage.getItem('tpaHintedAuthentication')).toEqual('true');
});
it('should not set tpaHintedAuthentication in localStorage if skipRegistrationForm is false in provider', () => {
localStorage.clear();
props = {
socialAuthProviders: [{
...appleProvider,
iconClass: 'default',
iconImage: null,
skipRegistrationForm: false,
}],
};
const tree = mount(
<IntlProvider locale="en">
<SocialAuthProviders {...props} />
</IntlProvider>,
);
tree.find(`button#${appleProvider.id}`).simulate('click');
expect(localStorage.getItem('tpaHintedAuthentication')).toEqual(null);
});
});

View File

@@ -8,7 +8,7 @@ import { sendPageEvent } from '@edx/frontend-platform/analytics';
import {
getCountryList, getLocale, useIntl,
} from '@edx/frontend-platform/i18n';
import { Form, StatefulButton } from '@edx/paragon';
import { Form, Spinner, StatefulButton } from '@edx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import Skeleton from 'react-loading-skeleton';
@@ -37,10 +37,15 @@ import {
setUserPipelineDataLoaded,
} from './data/actions';
import {
COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY, FORM_SUBMISSION_ERROR,
COUNTRY_CODE_KEY,
COUNTRY_DISPLAY_KEY,
FIELDS,
FORM_SUBMISSION_ERROR,
} from './data/constants';
import { registrationErrorSelector, validationsSelector } from './data/selectors';
import { getSuggestionForInvalidEmail, validateCountryField, validateEmailAddress } from './data/utils';
import {
getSuggestionForInvalidEmail, isTpaHintedAuthentication, validateCountryField, validateEmailAddress,
} from './data/utils';
import messages from './messages';
import RegistrationFailure from './RegistrationFailure';
import { EmailField, UsernameField } from './registrationFields';
@@ -90,7 +95,7 @@ const RegistrationPage = (props) => {
const [configurableFormFields, setConfigurableFormFields] = useState({ ...backedUpFormData.configurableFormFields });
const [errors, setErrors] = useState({ ...backedUpFormData.errors });
const [emailSuggestion, setEmailSuggestion] = useState({ ...backedUpFormData.emailSuggestion });
const [autoSubmitRegisterForm, setAutoSubmitRegisterForm] = useState(isTpaHintedAuthentication());
const [errorCode, setErrorCode] = useState({ type: '', count: 0 });
const [formStartTime, setFormStartTime] = useState(null);
const [focusedField, setFocusedField] = useState(null);
@@ -100,12 +105,36 @@ const RegistrationPage = (props) => {
} = thirdPartyAuthContext;
const platformName = getConfig().SITE_NAME;
/**
* If auto submitting register form, we will check tos and honor code fields if they exist for feature parity.
*/
const checkTOSandHonorCodeFields = () => {
if (Object.keys(fieldDescriptions).includes(FIELDS.HONOR_CODE)) {
setConfigurableFormFields(prevState => ({
...prevState,
[FIELDS.HONOR_CODE]: true,
}));
}
if (Object.keys(fieldDescriptions).includes(FIELDS.TERMS_OF_SERVICE)) {
setConfigurableFormFields(prevState => ({
...prevState,
[FIELDS.TERMS_OF_SERVICE]: true,
}));
}
};
/**
* Set the userPipelineDetails data in formFields for only first time
*/
useEffect(() => {
if (!userPipelineDataLoaded) {
const { pipelineUserDetails } = thirdPartyAuthContext;
const { autoSubmitRegForm, pipelineUserDetails, errorMessage } = thirdPartyAuthContext;
if (errorMessage) {
localStorage.removeItem('tpaHintedAuthentication');
setAutoSubmitRegisterForm(false);
} else if (autoSubmitRegForm) {
checkTOSandHonorCodeFields();
}
if (pipelineUserDetails && Object.keys(pipelineUserDetails).length !== 0) {
const { name = '', username = '', email = '' } = pipelineUserDetails;
setFormFields(prevState => ({
@@ -114,7 +143,11 @@ const RegistrationPage = (props) => {
setUserPipelineDetailsLoaded(true);
}
}
}, [thirdPartyAuthContext, userPipelineDataLoaded, setUserPipelineDetailsLoaded]);
}, [ // eslint-disable-line react-hooks/exhaustive-deps
thirdPartyAuthContext,
userPipelineDataLoaded,
setUserPipelineDetailsLoaded,
]);
useEffect(() => {
if (!formStartTime) {
@@ -355,6 +388,7 @@ const RegistrationPage = (props) => {
};
const handleEmailSuggestionClosed = () => setEmailSuggestion({ suggestion: '', type: '' });
const handleUsernameSuggestionClosed = () => props.resetUsernameSuggestions();
const handleOnChange = (event) => {
@@ -409,9 +443,7 @@ const RegistrationPage = (props) => {
}
};
const handleSubmit = (e) => {
e.preventDefault();
const registerUser = () => {
const totalRegistrationTime = (Date.now() - formStartTime) / 1000;
let payload = { ...formFields };
@@ -458,6 +490,17 @@ const RegistrationPage = (props) => {
props.registerNewUser(payload);
};
const handleSubmit = (e) => {
e.preventDefault();
registerUser();
};
useEffect(() => {
if (autoSubmitRegisterForm && userPipelineDataLoaded) {
registerUser();
}
}, [autoSubmitRegisterForm, userPipelineDataLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
const renderForm = () => {
if (institutionLogin) {
return (
@@ -481,99 +524,106 @@ const RegistrationPage = (props) => {
getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && Object.keys(optionalFields).includes('fields')
}
/>
<div className="mw-xs mt-3">
<ThirdPartyAuthAlert
currentProvider={currentProvider}
platformName={platformName}
referrer={REGISTER_PAGE}
/>
<RegistrationFailure
errorCode={errorCode.type}
failureCount={errorCode.count}
context={{ provider: currentProvider }}
/>
<Form id="registration-form" name="registration-form">
<FormGroup
name="name"
value={formFields.name}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.name}
helpText={[formatMessage(messages['help.text.name'])]}
floatingLabel={formatMessage(messages['registration.fullname.label'])}
{autoSubmitRegisterForm && !errorCode.type ? (
<div className="mw-xs mt-5 text-center">
<Spinner animation="border" variant="primary" id="tpa-spinner" />
</div>
) : (
<div className="mw-xs mt-3">
<ThirdPartyAuthAlert
currentProvider={currentProvider}
platformName={platformName}
referrer={REGISTER_PAGE}
/>
<EmailField
name="email"
value={formFields.email}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
handleSuggestionClick={(e) => handleSuggestionClick(e, 'email')}
handleOnClose={handleEmailSuggestionClosed}
emailSuggestion={emailSuggestion}
errorMessage={errors.email}
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
<RegistrationFailure
errorCode={errorCode.type}
failureCount={errorCode.count}
context={{ provider: currentProvider }}
/>
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleBlur={handleOnBlur}
handleChange={handleOnChange}
handleFocus={handleOnFocus}
handleSuggestionClick={handleSuggestionClick}
handleUsernameSuggestionClose={handleUsernameSuggestionClosed}
usernameSuggestions={usernameSuggestions}
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}
<Form id="registration-form" name="registration-form">
<FormGroup
name="name"
value={formFields.name}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['registration.password.label'])}
errorMessage={errors.name}
helpText={[formatMessage(messages['help.text.name'])]}
floatingLabel={formatMessage(messages['registration.fullname.label'])}
/>
)}
<ConfigurableRegistrationForm
countryList={countryList}
email={formFields.email}
fieldErrors={errors}
formFields={configurableFormFields}
setFieldErrors={setErrors}
setFormFields={setConfigurableFormFields}
setFocusedField={setFocusedField}
fieldDescriptions={fieldDescriptions}
/>
<StatefulButton
id="register-user"
name="register-user"
type="submit"
variant="brand"
className="register-stateful-button-width mt-4 mb-4"
state={submitState}
labels={{
default: formatMessage(messages['create.account.for.free.button']),
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
<ThirdPartyAuth
currentProvider={currentProvider}
providers={providers}
secondaryProviders={secondaryProviders}
handleInstitutionLogin={handleInstitutionLogin}
thirdPartyAuthApiStatus={thirdPartyAuthApiStatus}
/>
</Form>
</div>
<EmailField
name="email"
value={formFields.email}
handleChange={handleOnChange}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
handleSuggestionClick={(e) => handleSuggestionClick(e, 'email')}
handleOnClose={handleEmailSuggestionClosed}
emailSuggestion={emailSuggestion}
errorMessage={errors.email}
helpText={[formatMessage(messages['help.text.email'])]}
floatingLabel={formatMessage(messages['registration.email.label'])}
/>
<UsernameField
name="username"
spellCheck="false"
value={formFields.username}
handleBlur={handleOnBlur}
handleChange={handleOnChange}
handleFocus={handleOnFocus}
handleSuggestionClick={handleSuggestionClick}
handleUsernameSuggestionClose={handleUsernameSuggestionClosed}
usernameSuggestions={usernameSuggestions}
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}
handleBlur={handleOnBlur}
handleFocus={handleOnFocus}
errorMessage={errors.password}
floatingLabel={formatMessage(messages['registration.password.label'])}
/>
)}
<ConfigurableRegistrationForm
countryList={countryList}
email={formFields.email}
fieldErrors={errors}
formFields={configurableFormFields}
setFieldErrors={setErrors}
setFormFields={setConfigurableFormFields}
setFocusedField={setFocusedField}
fieldDescriptions={fieldDescriptions}
/>
<StatefulButton
id="register-user"
name="register-user"
type="submit"
variant="brand"
className="register-stateful-button-width mt-4 mb-4"
state={submitState}
labels={{
default: formatMessage(messages['create.account.for.free.button']),
pending: '',
}}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
<ThirdPartyAuth
currentProvider={currentProvider}
providers={providers}
secondaryProviders={secondaryProviders}
handleInstitutionLogin={handleInstitutionLogin}
thirdPartyAuthApiStatus={thirdPartyAuthApiStatus}
/>
</Form>
</div>
)}
</>
);
};
@@ -642,16 +692,11 @@ RegistrationPage.propTypes = {
submitState: PropTypes.string,
thirdPartyAuthApiStatus: PropTypes.string,
thirdPartyAuthContext: PropTypes.shape({
currentProvider: PropTypes.string,
platformName: PropTypes.string,
providers: PropTypes.arrayOf(
PropTypes.shape({}),
),
secondaryProviders: PropTypes.arrayOf(
PropTypes.shape({}),
),
finishAuthUrl: PropTypes.string,
autoSubmitRegForm: PropTypes.bool,
countryCode: PropTypes.string,
currentProvider: PropTypes.string,
errorMessage: PropTypes.string,
finishAuthUrl: PropTypes.string,
pipelineUserDetails: PropTypes.shape({
email: PropTypes.string,
name: PropTypes.string,
@@ -659,6 +704,13 @@ RegistrationPage.propTypes = {
lastName: PropTypes.string,
username: PropTypes.string,
}),
platformName: PropTypes.string,
providers: PropTypes.arrayOf(
PropTypes.shape({}),
),
secondaryProviders: PropTypes.arrayOf(
PropTypes.shape({}),
),
}),
usernameSuggestions: PropTypes.arrayOf(PropTypes.string),
userPipelineDataLoaded: PropTypes.bool,
@@ -700,12 +752,14 @@ RegistrationPage.defaultProps = {
submitState: DEFAULT_STATE,
thirdPartyAuthApiStatus: PENDING_STATE,
thirdPartyAuthContext: {
currentProvider: null,
finishAuthUrl: null,
autoSubmitRegForm: false,
countryCode: null,
currentProvider: null,
errorMessage: null,
finishAuthUrl: null,
pipelineUserDetails: null,
providers: [],
secondaryProviders: [],
pipelineUserDetails: null,
},
usernameSuggestions: [],
userPipelineDataLoaded: false,

View File

@@ -111,3 +111,5 @@ export function validateCountryField(value, countryList, errorMessage) {
}
return { error, countryCode, displayValue };
}
export const isTpaHintedAuthentication = () => localStorage.getItem('tpaHintedAuthentication') === 'true';

View File

@@ -24,6 +24,7 @@ import {
import {
FIELDS, FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED,
} from '../data/constants';
import * as utils from '../data/utils';
import RegistrationFailureMessage from '../RegistrationFailure';
import RegistrationPage from '../RegistrationPage';
@@ -128,6 +129,14 @@ describe('RegistrationPage', () => {
}
};
const ssoProvider = {
id: 'oa2-apple-id',
name: 'Apple',
iconClass: null,
iconImage: 'https://openedx.devstack.lms/logo.png',
loginUrl: '/auth/login/apple-id/?auth_entry=login&next=/dashboard',
};
describe('Test Registration Page', () => {
mergeConfig({
SHOW_CONFIGURABLE_EDX_FIELDS: true,
@@ -141,14 +150,6 @@ describe('RegistrationPage', () => {
country: 'Select your country or region of residence',
};
const ssoProvider = {
id: 'oa2-apple-id',
name: 'Apple',
iconClass: null,
iconImage: 'https://openedx.devstack.lms/logo.png',
loginUrl: '/auth/login/apple-id/?auth_entry=login&next=/dashboard',
};
const secondaryProviders = {
id: 'saml-test', name: 'Test University', loginUrl: '/dummy-auth', registerUrl: '/dummy_auth',
};
@@ -995,6 +996,7 @@ describe('RegistrationPage', () => {
describe('Test Configurable Fields', () => {
mergeConfig({
ENABLE_DYNAMIC_REGISTRATION_FIELDS: true,
SHOW_CONFIGURABLE_EDX_FIELDS: true,
});
it('should render fields returned by backend', () => {
@@ -1127,5 +1129,112 @@ describe('RegistrationPage', () => {
registrationPage.find('button.dropdown-item').at(0).simulate('click', { target: { value: 'Pakistan', name: 'countryItem' } });
expect(registrationPage.find('div[feedback-for="name"]').exists()).toBeTruthy();
});
it('should check TOS and honor code fields if they exist when auto submitting register form', () => {
getLocale.mockImplementation(() => ('en-us'));
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
pipelineUserDetails: {
email: 'test@example.com',
username: 'test',
},
autoSubmitRegForm: true,
},
fieldDescriptions: {
terms_of_service: {
name: FIELDS.TERMS_OF_SERVICE,
error_message: 'You must agree to the Terms and Service agreement of our site',
},
honor_code: {
name: FIELDS.HONOR_CODE,
error_message: 'You must agree to the Honor Code agreement of our site',
},
},
},
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registrationPage.find('input#tos').props().value).toEqual(true);
expect(registrationPage.find('input#honor-code').props().value).toEqual(true);
});
it('should set autoSubmitRegisterForm true if isTpaHintedAuthentication returns true', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
getLocale.mockImplementation(() => ('en-us'));
utils.isTpaHintedAuthentication = jest.fn().mockImplementation(() => true);
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registrationPage.find('#tpa-spinner').exists()).toBeTruthy();
});
it('should show spinner instead of form while registering if autoSubmitRegForm is true', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
getLocale.mockImplementation(() => ('en-us'));
store = mockStore({
...initialState,
register: {
...initialState.register,
backendCountryCode: 'PK',
userPipelineDataLoaded: false,
},
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
currentProvider: ssoProvider.name,
pipelineUserDetails: {
name: 'John Doe',
username: 'john_doe',
email: 'john.doe@example.com',
},
autoSubmitRegForm: true,
},
},
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registrationPage.find('#tpa-spinner').exists()).toBeTruthy();
expect(registrationPage.find('#registration-form').exists()).toBeFalsy();
});
it('should set autoSubmitRegisterForm false if third party authentication fails', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
getLocale.mockImplementation(() => ('en-us'));
store = mockStore({
...initialState,
register: {
...initialState.register,
backendCountryCode: 'PK',
userPipelineDataLoaded: false,
},
commonComponents: {
...initialState.commonComponents,
thirdPartyAuthContext: {
...initialState.commonComponents.thirdPartyAuthContext,
currentProvider: ssoProvider.name,
pipelineUserDetails: {},
errorMessage: 'An error occured',
autoSubmitRegForm: true,
},
},
});
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registrationPage.find('#tpa-spinner').exists()).toBeFalsy();
expect(registrationPage.find('#registration-form').exists()).toBeTruthy();
expect(localStorage.getItem('tpaHintedAuthentication')).toEqual(null);
});
});
});