Login with email/forget password UI refactoring

* move button into login modal with social buttons
* adds email link next to login field when filling it
* adds proper validation messages
* improves forgot password flash clearing
* more tests
This commit is contained in:
Joffrey JAFFEUX 2018-02-20 13:29:43 +01:00 committed by Guo Xiang Tan
parent 720e1965e3
commit 6f5acfe783
13 changed files with 277 additions and 86 deletions

View File

@ -14,11 +14,13 @@ export default Ember.Component.extend({
Ember.run.scheduleOnce('afterRender', this, this._afterFirstRender);
this.appEvents.on('modal-body:flash', msg => this._flash(msg));
this.appEvents.on('modal-body:clearFlash', () => this._clearFlash());
},
willDestroyElement() {
this._super();
this.appEvents.off('modal-body:flash');
this.appEvents.off('modal-body:clearFlash');
},
_afterFirstRender() {
@ -45,10 +47,16 @@ export default Ember.Component.extend({
);
},
_clearFlash() {
$('#modal-alert').hide().removeClass('alert-error', 'alert-success');
},
_flash(msg) {
$('#modal-alert').hide()
.removeClass('alert-error', 'alert-success')
.addClass(`alert alert-${msg.messageClass || 'success'}`).html(msg.text || '')
.fadeIn();
this._clearFlash();
$('#modal-alert')
.addClass(`alert alert-${msg.messageClass || 'success'}`)
.html(msg.text || '')
.fadeIn();
},
});

View File

@ -13,8 +13,12 @@ export default Ember.Component.extend({
},
actions: {
externalLogin: function(provider) {
this.sendAction('action', provider);
emailLogin() {
this.sendAction('emailLogin');
},
externalLogin(provider) {
this.sendAction('externalLogin', provider);
}
}
});

View File

@ -29,45 +29,36 @@ export default Ember.Controller.extend(ModalFunctionality, {
},
resetPassword() {
return this._submit('/session/forgot_password', 'forgot_password.complete');
},
if (this.get('submitDisabled')) return false;
this.set('disabled', true);
emailLogin() {
return this._submit('/u/email-login', 'email_login.complete');
this.clearFlash();
ajax('/session/forgot_password', {
data: { login: this.get('accountEmailOrUsername').trim() },
type: 'POST'
}).then(data => {
const accountEmailOrUsername = escapeExpression(this.get("accountEmailOrUsername"));
const isEmail = accountEmailOrUsername.match(/@/);
let key = `forgot_password.complete_${isEmail ? 'email' : 'username'}`;
if (data.user_found) {
this.set('offerHelp', I18n.t(`${key}_found`, {
email: accountEmailOrUsername,
username: accountEmailOrUsername
}));
} else {
this.flash(I18n.t(`${key}_not_found`, {
email: accountEmailOrUsername,
username: accountEmailOrUsername
}), 'error');
}
}).catch(e => {
this.flash(extractError(e), 'error');
}).finally(() => {
this.set('disabled', false);
});
return false;
}
},
_submit(route, translationKey) {
if (this.get('submitDisabled')) return false;
this.set('disabled', true);
ajax(route, {
data: { login: this.get('accountEmailOrUsername').trim() },
type: 'POST'
}).then(data => {
const escaped = escapeExpression(this.get('accountEmailOrUsername'));
const isEmail = this.get('accountEmailOrUsername').match(/@/);
let key = `${translationKey}_${isEmail ? 'email' : 'username'}`;
let extraClass;
if (data.user_found === true) {
key += '_found';
this.set('accountEmailOrUsername', '');
this.set('offerHelp', I18n.t(key, { email: escaped, username: escaped }));
} else {
if (data.user_found === false) {
key += '_not_found';
extraClass = 'error';
}
this.flash(I18n.t(key, { email: escaped, username: escaped }), extraClass);
}
}).catch(e => {
this.flash(extractError(e), 'error');
}).finally(() => {
this.set('disabled', false);
});
return false;
},
});

