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', () => {