From 2099e62bb4d7902a2705dbf4a63cb4a19b90f1bf Mon Sep 17 00:00:00 2001 From: Zainab Amir Date: Mon, 4 Apr 2022 16:42:07 +0500 Subject: [PATCH] feat: add password change modal (#552) * feat: add password change modal Based on the error code sent from the platform, the modal will either be to nudge users to change password or block users from logging in. VAN-667 VAN-668 --- src/data/utils/useMobileResponsive.js | 24 +++++ src/login/ChangePasswordPrompt.jsx | 89 +++++++++++++++++++ src/login/LoginFailure.jsx | 14 +++ src/login/data/constants.js | 2 + src/login/messages.jsx | 33 +++++++ src/login/tests/ChangePasswordPrompt.test.jsx | 73 +++++++++++++++ src/login/tests/LoginFailure.test.jsx | 56 ++++++++++++ src/login/tests/LoginPage.test.jsx | 6 +- 8 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 src/data/utils/useMobileResponsive.js create mode 100644 src/login/ChangePasswordPrompt.jsx create mode 100644 src/login/tests/ChangePasswordPrompt.test.jsx 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', () => {