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
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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user