diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index af9b6acfac..2fdbccd2fb 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -1159,25 +1159,18 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None): if request.method == 'POST': password = request.POST['new_password1'] - valid_link = True # password reset link will be valid if there is no security violation - error_message = None + try: validate_password(password, user=user) - except SecurityPolicyError as err: - error_message = err.message - valid_link = False except ValidationError as err: - error_message = err.message - - if error_message: # We have a password reset attempt which violates some security # policy, or any other validation. Use the existing Django template to communicate that # back to the user. context = { - 'validlink': valid_link, + 'validlink': True, 'form': None, 'title': _('Password reset unsuccessful'), - 'err_msg': error_message, + 'err_msg': err.message, } context.update(platform_name) return TemplateResponse( diff --git a/common/static/js/src/ReactRenderer.jsx b/common/static/js/src/ReactRenderer.jsx index bf295c0368..1876dfb478 100644 --- a/common/static/js/src/ReactRenderer.jsx +++ b/common/static/js/src/ReactRenderer.jsx @@ -1,7 +1,10 @@ -import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; +// babel-polyfill must be imported last because of https://github.com/facebook/react/issues/8379 +// which otherwise causes "Objects are not valid as a react child" errors in IE11. +import 'babel-polyfill'; + class ReactRendererException extends Error { constructor(message) { super(`ReactRendererException: ${message}`); diff --git a/lms/djangoapps/courseware/tests/test_password_history.py b/lms/djangoapps/courseware/tests/test_password_history.py index 17233ad8b8..8a4e5a87f9 100644 --- a/lms/djangoapps/courseware/tests/test_password_history.py +++ b/lms/djangoapps/courseware/tests/test_password_history.py @@ -71,7 +71,7 @@ class TestPasswordHistory(LoginEnrollmentTestCase): history = PasswordHistory() history.create(user) - def assertPasswordResetError(self, response, error_message, valid_link=False): + def assertPasswordResetError(self, response, error_message, valid_link=True): """ This method is a custom assertion that verifies that a password reset view returns an error response as expected. @@ -363,4 +363,4 @@ class TestPasswordHistory(LoginEnrollmentTestCase): 'new_password1': password1, 'new_password2': password2, }, follow=True) - self.assertPasswordResetError(resp, err_msg, valid_link=True) + self.assertPasswordResetError(resp, err_msg) diff --git a/lms/static/js/student_account/components/.eslintrc.js b/lms/static/js/student_account/components/.eslintrc.js new file mode 100644 index 0000000000..838b853a82 --- /dev/null +++ b/lms/static/js/student_account/components/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: 'eslint-config-edx', + root: true, + settings: { + 'import/resolver': { + webpack: { + config: 'webpack.dev.config.js', + }, + }, + }, +}; diff --git a/lms/static/js/student_account/components/PasswordResetConfirmation.jsx b/lms/static/js/student_account/components/PasswordResetConfirmation.jsx new file mode 100644 index 0000000000..a18d2327f9 --- /dev/null +++ b/lms/static/js/student_account/components/PasswordResetConfirmation.jsx @@ -0,0 +1,142 @@ +/* globals gettext */ + +import 'whatwg-fetch'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import { Button, StatusAlert } from '@edx/paragon/static'; + +import PasswordResetInput from './PasswordResetInput'; + +// NOTE: Use static paragon with this because some internal classes (StatusAlert at least) +// conflict with some standard LMS ones ('alert' at least). This means that you need to do +// something like the following on any templates that use this class: +// +// +// + +class PasswordResetConfirmation extends React.Component { + constructor(props) { + super(props); + this.state = { + password: '', + passwordConfirmation: '', + showMatchError: false, + isValid: true, + validationMessage: '', + }; + this.onBlurPassword1 = this.onBlurPassword1.bind(this); + this.onBlurPassword2 = this.onBlurPassword2.bind(this); + } + + onBlurPassword1(password) { + this.updatePasswordState(password, this.state.passwordConfirmation); + this.validatePassword(password); + } + + onBlurPassword2(passwordConfirmation) { + this.updatePasswordState(this.state.password, passwordConfirmation); + } + + updatePasswordState(password, passwordConfirmation) { + this.setState({ + password, + passwordConfirmation, + showMatchError: !!password && !!passwordConfirmation && (password !== passwordConfirmation), + }); + } + + validatePassword(password) { + fetch('/api/user/v1/validation/registration', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + }), + }) + .then(res => res.json()) + .then((response) => { + let validationMessage = ''; + // Be careful about grabbing this message, since we could have received an HTTP error or the + // endpoint didn't give us what we expect. We only care if we get a clear error message. + if (response.validation_decisions && response.validation_decisions.password) { + validationMessage = response.validation_decisions.password; + } + this.setState({ + isValid: !validationMessage, + validationMessage, + }); + }); + } + + render() { + return ( +
+
+ + +
+