View File

@ -4,6 +4,8 @@ import showModal from 'discourse/lib/show-modal';
import { setting } from 'discourse/lib/computed';
import { findAll } from 'discourse/models/login-method';
import { escape } from 'pretty-text/sanitizer';
import { escapeExpression } from 'discourse/lib/utilities';
import { extractError } from 'discourse/lib/ajax-error';
import computed from 'ember-addons/ember-computed-decorators';
// This is happening outside of the app via popup
@ -24,8 +26,10 @@ export default Ember.Controller.extend(ModalFunctionality, {
authenticate: null,
loggingIn: false,
loggedIn: false,
processingEmailLink: false,
canLoginLocal: setting('enable_local_logins'),
canLoginLocalWithEmail: setting('enable_local_logins_via_email'),
loginRequired: Em.computed.alias('application.loginRequired'),
resetForm: function() {
@ -59,6 +63,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
return this.get('loggingIn') || this.get('authenticate');
}.property('loggingIn', 'authenticate'),
@computed('canLoginLocalWithEmail', 'loginName', 'processingEmailLink')
showLoginWithEmailLink(canLoginLocalWithEmail, loginName, processingEmailLink) {
return canLoginLocalWithEmail && !Ember.isEmpty(loginName) && !processingEmailLink;
},
actions: {
login() {
const self = this;
@ -198,6 +207,37 @@ export default Ember.Controller.extend(ModalFunctionality, {
const forgotPasswordController = this.get('forgotPassword');
if (forgotPasswordController) { forgotPasswordController.set("accountEmailOrUsername", this.get("loginName")); }
this.send("showForgotPassword");
},
emailLogin() {
if (this.get('processingEmailLink')) {
return;
}
if (Ember.isEmpty(this.get('loginName'))){
this.flash(I18n.t('login.blank_username'), 'error');
return;
}
this.set('processingEmailLink', true);
ajax('/u/email-login', {
data: { login: this.get('loginName').trim() },
type: 'POST'
}).then(data => {
const loginName = escapeExpression(this.get('loginName'));
const isEmail = loginName.match(/@/);
let key = `email_login.complete_${isEmail ? 'email' : 'username'}`;
if (data.user_found) {
this.flash(I18n.t(`${key}_found`, { email: loginName, username: loginName }));
} else {
this.flash(I18n.t(`${key}_not_found`, { email: loginName, username: loginName }), 'error');
}
}).catch(e => {
this.flash(extractError(e), 'error');
}).finally(() => {
this.set('processingEmailLink', false);
});
}
},

View File

@ -5,6 +5,10 @@ export default Ember.Mixin.create({
this.appEvents.trigger('modal-body:flash', { text, messageClass });
},
clearFlash() {
this.appEvents.trigger('modal-body:clearFlash');
},
showModal(...args) {
return showModal(...args);
},

View File

@ -1,3 +1,12 @@
{{#each buttons as |b|}}
<button class="btn btn-social {{b.name}}" {{action "externalLogin" b}}>{{b.title}}</button>
{{/each}}
{{#if canLoginLocalWithEmail}}
{{d-button
action="emailLogin"
label="email_login.button_label"
disabled=processingEmailLink
icon="envelope-o"
class="login-with-email-button"}}
{{/if}}

View File

@ -1,6 +1,11 @@
{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}}
{{#d-modal-body title="login.title" class="login-modal"}}
{{login-buttons action="externalLogin"}}
{{login-buttons
canLoginLocalWithEmail=canLoginLocalWithEmail
processingEmailLink=processingEmailLink
emailLogin='emailLogin'
externalLogin='externalLogin'}}
{{#if canLoginLocal}}
<form id='login-form' method='post'>
<div id="credentials">
@ -13,6 +18,16 @@
{{text-field value=loginName type="email" placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off"}}
</td>
</tr>
{{#if showLoginWithEmailLink}}
<tr>
<td></td>
<td>
<a class="login-with-email-link" {{action "emailLogin"}}>
{{i18n 'email_login.link_label'}}
</a>
</td>
</tr>
{{/if}}
<tr>
<td>
<label for='login-account-password'>{{i18n 'login.password'}}&nbsp;</label>

View File

@ -13,12 +13,6 @@
label="forgot_password.reset"
disabled=submitDisabled
class="btn-primary"}}
{{#if siteSettings.enable_local_logins_via_email}}
{{d-button action="emailLogin"
label="email_login.label"
disabled=submitDisabled
class="email-login"}}
{{/if}}
{{else}}
{{d-button class="btn-large btn-primary"
label="forgot_password.button_ok"

View File

@ -1,6 +1,11 @@
{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}}
{{#d-modal-body title="login.title" class="login-modal"}}
{{login-buttons action="externalLogin"}}
{{login-buttons
canLoginLocalWithEmail=canLoginLocalWithEmail
processingEmailLink=processingEmailLink
emailLogin='emailLogin'
externalLogin='externalLogin'}}
{{#if canLoginLocal}}
<form id='login-form' method='post'>
<div id="credentials">
@ -8,7 +13,13 @@
<tr>
<td><label for='login-account-name'>{{i18n 'login.username'}}</label></td>
<td>{{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus"}}</td>
<td></td>
<td>
{{#if showLoginWithEmailLink}}
<a class="login-with-email-link" {{action "emailLogin"}}>
{{i18n 'email_login.link_label'}}
</a>
{{/if}}
</td>
</tr>
<tr>
<td><label for='login-account-password'>{{i18n 'login.password'}}</label></td>

View File

@ -128,7 +128,7 @@
&:before {
margin-right: 9px;
font-family: FontAwesome;
font-size: 1.214em;
font-size: $font-0;
}
&.google, &.google_oauth2 {
background: $google;

View File

@ -1097,7 +1097,8 @@ en:
button_help: "Help"
email_login:
label: "Login With Email"
link_label: "Email me a magic link"
button_label: "with email"
complete_username: "If an account matches the username <b>%{username}</b>, you should receive an email with a magic login link shortly."
complete_email: "If an account matches <b>%{email}</b>, you should receive an email with a magic login link shortly."
complete_username_found: "We found an account that matches the username <b>%{username}</b>, you should receive an email with a magic login link shortly."
@ -1116,6 +1117,7 @@ en:
caps_lock_warning: "Caps Lock is on"
error: "Unknown error"
rate_limit: "Please wait before trying to log in again."
blank_username: "Please enter your email or username."
blank_username_or_password: "Please enter your email or username, and password."
reset_password: 'Reset Password'
logging_in: "Signing In..."

View File

@ -3,9 +3,6 @@ import { acceptance } from "helpers/qunit-helpers";
let userFound = false;
acceptance("Forgot password", {
settings: {
enable_local_logins_via_email: true
},
beforeEach() {
const response = object => {
return [
@ -15,41 +12,44 @@ acceptance("Forgot password", {
];
};
server.post('/u/email-login', () => { // eslint-disable-line no-undef
server.post('/session/forgot_password', () => { // eslint-disable-line no-undef
return response({ "user_found": userFound });
});
}
});
QUnit.test("logging in via email", assert => {
QUnit.test("requesting password reset", assert => {
visit("/");
click("header .login-button");
andThen(() => {
assert.ok(exists('.login-modal'), "it shows the login modal");
});
click('#forgot-password-link');
fillIn("#username-or-email", 'someuser');
click('.email-login');
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('email_login.complete_username_not_found', { username: 'someuser' }),
'it should display the right error message'
find('button[title="Reset Password"]').attr("disabled"),
"disabled",
'it should disable the button until the field is filled'
);
});
fillIn("#username-or-email", 'someuser');
click('button[title="Reset Password"]');
andThen(() => {
assert.equal(
find(".alert-error").html().trim(),
I18n.t('forgot_password.complete_username_not_found', { username: 'someuser' }),
'it should display an error for an invalid username'
);
});
fillIn("#username-or-email", 'someuser@gmail.com');
click('.email-login');
click('button[title="Reset Password"]');
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('email_login.complete_email_not_found', { email: 'someuser@gmail.com' }),
'it should display the right error message'
find(".alert-error").html().trim(),
I18n.t('forgot_password.complete_email_not_found', { email: 'someuser@gmail.com' }),
'it should display an error for an invalid email'
);
});
@ -59,32 +59,29 @@ QUnit.test("logging in via email", assert => {
userFound = true;
});
click('.email-login');
click('button[title="Reset Password"]');
andThen(() => {
assert.notOk(exists(find(".alert-error")), 'it should remove the flash error when succeeding');
assert.equal(
find(".modal-body").html().trim(),
I18n.t('email_login.complete_username_found', { username: 'someuser' }),
'it should display the right message'
I18n.t('forgot_password.complete_username_found', { username: 'someuser' }),
'it should display a success message for a valid username'
);
});
visit("/");
click("header .login-button");
andThen(() => {
assert.ok(exists('.login-modal'), "it shows the login modal");
});
click('#forgot-password-link');
fillIn("#username-or-email", 'someuser@gmail.com');
click('.email-login');
click('button[title="Reset Password"]');
andThen(() => {
assert.equal(
find(".modal-body").html().trim(),
I18n.t('email_login.complete_email_found', { email: 'someuser@gmail.com' }),
'it should display the right message'
I18n.t('forgot_password.complete_email_found', { email: 'someuser@gmail.com' }),
'it should display a success message for a valid email'
);
});
});

View File

@ -0,0 +1,116 @@
import { acceptance } from "helpers/qunit-helpers";
let userFound = false;
acceptance("Login with email", {
settings: {
enable_local_logins_via_email: true,
enable_facebook_logins: true
},
beforeEach() {
const response = object => {
return [
200,
{ "Content-Type": "application/json" },
object
];
};
server.post('/u/email-login', () => { // eslint-disable-line no-undef
return response({ "user_found": userFound });
});
}
});
QUnit.test("logging in via email (link)", assert => {
visit("/");
click("header .login-button");
andThen(() => {
assert.notOk(exists(".login-with-email-link"), 'it displays the link only when field is filled');
});
fillIn("#login-account-name", "someuser");
click(".login-with-email-link");
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('email_login.complete_username_not_found', { username: 'someuser' }),
'it should display an error for an invalid username'
);
});
fillIn("#login-account-name", 'someuser@gmail.com');
click('.login-with-email-link');
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('email_login.complete_email_not_found', { email: 'someuser@gmail.com' }),
'it should display an error for an invalid email'
);
});
fillIn("#login-account-name", 'someuser');
andThen(() => {
userFound = true;
});
click('.login-with-email-link');
andThen(() => {
assert.equal(
find(".alert-success").html().trim(),
I18n.t('email_login.complete_username_found', { username: 'someuser' }),
'it should display a success message for a valid username'
);
});
visit("/");
click("header .login-button");
fillIn("#login-account-name", 'someuser@gmail.com');
click('.login-with-email-link');
andThen(() => {
assert.equal(
find(".alert-success").html().trim(),
I18n.t('email_login.complete_email_found', { email: 'someuser@gmail.com' }),
'it should display a success message for a valid email'
);
});
andThen(() => {
userFound = false;
});
});
QUnit.test("logging in via email (button)", assert => {
visit("/");
click("header .login-button");
click('.login-with-email-button');
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('login.blank_username'),
'it should display an error for blank username'
);
});
andThen(() => {
userFound = true;
});
fillIn("#login-account-name", 'someuser');
click('.login-with-email-button');
andThen(() => {
assert.equal(
find(".alert-success").html().trim(),
I18n.t('email_login.complete_username_found', { username: 'someuser' }),
'it should display a success message for a valid username'
);
});
});