Compare commits

...

7 Commits

Author SHA1 Message Date
Kyr
1e0be4e581 fix: username suggestions alignment (#1278)
Co-authored-by: Kyrylo Hudym-Levkovych <kyr.hudym@kyrs-MacBook-Pro.local>
2024-12-23 18:54:43 +05:00
Stanislav
8b2160388f fix: Prevent wrong appearance of skeleton after second tab click (#1224)
Co-authored-by: Stanislav Lunyachek <lunyachek@MacBook-Pro-M1.local>
2024-12-23 16:40:30 +05:00
Stanislav Lunyachek
ab357704f8 feat: Hide preloaders for third party auth providers if they are disabled 2024-04-19 10:56:58 +05:00
Ihor Romaniuk
2eab85e960 fix: Update custom SSO buttons to use brand colors (#1165) (#1208)
Description: Update custom SSO buttons to use brand colors
VAN-1838

Co-authored-by: Blue <ahtesham-quraish@users.noreply.github.com>
2024-03-31 23:11:48 -07:00
Dmytro
9f503eb5ef fix: Registration with password that doesn't meet the requirements (#1182)
Co-authored-by: Dima Alipov <dimaalipov@MacBook-Pro-Dima.local>
2024-03-18 12:56:15 +05:00
Attiya Ishaque
c2a55e125c fix: browser header showing null and replace authn with Authentication 2024-03-04 13:44:58 -03:00
Attiya Ishaque
abea379eb0 fix: browser header showing null (#1170) 2024-02-27 12:52:16 +05:00
19 changed files with 253 additions and 52 deletions

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Authn | <%= process.env.SITE_NAME %></title>
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />

View File

@@ -166,6 +166,7 @@ class LoginPage extends React.Component {
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
const isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive);
return (
<>
@@ -183,7 +184,7 @@ class LoginPage extends React.Component {
</Hyperlink>
)}
{thirdPartyAuthApiStatus === PENDING_STATE ? (
{thirdPartyAuthApiStatus === PENDING_STATE && isThirdPartyAuthActive ? (
<Skeleton className="tpa-skeleton mb-3" height={30} count={2} />
) : (
<>

View File

@@ -64,7 +64,10 @@ const Logistration = (props) => {
setInstitutionLogin(!institutionLogin);
};
const handleOnSelect = (tabKey) => {
const handleOnSelect = (tabKey, currentTab) => {
if (tabKey === currentTab) {
return;
}
sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' });
props.clearThirdPartyAuthContextErrorMessage();
if (tabKey === LOGIN_PAGE) {
@@ -117,7 +120,7 @@ const Logistration = (props) => {
</Tabs>
)
: (!isValidTpaHint() && (
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={handleOnSelect}>
<Tabs defaultActiveKey={selectedPage} id="controlled-tab" onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)}>
<Tab title={formatMessage(messages['logistration.register'])} eventKey={REGISTER_PAGE} />
<Tab title={formatMessage(messages['logistration.sign.in'])} eventKey={LOGIN_PAGE} />
</Tabs>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -9,9 +9,9 @@ import PropTypes from 'prop-types';
import validateEmail from './validator';
import { FormGroup } from '../../../common-components';
import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
fetchRealtimeValidations,
setEmailSuggestionInStore,
} from '../../data/actions';
import messages from '../../messages';
@@ -44,6 +44,10 @@ const EmailField = (props) => {
const [emailSuggestion, setEmailSuggestion] = useState({ ...backedUpFormData?.emailSuggestion });
useEffect(() => {
setEmailSuggestion(backedUpFormData.emailSuggestion);
}, [backedUpFormData.emailSuggestion]);
const handleOnBlur = (e) => {
const { value } = e.target;
const { fieldError, confirmEmailError, suggestion } = validateEmail(value, confirmEmailValue, formatMessage);
@@ -52,10 +56,7 @@ const EmailField = (props) => {
handleErrorChange('confirm_email', confirmEmailError);
}
dispatch(backupRegistrationFormBegin({
...backedUpFormData,
emailSuggestion: { ...suggestion },
}));
dispatch(setEmailSuggestionInStore(suggestion));
setEmailSuggestion(suggestion);
if (fieldError) {

View File

@@ -46,7 +46,14 @@ describe('EmailField', () => {
);
const initialState = {
register: {},
register: {
registrationFormData: {
emailSuggestion: {
suggestion: 'example@gmail.com',
type: 'warning',
},
},
},
};
beforeEach(() => {

View File

@@ -91,7 +91,7 @@ export const validateEmailAddress = (value, username, domainName) => {
const validateEmail = (value, confirmEmailValue, formatMessage) => {
let fieldError = '';
let confirmEmailError = '';
let emailSuggestion = {};
let emailSuggestion = { suggestion: '', type: '' };
if (!value) {
fieldError = formatMessage(messages['empty.email.field.error']);

View File

@@ -4,7 +4,7 @@ export const INVALID_NAME_REGEX = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{
export const urlRegex = new RegExp(INVALID_NAME_REGEX);
const validateName = (value, formatMessage) => {
let fieldError;
let fieldError = '';
if (!value.trim()) {
fieldError = formatMessage(messages['empty.name.field.error']);
} else if (value && value.match(urlRegex)) {

View File

@@ -100,7 +100,7 @@ const UsernameField = (props) => {
};
const suggestedUsernames = () => (
<div className={className}>
<div className={className} role="listbox">
<span className="text-gray username-suggestion--label">{formatMessage(messages['registration.username.suggestion.label'])}</span>
<div className="username-scroll-suggested--form-field">
{usernameSuggestions.map((username, index) => (
@@ -111,7 +111,9 @@ const UsernameField = (props) => {
className="username-suggestions--chip data-hj-suppress"
autoComplete={props.autoComplete}
key={`suggestion-${index.toString()}`}
tabIndex={0}
onClick={(e) => handleSuggestionClick(e, username)}
role="option"
>
{username}
</Button>
@@ -122,7 +124,7 @@ const UsernameField = (props) => {
);
if (usernameSuggestions.length > 0 && errorMessage && value === ' ') {
className = 'username-suggestions__error';
className = 'username-suggestions';
iconButton = <IconButton src={Close} iconAs={Icon} alt="Close" onClick={() => handleUsernameSuggestionClose()} variant="black" size="sm" className="username-suggestions__close__button" />;
suggestedUsernameDiv = suggestedUsernames();
} else if (usernameSuggestions.length > 0 && value === ' ') {
@@ -133,14 +135,15 @@ const UsernameField = (props) => {
suggestedUsernameDiv = suggestedUsernames();
}
return (
<FormGroup
{...props}
handleChange={handleOnChange}
handleFocus={handleOnFocus}
handleBlur={handleOnBlur}
>
<div className="username__form-group-wrapper">
{suggestedUsernameDiv}
</FormGroup>
<FormGroup
{...props}
handleChange={handleOnChange}
handleFocus={handleOnFocus}
handleBlur={handleOnBlur}
/>
</div>
);
};

View File

@@ -19,6 +19,7 @@ import {
backupRegistrationFormBegin,
clearRegistrationBackendError,
registerNewUser,
setEmailSuggestionInStore,
setUserPipelineDataLoaded,
} from './data/actions';
import {
@@ -185,8 +186,8 @@ const RegistrationPage = (props) => {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
if (registrationError[name]) {
dispatch(clearRegistrationBackendError(name));
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
}
setErrors(prevErrors => ({ ...prevErrors, [name]: '' }));
setFormFields(prevState => ({ ...prevState, [name]: value }));
};
@@ -220,7 +221,7 @@ const RegistrationPage = (props) => {
}
// Validating form data before submitting
const { isValid, fieldErrors } = isFormValid(
const { isValid, fieldErrors, emailSuggestion } = isFormValid(
payload,
registrationEmbedded ? temporaryErrors : errors,
configurableFormFields,
@@ -228,6 +229,7 @@ const RegistrationPage = (props) => {
formatMessage,
);
setErrors({ ...fieldErrors });
dispatch(setEmailSuggestionInStore(emailSuggestion));
// returning if not valid
if (!isValid) {

View File

@@ -235,6 +235,53 @@ describe('RegistrationPage', () => {
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' }));
});
it('should display an error when form is submitted with an invalid email', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const emailError = 'Enter a valid email address';
const formPayload = {
name: 'Petro',
username: 'petro_qa',
email: 'petro @example.com',
password: 'password1',
country: 'Ukraine',
honor_code: true,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, formPayload, true);
registrationPage.find('button.btn-brand').simulate('click');
expect(
registrationPage.find('div[feedback-for="email"]').text(),
).toEqual(emailError);
});
it('should display an error when form is submitted with an invalid username', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
const usernameError = 'Usernames can only contain letters (A-Z, a-z), numerals (0-9), '
+ 'underscores (_), and hyphens (-). Usernames cannot contain spaces';
const formPayload = {
name: 'Petro',
username: 'petro qa',
email: 'petro@example.com',
password: 'password1',
country: 'Ukraine',
honor_code: true,
totalRegistrationTime: 0,
};
store.dispatch = jest.fn(store.dispatch);
const registrationPage = mount(routerWrapper(reduxWrapper(<IntlRegistrationPage {...props} />)));
populateRequiredFields(registrationPage, formPayload, true);
registrationPage.find('button.btn-brand').simulate('click');
expect(
registrationPage.find('div[feedback-for="username"]').text(),
).toEqual(usernameError);
});
it('should submit form with marketing email opt in value', () => {
mergeConfig({
MARKETING_EMAILS_OPT_IN: 'true',

View File

@@ -11,6 +11,7 @@ import configureStore from 'redux-mock-store';
import ConfigurableRegistrationForm from './ConfigurableRegistrationForm';
import { FIELDS } from '../data/constants';
import RegistrationPage from '../RegistrationPage';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendPageEvent: jest.fn(),
@@ -22,7 +23,20 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}));
const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm);
const IntlRegistrationPage = injectIntl(RegistrationPage);
const mockStore = configureStore();
const populateRequiredFields = (registrationPage, payload, isThirdPartyAuth = false) => {
registrationPage.find('input#name').simulate('change', { target: { value: payload.name, name: 'name' } });
registrationPage.find('input#username').simulate('change', { target: { value: payload.username, name: 'username' } });
registrationPage.find('input#email').simulate('change', { target: { value: payload.email, name: 'email' } });
registrationPage.find('input[name="country"]').simulate('change', { target: { value: payload.country, name: 'country' } });
registrationPage.find('input[name="country"]').simulate('blur', { target: { value: payload.country, name: 'country' } });
if (!isThirdPartyAuth) {
registrationPage.find('input#password').simulate('change', { target: { value: payload.password, name: 'password' } });
}
};
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
@@ -182,5 +196,52 @@ describe('ConfigurableRegistrationForm', () => {
[FIELDS.TERMS_OF_SERVICE]: true,
});
});
it('should show error if email and confirm email fields do not match on submit click', () => {
const formPayload = {
name: 'Petro',
username: 'petro_qa',
email: 'petro@example.com',
password: 'password1',
country: 'Ukraine',
honor_code: true,
totalRegistrationTime: 0,
};
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
confirm_email: {
name: 'confirm_email', type: 'text', label: 'Confirm Email',
},
country: { name: 'country' },
},
},
});
const registrationPage = mount(routerWrapper(reduxWrapper(
<IntlRegistrationPage {...props} />,
)));
populateRequiredFields(registrationPage, formPayload, true);
registrationPage.find('input#confirm_email').simulate(
'change', { target: { value: 'test2@gmail.com', name: 'confirm_email' } },
);
const button = registrationPage.find('button.btn-brand');
button.simulate('click');
registrationPage.update();
const confirmEmailErrorElement = registrationPage.find('div#confirm_email-error');
expect(confirmEmailErrorElement.text()).toEqual('The email addresses do not match.');
const validationErrors = registrationPage.find('#validation-errors');
const firstValidationErrorText = validationErrors.first().text();
expect(firstValidationErrorText).toContain(
"We couldn't create your account.Please check your responses and try again.",
);
});
});
});

View File

@@ -25,6 +25,7 @@ const ThirdPartyAuth = (props) => {
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
const isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive);
return (
<>
@@ -34,7 +35,7 @@ const ThirdPartyAuth = (props) => {
</div>
)}
{thirdPartyAuthApiStatus === PENDING_STATE ? (
{thirdPartyAuthApiStatus === PENDING_STATE && isThirdPartyAuthActive ? (
<Skeleton className="tpa-skeleton" height={36} count={2} />
) : (
<>

View File

@@ -6,6 +6,7 @@ export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_N
export const REGISTER_CLEAR_USERNAME_SUGGESTIONS = 'REGISTRATION_CLEAR_USERNAME_SUGGESTIONS';
export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERROR';
export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE';
export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS';
export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED';
// Backup registration form
@@ -37,6 +38,12 @@ export const fetchRealtimeValidationsFailure = () => ({
type: REGISTER_FORM_VALIDATIONS.FAILURE,
});
// Set email field frontend validations
export const setEmailSuggestionInStore = (emailSuggestion) => ({
type: REGISTER_SET_EMAIL_SUGGESTIONS,
payload: { emailSuggestion },
});
// Register
export const registerNewUser = registrationInfo => ({
type: REGISTER_NEW_USER.BASE,

View File

@@ -3,7 +3,9 @@ import {
REGISTER_CLEAR_USERNAME_SUGGESTIONS,
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE, REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTER_SET_COUNTRY_CODE,
REGISTER_SET_EMAIL_SUGGESTIONS,
REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
} from './actions';
import {
@@ -119,6 +121,15 @@ const reducer = (state = defaultState, action = {}) => {
userPipelineDataLoaded: value,
};
}
case REGISTER_SET_EMAIL_SUGGESTIONS: {
return {
...state,
registrationFormData: {
...state.registrationFormData,
emailSuggestion: action.payload.emailSuggestion,
},
};
}
default:
return {
...state,

View File

@@ -1,11 +1,14 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { call, put, takeEvery } from 'redux-saga/effects';
import {
call, put, race, take, takeEvery,
} from 'redux-saga/effects';
import {
fetchRealtimeValidationsBegin,
fetchRealtimeValidationsFailure,
fetchRealtimeValidationsSuccess,
REGISTER_CLEAR_USERNAME_SUGGESTIONS,
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
registerNewUserBegin,
@@ -41,9 +44,15 @@ export function* handleNewUserRegistration(action) {
export function* fetchRealtimeValidations(action) {
try {
yield put(fetchRealtimeValidationsBegin());
const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload);
yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations)));
const { response } = yield race({
response: call(getFieldsValidations, action.payload.formPayload),
cancel: take(REGISTER_CLEAR_USERNAME_SUGGESTIONS),
});
if (response) {
yield put(fetchRealtimeValidationsSuccess(camelCaseObject(response.fieldValidations)));
}
} catch (e) {
if (e.response && e.response.status === 403) {
yield put(fetchRealtimeValidationsFailure());

View File

@@ -7,6 +7,7 @@ import {
REGISTER_FORM_VALIDATIONS,
REGISTER_NEW_USER,
REGISTER_SET_COUNTRY_CODE,
REGISTER_SET_EMAIL_SUGGESTIONS,
REGISTER_SET_USER_PIPELINE_DATA_LOADED,
REGISTRATION_CLEAR_BACKEND_ERROR,
} from '../actions';
@@ -64,6 +65,29 @@ describe('Registration Reducer Tests', () => {
},
);
});
it('should set email suggestions', () => {
const emailSuggestion = {
type: 'test type',
suggestion: 'test suggestion',
};
const action = {
type: REGISTER_SET_EMAIL_SUGGESTIONS,
payload: { emailSuggestion },
};
expect(reducer(defaultState, action)).toEqual(
{
...defaultState,
registrationFormData: {
...defaultState.registrationFormData,
emailSuggestion: {
type: 'test type', suggestion: 'test suggestion',
},
},
});
});
it('should set redirect url dashboard on registration success action', () => {
const payload = {
redirectUrl: `${getConfig().BASE_URL}${DEFAULT_REDIRECT_URL}`,

View File

@@ -2,6 +2,9 @@ import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { LETTER_REGEX, NUMBER_REGEX } from '../../data/constants';
import messages from '../messages';
import validateEmail from '../RegistrationFields/EmailField/validator';
import validateName from '../RegistrationFields/NameField/validator';
import validateUsername from '../RegistrationFields/UsernameField/validator';
/**
* It validates the password field value
@@ -35,12 +38,38 @@ export const isFormValid = (
) => {
const fieldErrors = { ...errors };
let isValid = true;
let emailSuggestion = { suggestion: '', type: '' };
Object.keys(payload).forEach(key => {
if (!payload[key]) {
fieldErrors[key] = formatMessage(messages[`empty.${key}.field.error`]);
}
if (fieldErrors[key]) {
isValid = false;
switch (key) {
case 'name':
fieldErrors.name = validateName(payload.name, formatMessage);
if (fieldErrors.name) { isValid = false; }
break;
case 'email': {
const {
fieldError, confirmEmailError, suggestion,
} = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage);
if (fieldError) {
fieldErrors.email = fieldError;
isValid = false;
}
if (confirmEmailError) {
fieldErrors.confirm_email = confirmEmailError;
isValid = false;
}
emailSuggestion = suggestion;
break;
}
case 'username':
fieldErrors.username = validateUsername(payload.username, formatMessage);
if (fieldErrors.username) { isValid = false; }
break;
case 'password':
fieldErrors.password = validatePasswordField(payload.password, formatMessage);
if (fieldErrors.password) { isValid = false; }
break;
default:
break;
}
});
@@ -56,12 +85,10 @@ export const isFormValid = (
} else if (!configurableFormFields[key]) {
fieldErrors[key] = fieldDescriptions[key].error_message;
}
if (fieldErrors[key]) {
isValid = false;
}
if (fieldErrors[key]) { isValid = false; }
});
return { isValid, fieldErrors };
return { isValid, fieldErrors, emailSuggestion };
};
/**

View File

@@ -65,10 +65,15 @@
margin-right: 0.25rem;
}
.username-suggestions {
.username__form-group-wrapper {
position: relative;
margin-top: -2.5rem;
margin-left: 15px;
}
.username-suggestions {
position: absolute;
inset: 0;
padding-left: 15px;
z-index: 100;
}
.username-suggestions__close__button {
@@ -76,13 +81,6 @@
position: absolute;
}
.username-suggestions__error {
position: relative;
margin-top: -13.7%;
margin-bottom: 11%;
margin-left: 15px;
}
.username-scroll-suggested--form-field {
width: 20rem;
white-space: nowrap;

View File

@@ -11,7 +11,6 @@
// ----------------------------
// #COLORS
// ----------------------------
$font-blue: #126f9a;
$white: #FFFFFF;
// social platforms
@@ -105,10 +104,10 @@ $elevation-level-2-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15);
font-size: 14px;
background-color: $white;
border: 1px solid $font-blue;
border: 1px solid $primary;
width: 224px;
height: 36px;
color: $font-blue;
color: $primary;
.btn-tpa__image-icon{
background-color: transparent;
@@ -133,7 +132,7 @@ $elevation-level-2-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15);
}
.btn-tpa__font-container {
background-color: $font-blue;
background-color: $primary;
color: $white;
font-size: 11px;