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:
committed by
Michael Terry
parent
32f9902f2e
commit
c19d01a994
@@ -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(
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
lms/static/js/student_account/components/.eslintrc.js
Normal file
11
lms/static/js/student_account/components/.eslintrc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
extends: 'eslint-config-edx',
|
||||
root: true,
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: 'webpack.dev.config.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
34
package-lock.json
generated
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user