+ + {gettext('Reset Your Password')} + +

+ +

+ {gettext('Enter and confirm your new password.')} +

+ + + + + + + +
+
+ ); + } +} + +PasswordResetConfirmation.propTypes = { + csrfToken: PropTypes.string.isRequired, + errorMessage: PropTypes.string, +}; + +PasswordResetConfirmation.defaultProps = { + errorMessage: '', +}; + +export { PasswordResetConfirmation }; // eslint-disable-line import/prefer-default-export diff --git a/lms/static/js/student_account/components/PasswordResetInput.jsx b/lms/static/js/student_account/components/PasswordResetInput.jsx new file mode 100644 index 0000000000..d47584e472 --- /dev/null +++ b/lms/static/js/student_account/components/PasswordResetInput.jsx @@ -0,0 +1,27 @@ +/* globals gettext */ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { InputText } from '@edx/paragon/static'; + +function PasswordResetInput(props) { + return ( +
+ +
+ ); +} + +PasswordResetInput.propTypes = { + name: PropTypes.string.isRequired, +}; + +export default PasswordResetInput; diff --git a/lms/static/js/student_account/components/spec/PasswordResetConfirmation_spec.js b/lms/static/js/student_account/components/spec/PasswordResetConfirmation_spec.js new file mode 100644 index 0000000000..5f986b1ee4 --- /dev/null +++ b/lms/static/js/student_account/components/spec/PasswordResetConfirmation_spec.js @@ -0,0 +1,68 @@ +/* globals setFixtures */ + +import ReactDOM from 'react-dom'; +import React from 'react'; +import sinon from 'sinon'; // eslint-disable-line import/no-extraneous-dependencies +import { PasswordResetConfirmation } from '../PasswordResetConfirmation'; + +describe('PasswordResetConfirmation', () => { + beforeEach(() => { + setFixtures('
'); + sinon.stub(window, 'fetch'); + }); + + afterEach(() => { + window.fetch.restore(); + }); + + function init(submitError) { + ReactDOM.render( + React.createElement(PasswordResetConfirmation, { + csrfToken: 'csrfToken', + errorMessage: submitError, + }, null), + document.getElementById('wrapper'), + ); + } + + function triggerValidation() { + $('#new_password1').focus(); + $('#new_password1').val('a'); + $('#new_password2').focus(); + + expect(window.fetch.calledWithMatch( + '/api/user/v1/validation/registration', + { body: JSON.stringify({ password: 'a' }) }, + )); + } + + function prepareValidation(validationError, done) { + window.fetch.reset(); + window.fetch.callsFake(() => { + done(); + return Promise.resolve({ + json: () => ({ validation_decisions: { password: validationError } }), + }); + }); + } + + it('shows submit error', () => { + init('Submit error.'); + + expect($('.alert-dialog')).toExist(); + expect($('.alert-dialog')).not.toBeHidden(); + expect($('.alert-dialog')).toHaveText('Submit error.'); + }); + + describe('validation', () => { + beforeEach((done) => { + init(''); + prepareValidation('Validation error.', done); + triggerValidation(); + }); + + it('shows validation error', () => { + expect($('#error-new_password1')).toContainText('Validation error.'); + }); + }); +}); diff --git a/lms/static/js/student_account/password_reset.js b/lms/static/js/student_account/password_reset.js deleted file mode 100644 index e26448b4ae..0000000000 --- a/lms/static/js/student_account/password_reset.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Password reset template JS. - */ -$(function() { - 'use strict'; - // adding js class for styling with accessibility in mind - $('body').addClass('js'); - - // form field label styling on focus - $('form :input').focus(function() { - $("label[for='" + this.id + "']").parent().addClass('is-focused'); - }).blur(function() { - $('label').parent().removeClass('is-focused'); - }); -}); diff --git a/lms/static/karma_lms.conf.js b/lms/static/karma_lms.conf.js index 4290ebf079..eb9b9c0cce 100644 --- a/lms/static/karma_lms.conf.js +++ b/lms/static/karma_lms.conf.js @@ -42,6 +42,7 @@ var options = { // Define the Webpack-built spec files first {pattern: 'course_experience/js/**/*_spec.js', webpack: true}, {pattern: 'js/learner_dashboard/**/*_spec.js', webpack: true}, + {pattern: 'js/student_account/components/**/*_spec.js', webpack: true}, {pattern: 'completion/js/**/*_spec.js', webpack: true}, // Add all remaining spec files to be used without Webpack diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index 5991c1b118..94fd4f84e0 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -172,7 +172,7 @@ &::after { position: absolute; left: 0; - top: ($baseline/2); + top: 50%; width: 100%; height: 1px; background: $gray-l3; @@ -182,10 +182,11 @@ .text { position: relative; - top: -2px; // Aligns center of text with center of line (CR) + top: -1px; // Aligns center of text with center of line (CR) z-index: 6; padding: 0 $baseline; background: $white; + font-size: $baseline; } } diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html index ce2f741180..bb6ea1e868 100644 --- a/lms/templates/registration/password_reset_confirm.html +++ b/lms/templates/registration/password_reset_confirm.html @@ -8,78 +8,46 @@ from openedx.core.djangolib.markup import HTML, Text %> <%inherit file="../main.html"/> +<%namespace name='static' file='../static_content.html'/> <%block name="title"> ${_("Reset Your {platform_name} Password").format(platform_name=platform_name)} -<%block name="bodyextra"> - +<%block name="head_extra"> + <%block name="bodyclass">view-passwordreset <%block name="body"> -
-
-
-
-

