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
This commit is contained in:
Zainab Amir
2022-04-04 16:42:07 +05:00
committed by GitHub
parent 7f7931fec5
commit 2099e62bb4
8 changed files with 294 additions and 3 deletions

View File

@@ -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;

View File

@@ -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 <Redirect to={updatePathWithQueryParams(RESET_PAGE)} />;
}
return (
<ModalDialog
title="Password security"
isOpen={isOpen}
onClose={close}
size={isMobileView ? 'sm' : 'md'}
hasCloseButton={false}
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages[`password.security.${variant}.title`])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{intl.formatMessage(messages[`password.security.${variant}.body`])}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow className={classNames(
{ 'd-flex flex-column': isMobileView },
)}
>
{variant === 'nudge' ? (
<ModalDialog.CloseButton id="password-security-close" variant="tertiary">
{intl.formatMessage(messages['password.security.close.button'])}
</ModalDialog.CloseButton>
) : null}
<Link
id="password-security-reset-password"
className={classNames(
'btn btn-primary',
{ 'w-100': isMobileView },
)}
to={updatePathWithQueryParams(RESET_PAGE)}
>
{intl.formatMessage(messages['password.security.redirect.to.reset.password.button'])}
</Link>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
ChangePasswordPrompt.defaultProps = {
variant: 'block',
redirectUrl: null,
};
ChangePasswordPrompt.propTypes = {
intl: intlShape.isRequired,
variant: PropTypes.oneOf(['nudge', 'block']),
redirectUrl: PropTypes.string,
};
export default injectIntl(ChangePasswordPrompt);

View File

@@ -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 (
<ChangePasswordPrompt
redirectUrl={props.loginError.redirectUrl}
variant="nudge"
/>
);
case REQUIRE_PASSWORD_CHANGE:
return <ChangePasswordPrompt />;
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,
};

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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(
<IntlProvider locale="en">
<MemoryRouter>
<IntlChangePasswordPrompt {...props} />
</MemoryRouter>
</IntlProvider>,
);
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(
<IntlProvider locale="en">
<MemoryRouter>
<Router history={history}>
<IntlChangePasswordPrompt {...props} />
</Router>
</MemoryRouter>
</IntlProvider>,
);
await act(async () => {
await changePasswordPrompt.find('div.pgn__modal-backdrop').first().simulate('click');
});
changePasswordPrompt.update();
expect(history.location.pathname).toEqual(RESET_PAGE);
});
});

View File

@@ -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(
<IntlProvider locale="en">
<MemoryRouter>
<IntlLoginFailureMessage {...props} />
</MemoryRouter>
</IntlProvider>,
);
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(
<IntlProvider locale="en">
<MemoryRouter>
<IntlLoginFailureMessage {...props} />
</MemoryRouter>
</IntlProvider>,
);
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.',
);
});
});

View File

@@ -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(<IntlLoginPage {...props} />));
expect(window.location.href).toBe(dasboardUrl);
expect(window.location.href).toBe(dashboardUrl);
});
it('should redirect to finishAuthUrl upon successful login via SSO', () => {