Add live validation to password reset

Adds a new React factory for that page to handle the logic. Also
cleans up the UI a little (centers it, stops using serif font, etc).
This commit is contained in:
Michael Terry
2018-04-02 11:12:44 -04:00
committed by Michael Terry
parent 32f9902f2e
commit c19d01a994
14 changed files with 322 additions and 98 deletions

View File

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

View File

@@ -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}`);

View File

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

View File

@@ -0,0 +1,11 @@
module.exports = {
extends: 'eslint-config-edx',
root: true,
settings: {
'import/resolver': {
webpack: {
config: 'webpack.dev.config.js',
},
},
},
};

View File

@@ -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:
//
// <link type='text/css' rel='stylesheet' href='${STATIC_URL}paragon/static/paragon.min.css'>
//
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 (
<section id="password-reset-confirm-anchor" className="form-type">
<div id="password-reset-confirm-form" className="form-wrapper" aria-live="polite">
<StatusAlert
alertType="danger"
dismissible={false}
open={!!this.props.errorMessage}
dialog={this.props.errorMessage}
/>
<form id="passwordreset-form" method="post" action="">
<h2 className="section-title lines">
<span className="text">
{gettext('Reset Your Password')}
</span>
</h2>
<p className="action-label" id="new_password_help_text">
{gettext('Enter and confirm your new password.')}
</p>
<PasswordResetInput
name="new_password1"
describedBy="new_password_help_text"
label={gettext('New Password')}
onBlur={this.onBlurPassword1}
isValid={this.state.isValid}
validationMessage={this.state.validationMessage}
/>
<PasswordResetInput
name="new_password2"
describedBy="new_password_help_text"
label={gettext('Confirm Password')}
onBlur={this.onBlurPassword2}
isValid={!this.state.showMatchError}
validationMessage={gettext('Passwords do not match.')}
/>
<input
type="hidden"
id="csrf_token"
name="csrfmiddlewaretoken"
value={this.props.csrfToken}
/>
<Button
type="submit"
className={['action', 'action-primary', 'action-update', 'js-reset']}
label={gettext('Reset My Password')}
/>
</form>
</div>
</section>
);
}
}
PasswordResetConfirmation.propTypes = {
csrfToken: PropTypes.string.isRequired,
errorMessage: PropTypes.string,
};
PasswordResetConfirmation.defaultProps = {
errorMessage: '',
};
export { PasswordResetConfirmation }; // eslint-disable-line import/prefer-default-export

View File

@@ -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 (
<div className="form-field">
<InputText
id={props.name}
type="password"
themes={['danger']}
dangerIconDescription={gettext('Error: ')}
required
{...props}
/>
</div>
);
}
PasswordResetInput.propTypes = {
name: PropTypes.string.isRequired,
};
export default PasswordResetInput;

View File

@@ -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('<div id="wrapper"></div>');
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.');
});
});
});

View File

@@ -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');
});
});

View File

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

View File

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

View File

@@ -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">
<title>${_("Reset Your {platform_name} Password").format(platform_name=platform_name)}</title>
</%block>
<%block name="bodyextra">
<script type="text/javascript" src="${STATIC_URL}js/student_account/password_reset.js"></script>
<%block name="head_extra">
<link type="text/css" rel="stylesheet" href="${STATIC_URL}paragon/static/paragon.min.css">
</%block>
<%block name="bodyclass">view-passwordreset</%block>
<%block name="body">
<div id="password-reset-confirm-container" class="login-register">
<section id="password-reset-confirm-anchor" class="form-type">
<div id="password-reset-confirm-form" class="form-wrapper" aria-live="polite">
<div class="status submission-error ${'hidden' if not err_msg else ''}">
<h4 class="message-title">${_("Error Resetting Password")}</h4>
<ul class="message-copy">
% if err_msg:
<li>${err_msg}</li>
% else:
<li>${_("You must enter and confirm your new password.")}</li>
<li>${_("The text in both password fields must match.")}</li>
% endif
</ul>
</div>
% if validlink:
<form role="form" id="passwordreset-form" method="post" action="">
<div class="section-title lines">
<h2>
<span class="text">
${_("Reset Your Password")}
</span>
</h2>
</div>
<p class="action-label" id="new_password_help_text">
${_("Enter and confirm your new password.")}
</p>
<div class="form-field new_password1-new_password1">
<label for="new_password1">${_("New Password")}</label>
<input id="new_password1" type="password" name="new_password1" aria-describedby="new_password_help_text" />
</div>
<div class="form-field new_password2-new_password2">
<label for="new_password2">${_("Confirm Password")}</label>
<input id="new_password2" type="password" name="new_password2" />
</div>
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
<button type="submit" class="action action-primary action-update js-reset">${_("Reset My Password")}</button>
</form>
% else:
<div class="status submission-error">
<h4 class="message-title">${_("Invalid Password Reset Link")}</h4>
<ul class="message-copy">
${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('<a href="/login">'),
end_link=HTML('</a>'),
start_strong=HTML('<strong>'),
end_strong=HTML('</strong>')
)
}
</ul>
</div>
% endif
<div id="password-reset-confirm-container" class="login-register-content login-register">
% if validlink:
${static.renderReact(
component="PasswordResetConfirmation",
id="password-reset-confirm-react",
props={
'csrfToken': csrf_token,
'errorMessage': err_msg if err_msg else '',
},
)}
% else:
<div class="status submission-error">
<h4 class="message-title">${_("Invalid Password Reset Link")}</h4>
<ul class="message-copy">
${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('<a href="/login">'),
end_link=HTML('</a>'),
start_strong=HTML('<strong>'),
end_strong=HTML('</strong>')
)
}
</ul>
</div>
</section>
% endif
</div>
</%block>

34
package-lock.json generated
View File

@@ -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
},

View File

@@ -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",

View File

@@ -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',