${_("Error Resetting Password")}

-
    - % if err_msg: -
  • ${err_msg}
  • - % else: -
  • ${_("You must enter and confirm your new password.")}
  • -
  • ${_("The text in both password fields must match.")}
  • - % endif -
-
- - % if validlink: -
-
-

- - ${_("Reset Your Password")} - -

-
- -

- ${_("Enter and confirm your new password.")} -

- -
- - -
-
- - -
- - - - -
- % else: -
-

${_("Invalid Password Reset Link")}

-
    - ${Text(_(( - "This password reset link is invalid. It may have been used already. To reset your password, " - "go to the {start_link}sign-in{end_link} page and select {start_strong}Forgot password{end_strong}." - ))).format( - start_link=HTML(''), - end_link=HTML(''), - start_strong=HTML(''), - end_strong=HTML('') - ) - } -
-
- % endif +
+ % endif
diff --git a/package-lock.json b/package-lock.json index 6765b9ff3a..b0ac919b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,19 +22,43 @@ } }, "@edx/paragon": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-1.7.1.tgz", - "integrity": "sha512-Nzw6IpnMMzvIwXhlmwYz5E9f+aRPAAh94u3epP/GkhSJ2gq+kL3Kj645rEN+17xpzC95u11yfctpyXT6FtAVlA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-2.5.6.tgz", + "integrity": "sha512-ASKTOWTZvBUo8ev2SJ9apWBa7u7UKc3UNNdGlqgBzLzj2hNVJ2urxrgATaa1LaMazT23gMZkZM5iKCToQE5Pvw==", "requires": { - "@edx/edx-bootstrap": "0.4.3", + "@edx/edx-bootstrap": "1.0.0", "babel-polyfill": "6.26.0", "classnames": "2.2.5", + "email-prop-type": "1.1.5", "font-awesome": "4.7.0", + "mailto-link": "1.0.0", "prop-types": "15.6.0", "react": "16.1.0", "react-dom": "16.1.0", "react-element-proptypes": "1.0.0", "react-proptype-conditional-require": "1.0.4" + }, + "dependencies": { + "@edx/edx-bootstrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@edx/edx-bootstrap/-/edx-bootstrap-1.0.0.tgz", + "integrity": "sha512-ZVoGAqWo9NtPKoNRgOgiW+Qr83TyZ+CWiKFpTmqaG3fm0qBgysnYYRooh1pyaJPedgFw2ljGrAAHYpn94R3pfw==", + "requires": { + "bootstrap": "4.0.0", + "jquery": "3.3.1", + "popper.js": "1.12.9" + } + }, + "bootstrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.0.0.tgz", + "integrity": "sha512-gulJE5dGFo6Q61V/whS6VM4WIyrlydXfCgkE+Gxe5hjrJ8rXLLZlALq7zq2RPhOc45PSwQpJkrTnc2KgD6cvmA==" + }, + "jquery": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz", + "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==" + } } }, "@edx/studio-frontend": { @@ -7480,7 +7504,7 @@ }, "onetime": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", "dev": true }, diff --git a/package.json b/package.json index aa163dc0eb..0c7b9b475c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "dependencies": { "@edx/edx-bootstrap": "0.4.3", - "@edx/paragon": "1.7.1", + "@edx/paragon": "2.5.6", "@edx/studio-frontend": "1.7.0", "babel-core": "6.26.0", "babel-loader": "6.4.1", diff --git a/webpack.common.config.js b/webpack.common.config.js index 6c22e0d586..a9db25ce0a 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -30,6 +30,7 @@ module.exports = { UpsellExperimentModal: './lms/static/common/js/components/UpsellExperimentModal.jsx', PortfolioExperimentUpsellModal: './lms/static/common/js/components/PortfolioExperimentUpsellModal.jsx', EntitlementSupportPage: './lms/djangoapps/support/static/support/jsx/entitlements/index.jsx', + PasswordResetConfirmation: './lms/static/js/student_account/components/PasswordResetConfirmation.jsx', // Learner Dashboard EntitlementFactory: './lms/static/js/learner_dashboard/course_entitlement_factory.js',