diff --git a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 index 660456db7f5..8de322600d6 100644 --- a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 @@ -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(); }, }); diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index 4c68d1740f2..aa90e565c73 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -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); } } }); diff --git a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 index 7e385fb3022..a5dff79c90f 100644 --- a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 +++ b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 @@ -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; - }, }); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 31d339b7c05..7646c8ecb34 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -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); + }); } }, diff --git a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 index d78478dae63..b34b906bdcd 100644 --- a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 +++ b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 @@ -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); }, diff --git a/app/assets/javascripts/discourse/templates/components/login-buttons.hbs b/app/assets/javascripts/discourse/templates/components/login-buttons.hbs index 776ddff179e..bdd430c4e7a 100644 --- a/app/assets/javascripts/discourse/templates/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/templates/components/login-buttons.hbs @@ -1,3 +1,12 @@ {{#each buttons as |b|}} {{/each}} + +{{#if canLoginLocalWithEmail}} + {{d-button + action="emailLogin" + label="email_login.button_label" + disabled=processingEmailLink + icon="envelope-o" + class="login-with-email-button"}} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs index 02ccc7820ee..41353ac8ed1 100644 --- a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs @@ -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}}
@@ -13,6 +18,16 @@ {{text-field value=loginName type="email" placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off"}} + {{#if showLoginWithEmailLink}} + + + + + + + {{/if}} diff --git a/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs index 8b42f88739d..dd243927340 100644 --- a/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs +++ b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs @@ -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" diff --git a/app/assets/javascripts/discourse/templates/modal/login.hbs b/app/assets/javascripts/discourse/templates/modal/login.hbs index 75e2a59ffe4..37ce830c25d 100644 --- a/app/assets/javascripts/discourse/templates/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/modal/login.hbs @@ -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}}
@@ -8,7 +13,13 @@ {{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus"}} - + + {{#if showLoginWithEmailLink}} + + {{/if}} + diff --git a/app/assets/stylesheets/common/components/buttons.scss b/app/assets/stylesheets/common/components/buttons.scss index 2dd074265be..f0e2255a0dc 100644 --- a/app/assets/stylesheets/common/components/buttons.scss +++ b/app/assets/stylesheets/common/components/buttons.scss @@ -128,7 +128,7 @@ &:before { margin-right: 9px; font-family: FontAwesome; - font-size: 1.214em; + font-size: $font-0; } &.google, &.google_oauth2 { background: $google; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 284f70a6fd5..dfb6f115a83 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 %{username}, you should receive an email with a magic login link shortly." complete_email: "If an account matches %{email}, you should receive an email with a magic login link shortly." complete_username_found: "We found an account that matches the username %{username}, 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..." diff --git a/test/javascripts/acceptance/forgot-password-test.js.es6 b/test/javascripts/acceptance/forgot-password-test.js.es6 index d3a868cb1f2..5e21fc8b60d 100644 --- a/test/javascripts/acceptance/forgot-password-test.js.es6 +++ b/test/javascripts/acceptance/forgot-password-test.js.es6 @@ -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' ); }); }); diff --git a/test/javascripts/acceptance/login-with-email-test.js.es6 b/test/javascripts/acceptance/login-with-email-test.js.es6 new file mode 100644 index 00000000000..7c575ce9a3b --- /dev/null +++ b/test/javascripts/acceptance/login-with-email-test.js.es6 @@ -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' + ); + }); +});