diff --git a/src/data/utils/useMobileResponsive.js b/src/data/utils/useMobileResponsive.js new file mode 100644 index 00000000..135a4bad --- /dev/null +++ b/src/data/utils/useMobileResponsive.js @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; + +import { breakpoints } from '@edx/paragon'; + +/** + * A react hook used to determine if the current window is mobile or not. + * returns true if the window is of mobile size. + * Code source: https://github.com/edx/prospectus/blob/master/src/utils/useMobileResponsive.js + */ +const useMobileResponsive = (breakpoint) => { + const [isMobileWindow, setIsMobileWindow] = useState(); + const checkForMobile = () => { + setIsMobileWindow(window.matchMedia(`(max-width: ${breakpoint || breakpoints.small.maxWidth}px)`).matches); + }; + useEffect(() => { + checkForMobile(); + window.addEventListener('resize', checkForMobile); + // return this function here to clean up the event listener + return () => window.removeEventListener('resize', checkForMobile); + }, []); + return isMobileWindow; +}; + +export default useMobileResponsive; diff --git a/src/login/ChangePasswordPrompt.jsx b/src/login/ChangePasswordPrompt.jsx new file mode 100644 index 00000000..162728c7 --- /dev/null +++ b/src/login/ChangePasswordPrompt.jsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; + +import classNames from 'classnames'; +import { Link, Redirect } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, ModalDialog, useToggle, +} from '@edx/paragon'; + +import messages from './messages'; +import { DEFAULT_REDIRECT_URL, RESET_PAGE } from '../data/constants'; +import { updatePathWithQueryParams } from '../data/utils'; +import useMobileResponsive from '../data/utils/useMobileResponsive'; + +const ChangePasswordPrompt = ({ intl, variant, redirectUrl }) => { + const isMobileView = useMobileResponsive(); + const [redirectToResetPasswordPage, setRedirectToResetPasswordPage] = useState(false); + const handlers = { + handleToggleOff: () => { + if (variant === 'block') { + setRedirectToResetPasswordPage(true); + } else { + window.location.href = redirectUrl || getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL); + } + }, + }; + // eslint-disable-next-line no-unused-vars + const [isOpen, open, close] = useToggle(true, handlers); + + if (redirectToResetPasswordPage) { + return ; + } + return ( + + + + {intl.formatMessage(messages[`password.security.${variant}.title`])} + + + + {intl.formatMessage(messages[`password.security.${variant}.body`])} + + + + {variant === 'nudge' ? ( + + {intl.formatMessage(messages['password.security.close.button'])} + + ) : null} + + {intl.formatMessage(messages['password.security.redirect.to.reset.password.button'])} + + + + + ); +}; + +ChangePasswordPrompt.defaultProps = { + variant: 'block', + redirectUrl: null, +}; + +ChangePasswordPrompt.propTypes = { + intl: intlShape.isRequired, + variant: PropTypes.oneOf(['nudge', 'block']), + redirectUrl: PropTypes.string, +}; + +export default injectIntl(ChangePasswordPrompt); diff --git a/src/login/LoginFailure.jsx b/src/login/LoginFailure.jsx index 4f0f1a55..411a0a53 100644 --- a/src/login/LoginFailure.jsx +++ b/src/login/LoginFailure.jsx @@ -15,8 +15,11 @@ import { INTERNAL_SERVER_ERROR, INVALID_FORM, NON_COMPLIANT_PASSWORD_EXCEPTION, + NUDGE_PASSWORD_CHANGE, + REQUIRE_PASSWORD_CHANGE, } from './data/constants'; import messages from './messages'; +import ChangePasswordPrompt from './ChangePasswordPrompt'; const LoginFailureMessage = (props) => { const { intl } = props; @@ -131,6 +134,15 @@ const LoginFailureMessage = (props) => { ); } break; + case NUDGE_PASSWORD_CHANGE: + return ( + + ); + case REQUIRE_PASSWORD_CHANGE: + return ; default: // TODO: use errorCode instead of processing error messages on frontend errorList = value.trim().split('\n'); @@ -165,6 +177,7 @@ const LoginFailureMessage = (props) => { LoginFailureMessage.defaultProps = { loginError: { + redirectUrl: null, errorCode: null, value: '', }, @@ -176,6 +189,7 @@ LoginFailureMessage.propTypes = { email: PropTypes.string, errorCode: PropTypes.string, value: PropTypes.string, + redirectUrl: PropTypes.string, }), intl: intlShape.isRequired, }; diff --git a/src/login/data/constants.js b/src/login/data/constants.js index a213714c..fb219cac 100644 --- a/src/login/data/constants.js +++ b/src/login/data/constants.js @@ -7,6 +7,8 @@ export const FORBIDDEN_REQUEST = 'forbidden-request'; export const FAILED_LOGIN_ATTEMPT = 'failed-login-attempt'; export const ACCOUNT_LOCKED_OUT = 'account-locked-out'; export const INCORRECT_EMAIL_PASSWORD = 'incorrect-email-or-password'; +export const NUDGE_PASSWORD_CHANGE = 'nudge-password-change'; +export const REQUIRE_PASSWORD_CHANGE = 'require-password-change'; // Account Activation Message export const ACCOUNT_ACTIVATION_MESSAGE = { diff --git a/src/login/messages.jsx b/src/login/messages.jsx index 407ec73d..4a3247de 100644 --- a/src/login/messages.jsx +++ b/src/login/messages.jsx @@ -241,6 +241,39 @@ const messages = defineMessages({ defaultMessage: 'click here to reset it.', description: 'Reset password link text for incorrect email or password credentials before blocking account', }, + // Vulnerable password change prompt + 'password.security.nudge.title': { + id: 'password.security.nudge.title', + defaultMessage: 'Password security', + description: 'Title for prompt that nudges user to change their vulnerable password', + }, + 'password.security.block.title': { + id: 'password.security.block.title', + defaultMessage: 'Password change required', + description: 'Title for prompt that asks user to change their vulnerable password', + }, + 'password.security.nudge.body': { + id: 'password.security.nudge.body', + defaultMessage: 'Our system detected that your password is vulnerable. ' + + 'We recommend you change it so that your account stays secure.', + description: 'Message copy for prompt that nudges user to change their vulnerable password', + }, + 'password.security.block.body': { + id: 'password.security.block.body', + defaultMessage: 'Our system detected that your password is vulnerable. ' + + 'Change your password so that your account stays secure.', + description: 'Message copy for prompt that asks user to change their vulnerable password', + }, + 'password.security.close.button': { + id: 'password.security.close.button', + defaultMessage: 'Close', + description: 'Button to close popup', + }, + 'password.security.redirect.to.reset.password.button': { + id: 'password.security.redirect.to.reset.password.button', + defaultMessage: 'Reset your password', + description: 'Button to redirect users to Reset Password page', + }, }); export default messages; diff --git a/src/login/tests/ChangePasswordPrompt.test.jsx b/src/login/tests/ChangePasswordPrompt.test.jsx new file mode 100644 index 00000000..3803e0ed --- /dev/null +++ b/src/login/tests/ChangePasswordPrompt.test.jsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import { MemoryRouter, Router } from 'react-router-dom'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; + +import ChangePasswordPrompt from '../ChangePasswordPrompt'; +import { RESET_PAGE } from '../../data/constants'; + +const IntlChangePasswordPrompt = injectIntl(ChangePasswordPrompt); +const history = createMemoryHistory(); + +describe('ChangePasswordPromptTests', () => { + let props = {}; + + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query, + })), + }); + }); + + it('[nudge modal] should redirect to next url when user clicks close button', () => { + const dashboardUrl = getConfig().BASE_URL.concat('/dashboard'); + props = { + variant: 'nudge', + redirectUrl: dashboardUrl, + }; + + delete window.location; + window.location = { href: getConfig().BASE_URL }; + + const changePasswordPrompt = mount( + + + + + , + ); + + changePasswordPrompt.find('button#password-security-close').simulate('click'); + expect(window.location.href).toBe(dashboardUrl); + }); + + it('[block modal] should redirect to reset password page when user clicks outside modal', async () => { + props = { + variant: 'block', + }; + + const changePasswordPrompt = mount( + + + + + + + , + ); + + await act(async () => { + await changePasswordPrompt.find('div.pgn__modal-backdrop').first().simulate('click'); + }); + + changePasswordPrompt.update(); + expect(history.location.pathname).toEqual(RESET_PAGE); + }); +}); diff --git a/src/login/tests/LoginFailure.test.jsx b/src/login/tests/LoginFailure.test.jsx index 853f6127..86d20890 100644 --- a/src/login/tests/LoginFailure.test.jsx +++ b/src/login/tests/LoginFailure.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; +import { MemoryRouter } from 'react-router-dom'; import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; @@ -12,6 +13,8 @@ import { NON_COMPLIANT_PASSWORD_EXCEPTION, FAILED_LOGIN_ATTEMPT, INCORRECT_EMAIL_PASSWORD, + NUDGE_PASSWORD_CHANGE, + REQUIRE_PASSWORD_CHANGE, } from '../data/constants'; const IntlLoginFailureMessage = injectIntl(LoginFailureMessage); @@ -19,6 +22,15 @@ const IntlLoginFailureMessage = injectIntl(LoginFailureMessage); describe('LoginFailureMessage', () => { let props = {}; + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query, + })), + }); + }); + it('should match non compliant password error message', () => { props = { loginError: { @@ -221,4 +233,48 @@ describe('LoginFailureMessage', () => { expect(loginFailureMessage.find('#login-failure-alert').first().text()).toEqual(expectedMessage); expect(loginFailureMessage.find('#login-failure-alert').find('a').props().href).toEqual('/reset'); }); + + it('should show modal that nudges users to change password', () => { + props = { + loginError: { + errorCode: NUDGE_PASSWORD_CHANGE, + }, + }; + + const loginFailureMessage = mount( + + + + + , + ); + + expect(loginFailureMessage.find('.pgn__modal-title').text()).toEqual('Password security'); + expect(loginFailureMessage.find('.pgn__modal-body').text()).toEqual( + 'Our system detected that your password is vulnerable. ' + + 'We recommend you change it so that your account stays secure.', + ); + }); + + it('should show modal that requires users to change password', () => { + props = { + loginError: { + errorCode: REQUIRE_PASSWORD_CHANGE, + }, + }; + + const loginFailureMessage = mount( + + + + + , + ); + + expect(loginFailureMessage.find('.pgn__modal-title').text()).toEqual('Password change required'); + expect(loginFailureMessage.find('.pgn__modal-body').text()).toEqual( + 'Our system detected that your password is vulnerable. ' + + 'Change your password so that your account stays secure.', + ); + }); }); diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index e1322df9..ba51165d 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -263,14 +263,14 @@ describe('LoginPage', () => { // ******** test redirection ******** it('should redirect to url returned by login endpoint', () => { - const dasboardUrl = 'http://localhost:18000/enterprise/select/active/?success_url=/dashboard'; + const dashboardUrl = 'http://localhost:18000/enterprise/select/active/?success_url=/dashboard'; store = mockStore({ ...initialState, login: { ...initialState.login, loginResult: { success: true, - redirectUrl: dasboardUrl, + redirectUrl: dashboardUrl, }, }, }); @@ -278,7 +278,7 @@ describe('LoginPage', () => { delete window.location; window.location = { href: getConfig().BASE_URL }; renderer.create(reduxWrapper()); - expect(window.location.href).toBe(dasboardUrl); + expect(window.location.href).toBe(dashboardUrl); }); it('should redirect to finishAuthUrl upon successful login via SSO', () => {