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:
24
src/data/utils/useMobileResponsive.js
Normal file
24
src/data/utils/useMobileResponsive.js
Normal 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;
|
||||
89
src/login/ChangePasswordPrompt.jsx
Normal file
89
src/login/ChangePasswordPrompt.jsx
Normal 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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
src/login/tests/ChangePasswordPrompt.test.jsx
Normal file
73
src/login/tests/ChangePasswordPrompt.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user