Update account creation form validation

Use the cancreateerror returned from list=users&usprop=cancreate for
username validation.

Use the new action=validatepassword to validate entered passwords.

This also injects the resulting errors in the style of HTMLForm's field
validation rather than at the top of the form.

Change-Id: Ie8c1270eb605367556fe36b0b2080eb3f957dc54
This commit is contained in:
Brad Jorsch 2016-12-01 18:04:21 -05:00 committed by Anomie
parent 1e4fdfb825
commit 4d59edf138
5 changed files with 249 additions and 77 deletions

View file

@ -1145,6 +1145,9 @@ abstract class HTMLFormField {
* @since 1.18
*/
protected static function formatErrors( $errors ) {
// Note: If you change the logic in this method, change
// htmlform.Checker.js to match.
if ( is_array( $errors ) && count( $errors ) === 1 ) {
$errors = array_shift( $errors );
}

View file

@ -33,7 +33,8 @@
"mw.plugin.*",
"mw.cookie",
"mw.experiments",
"mw.viewport"
"mw.viewport",
"mw.htmlform.*"
]
},
{

View file

@ -1092,6 +1092,12 @@ return [
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.htmlform.checker' => [
'scripts' => [
'resources/src/mediawiki/htmlform/htmlform.Checker.js',
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.htmlform.ooui' => [
'scripts' => [
'resources/src/mediawiki/htmlform/htmlform.Element.js',
@ -2070,7 +2076,6 @@ return [
'mediawiki.special.userlogin.signup.js' => [
'scripts' => 'resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js',
'messages' => [
'createacct-error',
'createacct-emailrequired',
'noname',
'userexists',
@ -2079,6 +2084,7 @@ return [
'mediawiki.api',
'mediawiki.jqueryMsg',
'jquery.throttle-debounce',
'mediawiki.htmlform.checker',
],
],
'mediawiki.special.unwatchedPages' => [

View file

@ -31,29 +31,14 @@
} );
// Check if the username is invalid or already taken
$( function () {
var
// We need to hook to all of these events to be sure we are notified of all changes to the
// value of an <input type=text> field.
events = 'keyup keydown change mouseup cut paste focus blur',
$input = $( '#wpName2' ),
$statusContainer = $( '#mw-createacct-status-area' ),
mw.hook( 'htmlform.enhance' ).add( function ( $root ) {
var $usernameInput = $root.find( '#wpName2' ),
$passwordInput = $root.find( '#wpPassword2' ),
$emailInput = $root.find( '#wpEmail' ),
$realNameInput = $root.find( '#wpRealName' ),
api = new mw.Api(),
currentRequest;
usernameChecker, passwordChecker;
// Hide any present status messages.
function clearStatus() {
$statusContainer.slideUp( function () {
$statusContainer
.removeAttr( 'class' )
.empty();
} );
}
// Returns a promise receiving a { state:, username: } object, where:
// * 'state' is one of 'invalid', 'taken', 'ok'
// * 'username' is the validated username if 'state' is 'ok', null otherwise (if it's not
// possible to register such an account)
function checkUsername( username ) {
// We could just use .then() if we didn't have to pass on .abort()…
var d, apiPromise;
@ -62,20 +47,29 @@
apiPromise = api.get( {
action: 'query',
list: 'users',
ususers: username // '|' in usernames is handled below
ususers: username,
usprop: 'cancreate',
formatversion: 2,
errorformat: 'html',
errorsuselocal: true,
uselang: mw.config.get( 'wgUserLanguage' )
} )
.done( function ( resp ) {
var userinfo = resp.query.users[ 0 ];
if ( resp.query.users.length !== 1 ) {
// Happens if the user types '|' into the field
d.resolve( { state: 'invalid', username: null } );
} else if ( userinfo.invalid !== undefined ) {
d.resolve( { state: 'invalid', username: null } );
if ( resp.query.users.length !== 1 || userinfo.invalid ) {
d.resolve( { valid: false, messages: [ mw.message( 'noname' ).parseDom() ] } );
} else if ( userinfo.userid !== undefined ) {
d.resolve( { state: 'taken', username: null } );
d.resolve( { valid: false, messages: [ mw.message( 'userexists' ).parseDom() ] } );
} else if ( !userinfo.cancreate ) {
d.resolve( {
valid: false,
messages: userinfo.cancreateerror ? userinfo.cancreateerror.map( function ( m ) {
return m.html;
} ) : []
} );
} else {
d.resolve( { state: 'ok', username: username } );
d.resolve( { valid: true, messages: [] } );
}
} )
.fail( d.reject );
@ -83,58 +77,46 @@
return d.promise( { abort: apiPromise.abort } );
}
function updateUsernameStatus() {
var
username = $.trim( $input.val() ),
currentRequestInternal;
function checkPassword() {
// We could just use .then() if we didn't have to pass on .abort()…
var apiPromise,
d = $.Deferred();
// Abort any pending requests.
if ( currentRequest ) {
currentRequest.abort();
if ( $.trim( $usernameInput.val() ) === '' ) {
d.resolve( { valid: true, messages: [] } );
return d.promise();
}
if ( username === '' ) {
clearStatus();
return;
}
apiPromise = api.post( {
action: 'validatepassword',
user: $usernameInput.val(),
password: $passwordInput.val(),
email: $emailInput.val() || '',
realname: $realNameInput.val() || '',
formatversion: 2,
errorformat: 'html',
errorsuselocal: true,
uselang: mw.config.get( 'wgUserLanguage' )
} )
.done( function ( resp ) {
var pwinfo = resp.validatepassword || {};
currentRequest = currentRequestInternal = checkUsername( username ).done( function ( info ) {
var message;
d.resolve( {
valid: pwinfo.validity === 'Good',
messages: pwinfo.validitymessages ? pwinfo.validitymessages.map( function ( m ) {
return m.html;
} ) : []
} );
} )
.fail( d.reject );
// Another request was fired in the meantime, the result we got here is no longer current.
// This shouldn't happen as we abort pending requests, but you never know.
if ( currentRequest !== currentRequestInternal ) {
return;
}
// If we're here, then the current request has finished, avoid calling .abort() needlessly.
currentRequest = undefined;
if ( info.state === 'ok' ) {
clearStatus();
} else {
if ( info.state === 'invalid' ) {
message = mw.message( 'noname' ).text();
} else if ( info.state === 'taken' ) {
message = mw.message( 'userexists' ).text();
}
$statusContainer
.attr( 'class', 'errorbox' )
.empty()
.append(
// Ugh…
// TODO Change the HTML structure in includes/templates/Usercreate.php
$( '<strong>' ).text( mw.message( 'createacct-error' ).text() ),
$( '<br>' ),
document.createTextNode( message )
)
.slideDown();
}
} ).fail( function () {
clearStatus();
} );
return d.promise( { abort: apiPromise.abort } );
}
$input.on( events, $.debounce( 1000, updateUsernameStatus ) );
usernameChecker = new mw.htmlform.Checker( $usernameInput, checkUsername );
usernameChecker.attach();
passwordChecker = new mw.htmlform.Checker( $passwordInput, checkPassword );
passwordChecker.attach( $usernameInput.add( $emailInput ).add( $realNameInput ) );
} );
}( mediaWiki, jQuery ) );

View file

@ -0,0 +1,180 @@
( function ( mw, $ ) {
mw.htmlform = {};
/**
* @class mw.htmlform.Checker
*/
/**
* A helper class to add validation to non-OOUI HtmlForm fields.
*
* @constructor
* @param {jQuery} $element Form field generated by HTMLForm
* @param {Function} validator Validation callback
* @param {string} validator.value Value of the form field to be validated
* @param {jQuery.Promise} validator.return The promise should be resolved
* with an object with two properties: Boolean 'valid' to indicate success
* or failure of validation, and an array 'messages' to be passed to
* setErrors() on failure.
*/
mw.htmlform.Checker = function ( $element, validator ) {
this.validator = validator;
this.$element = $element;
this.$errorBox = $element.next( '.error' );
if ( !this.$errorBox.length ) {
this.$errorBox = $( '<span>' );
this.$errorBox.hide();
$element.after( this.$errorBox );
}
this.currentValue = this.$element.val();
};
/**
* Attach validation events to the form element
*
* @param {jQuery} [$extraElements] Additional elements to listen for change
* events on.
* @return {mw.htmlform.Checker}
* @chainable
*/
mw.htmlform.Checker.prototype.attach = function ( $extraElements ) {
var $e,
// We need to hook to all of these events to be sure we are
// notified of all changes to the value of an <input type=text>
// field.
events = 'keyup keydown change mouseup cut paste focus blur';
$e = this.$element;
if ( $extraElements ) {
$e = $e.add( $extraElements );
}
$e.on( events, $.debounce( 1000, this.validate.bind( this ) ) );
return this;
};
/**
* Validate the form element
* @return {jQuery.Promise}
*/
mw.htmlform.Checker.prototype.validate = function () {
var currentRequestInternal,
that = this,
value = this.$element.val();
// Abort any pending requests.
if ( this.currentRequest && this.currentRequest.abort ) {
this.currentRequest.abort();
}
if ( value === '' ) {
this.currentValue = value;
this.setErrors( [] );
return;
}
this.currentRequest = currentRequestInternal = this.validator( value )
.done( function ( info ) {
var forceReplacement = value !== that.currentValue;
// Another request was fired in the meantime, the result we got here is no longer current.
// This shouldn't happen as we abort pending requests, but you never know.
if ( that.currentRequest !== currentRequestInternal ) {
return;
}
// If we're here, then the current request has finished, avoid calling .abort() needlessly.
that.currentRequest = undefined;
that.currentValue = value;
if ( info.valid ) {
that.setErrors( [], forceReplacement );
} else {
that.setErrors( info.messages, forceReplacement );
}
} ).fail( function () {
that.currentValue = null;
that.setErrors( [] );
} );
return currentRequestInternal;
};
/**
* Display errors associated with the form element
* @param {Array} errors Error messages. Each error message will be appended to a
* `<span>` or `<li>`, as with jQuery.append().
* @param {boolean} [forceReplacement] Set true to force a visual replacement even
* if the errors are the same. Ignored if errors are empty.
* @return {mw.htmlform.Checker}
* @chainable
*/
mw.htmlform.Checker.prototype.setErrors = function ( errors, forceReplacement ) {
var $oldErrorBox, tagName, showFunc, text, replace,
$errorBox = this.$errorBox;
if ( errors.length === 0 ) {
$errorBox.slideUp( function () {
$errorBox
.removeAttr( 'class' )
.empty();
} );
} else {
// Match behavior of HTMLFormField::formatErrors(), <span> or <ul>
// depending on the count.
tagName = errors.length === 1 ? 'span' : 'ul';
// We have to animate the replacement if we're changing the tag. We
// also want to if told to by the caller (i.e. to make it visually
// obvious that the changed field value gives the same error) or if
// the error text changes (because it makes more sense than
// changing the text with no animation).
replace = (
forceReplacement || $errorBox.length > 1 ||
$errorBox[ 0 ].tagName.toLowerCase() !== tagName
);
if ( !replace ) {
text = $( '<' + tagName + '>' )
.append( errors.map( function ( e ) {
return errors.length === 1 ? e : $( '<li>' ).append( e );
} ) );
if ( text.text() !== $errorBox.text() ) {
replace = true;
}
}
$oldErrorBox = $errorBox;
if ( replace ) {
this.$errorBox = $errorBox = $( '<' + tagName + '>' );
$errorBox.hide();
$oldErrorBox.after( this.$errorBox );
}
showFunc = function () {
if ( $oldErrorBox !== $errorBox ) {
$oldErrorBox
.removeAttr( 'class' )
.detach();
}
$errorBox
.attr( 'class', 'error' )
.empty()
.append( errors.map( function ( e ) {
return errors.length === 1 ? e : $( '<li>' ).append( e );
} ) )
.slideDown();
};
if ( $oldErrorBox !== $errorBox && $oldErrorBox.hasClass( 'error' ) ) {
$oldErrorBox.slideUp( showFunc );
} else {
showFunc();
}
}
return this;
};
}( mediaWiki, jQuery ) );