diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index ce7addad87..29c8e300b1 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -25,6 +25,7 @@ //= require ./discourse/lib/computed //= require ./discourse/lib/formatter //= require ./discourse/lib/eyeline +//= require ./discourse/lib/show-modal //= require ./discourse/mixins/scrolling //= require ./discourse/models/model //= require ./discourse/models/rest @@ -68,7 +69,6 @@ //= require ./discourse/lib/emoji/groups //= require ./discourse/lib/emoji/toolbar //= require ./discourse/components/d-editor -//= require ./discourse/lib/show-modal //= require ./discourse/lib/screen-track //= require ./discourse/routes/discourse //= require ./discourse/routes/build-topic-route diff --git a/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 b/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 new file mode 100644 index 0000000000..ddd8a2a562 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 @@ -0,0 +1,36 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; +import { extractError } from 'discourse/lib/ajax-error'; +import { userPath } from 'discourse/lib/url'; + +export default Ember.Controller.extend(ModalFunctionality, { + login: Ember.inject.controller(), + + currentEmail: null, + newEmail: null, + password: null, + + @computed('newEmail', 'currentEmail') + submitDisabled(newEmail, currentEmail) { + return newEmail === currentEmail; + }, + + actions: { + changeEmail() { + const login = this.get('login'); + + ajax(userPath('update-activation-email'), { + data: { + username: login.get('loginName'), + password: login.get('loginPassword'), + email: this.get('newEmail') + }, + type: 'PUT' + }).then(() => { + const modal = this.showModal('activation-resent', {title: 'log_in'}); + modal.set('currentEmail', this.get('newEmail')); + }).catch(err => this.flash(extractError(err), 'error')); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/basic-modal-body.js.es6 b/app/assets/javascripts/discourse/controllers/basic-modal-body.js.es6 new file mode 100644 index 0000000000..f7001555a9 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/basic-modal-body.js.es6 @@ -0,0 +1,5 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; + +export default Ember.Controller.extend(ModalFunctionality, { + modal: null +}); diff --git a/app/assets/javascripts/discourse/controllers/not-activated.js.es6 b/app/assets/javascripts/discourse/controllers/not-activated.js.es6 index cf0164b0e6..976e1311bd 100644 --- a/app/assets/javascripts/discourse/controllers/not-activated.js.es6 +++ b/app/assets/javascripts/discourse/controllers/not-activated.js.es6 @@ -4,21 +4,23 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend(ModalFunctionality, { - emailSent: false, - - onShow() { - this.set("emailSent", false); - }, - actions: { sendActivationEmail() { ajax(userPath('action/send_activation_email'), { data: { username: this.get('username') }, type: 'POST' }).then(() => { - this.set('emailSent', true); + const modal = this.showModal('activation-resent', {title: 'log_in'}); + modal.set('currentEmail', this.get('currentEmail')); }).catch(popupAjaxError); + }, + + editActivationEmail() { + const modal = this.showModal('activation-edit', {title: 'login.change_email'}); + + const currentEmail = this.get('currentEmail'); + modal.set('currentEmail', currentEmail); + modal.set('newEmail', currentEmail); } } - }); diff --git a/app/assets/javascripts/discourse/lib/show-modal.js.es6 b/app/assets/javascripts/discourse/lib/show-modal.js.es6 index ed2457e065..739fdd17b3 100644 --- a/app/assets/javascripts/discourse/lib/show-modal.js.es6 +++ b/app/assets/javascripts/discourse/lib/show-modal.js.es6 @@ -11,17 +11,23 @@ export default function(name, opts) { const controllerName = opts.admin ? `modals/${name}` : name; - const controller = container.lookup('controller:' + controllerName); + let controller = container.lookup('controller:' + controllerName); const templateName = opts.templateName || Ember.String.dasherize(name); const renderArgs = { into: 'modal', outlet: 'modalBody'}; - if (controller) { renderArgs.controller = controllerName; } + if (controller) { + renderArgs.controller = controllerName; + } else { + // use a basic controller + renderArgs.controller = 'basic-modal-body'; + controller = container.lookup(`controller:${renderArgs.controller}`); + } + if (opts.addModalBodyView) { renderArgs.view = 'modal-body'; } - const modalName = `modal/${templateName}`; const fullName = opts.admin ? `admin/templates/${modalName}` : modalName; route.render(fullName, renderArgs); @@ -29,13 +35,11 @@ export default function(name, opts) { modalController.set('title', I18n.t(opts.title)); } - if (controller) { - controller.set('modal', modalController); - const model = opts.model; - if (model) { controller.set('model', model); } - if (controller.onShow) { controller.onShow(); } - controller.set('flashMessage', null); - } + controller.set('modal', modalController); + const model = opts.model; + if (model) { controller.set('model', model); } + if (controller.onShow) { controller.onShow(); } + controller.set('flashMessage', null); return controller; }; diff --git a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 index 23bc790e15..d78478dae6 100644 --- a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 +++ b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 @@ -1,5 +1,17 @@ +import showModal from 'discourse/lib/show-modal'; + export default Ember.Mixin.create({ flash(text, messageClass) { this.appEvents.trigger('modal-body:flash', { text, messageClass }); + }, + + showModal(...args) { + return showModal(...args); + }, + + actions: { + closeModal() { + this.get('modal').send('closeModal'); + } } }); diff --git a/app/assets/javascripts/discourse/templates/components/modal-footer-close.hbs b/app/assets/javascripts/discourse/templates/components/modal-footer-close.hbs new file mode 100644 index 0000000000..a1e59ab16b --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/modal-footer-close.hbs @@ -0,0 +1,3 @@ + diff --git a/app/assets/javascripts/discourse/templates/modal/activation-edit.hbs b/app/assets/javascripts/discourse/templates/modal/activation-edit.hbs new file mode 100644 index 0000000000..735f4912a7 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/activation-edit.hbs @@ -0,0 +1,12 @@ +{{#d-modal-body}} +

{{i18n "login.provide_new_email"}}

+ {{input value=newEmail class="activate-new-email"}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/modal/activation-resent.hbs b/app/assets/javascripts/discourse/templates/modal/activation-resent.hbs new file mode 100644 index 0000000000..e694c838bf --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/activation-resent.hbs @@ -0,0 +1,5 @@ +{{#d-modal-body}} + {{{i18n 'login.sent_activation_email_again' currentEmail=currentEmail}}} +{{/d-modal-body}} + +{{modal-footer-close closeModal=(action "closeModal")}} diff --git a/app/assets/javascripts/discourse/templates/modal/not-activated.hbs b/app/assets/javascripts/discourse/templates/modal/not-activated.hbs index f8e4aeee29..4ed586628b 100644 --- a/app/assets/javascripts/discourse/templates/modal/not-activated.hbs +++ b/app/assets/javascripts/discourse/templates/modal/not-activated.hbs @@ -1,12 +1,14 @@ {{#d-modal-body}} - {{#if emailSent}} - {{{i18n 'login.sent_activation_email_again' currentEmail=currentEmail}}} - {{else}} - {{{i18n 'login.not_activated' sentTo=sentTo}}} - {{i18n 'login.resend_activation_email'}} - {{/if}} + {{{i18n 'login.not_activated' sentTo=sentTo}}} {{/d-modal-body}} diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 8fd7eff32a..c69d4a4058 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -376,3 +376,11 @@ } } } + +.modal-button-bar { + margin-top: 1em; + + button { + margin-right: 0.5em; + } +} diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7ca6f0d380..c548d232a2 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -26,6 +26,7 @@ class UsersController < ApplicationController :activate_account, :perform_account_activation, :send_activation_email, + :update_activation_email, :password_reset, :confirm_email_token, :admin_login, @@ -569,6 +570,28 @@ class UsersController < ApplicationController render layout: 'no_ember' end + def update_activation_email + RateLimiter.new(nil, "activate-edit-email-hr-#{request.remote_ip}", 5, 1.hour).performed! + + @user = User.find_by_username_or_email(params[:username]) + raise Discourse::InvalidAccess.new unless @user.present? + raise Discourse::InvalidAccess.new if @user.active? + raise Discourse::InvalidAccess.new if current_user.present? + + raise Discourse::InvalidAccess.new unless @user.confirm_password?(params[:password]) + + User.transaction do + @user.email = params[:email] + if @user.save + @user.email_tokens.create(email: @user.email) + enqueue_activation_email + render json: success_json + else + render_json_error(@user) + end + end + end + def send_activation_email if current_user.blank? || !current_user.staff? RateLimiter.new(nil, "activate-hr-#{request.remote_ip}", 30, 1.hour).performed! diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 29b0d2a179..f3db3d5853 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1040,6 +1040,12 @@ en: not_allowed_from_ip_address: "You can't login from that IP address." admin_not_allowed_from_ip_address: "You can't log in as admin from that IP address." resend_activation_email: "Click here to send the activation email again." + + resend_title: "Resend Activation Email" + change_email: "Change Email Address" + provide_new_email: "Provide a new address and we'll resend your confirmation email." + submit_new_email: "Update Email Address" + sent_activation_email_again: "We sent another activation email to you at {{currentEmail}}. It might take a few minutes for it to arrive; be sure to check your spam folder." to_continue: "Please Log In" preferences: "You need to be logged in to change your user preferences." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e952de369d..4fccc2ace2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1655,7 +1655,7 @@ en: incorrect_username_email_or_password: "Incorrect username, email or password" wait_approval: "Thanks for signing up. We will notify you when your account has been approved." active: "Your account is activated and ready to use." - activate_email: "

You're almost done! We sent an activation mail to %{email}. Please follow the instructions in the email to activate your account.

If it doesn't arrive, check your spam folder, or try to log in again to send another activation mail.

" + activate_email: "

You're almost done! We sent an activation mail to %{email}. Please follow the instructions in the email to activate your account.

If it doesn't arrive, check your spam folder, or try to log in again to send another activation mail or to input a new email address.

" not_activated: "You can't log in yet. We sent an activation email to you. Please follow the instructions in the email to activate your account." not_allowed_from_ip_address: "You can't log in as %{username} from that IP address." admin_not_allowed_from_ip_address: "You can't log in as admin from that IP address." diff --git a/config/routes.rb b/config/routes.rb index 8456bbb50b..b8091c7939 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -308,6 +308,7 @@ Discourse::Application.routes.draw do end end + put "#{root_path}/update-activation-email" => "users#update_activation_email" get "#{root_path}/hp" => "users#get_honeypot_value" get "#{root_path}/admin-login" => "users#admin_login" put "#{root_path}/admin-login" => "users#admin_login" diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 5d6ed23105..3b2f5f0d5a 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -1872,4 +1872,77 @@ describe UsersController do end + + describe '.update_activation_email' do + + it "raises an error with an invalid username" do + xhr :put, :update_activation_email, { + username: 'eviltrout', + password: 'invalid-password', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error with an invalid password" do + xhr :put, :update_activation_email, { + username: Fabricate(:inactive_user).username, + password: 'invalid-password', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error for an active user" do + xhr :put, :update_activation_email, { + username: Fabricate(:walter_white).username, + password: 'letscook', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error when logged in" do + log_in(:moderator) + + xhr :put, :update_activation_email, { + username: Fabricate(:inactive_user).username, + password: 'qwerqwer123', + email: 'updatedemail@example.com' + } + expect(response).to_not be_success + end + + it "raises an error when the new email is taken" do + user = Fabricate(:user) + + xhr :put, :update_activation_email, { + username: Fabricate(:inactive_user).username, + password: 'qwerqwer123', + email: user.email + } + expect(response).to_not be_success + end + + it "can be updated" do + user = Fabricate(:inactive_user) + token = user.email_tokens.first + + xhr :put, :update_activation_email, { + username: user.username, + password: 'qwerqwer123', + email: 'updatedemail@example.com' + } + + expect(response).to be_success + + user.reload + expect(user.email).to eq('updatedemail@example.com') + expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present + + token.reload + expect(token.expired?).to eq(true) + end + end + end diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb index 5cad8011f9..a635af3289 100644 --- a/spec/fabricators/user_fabricator.rb +++ b/spec/fabricators/user_fabricator.rb @@ -36,6 +36,7 @@ Fabricator(:inactive_user, from: :user) do name 'Inactive User' username 'inactive_user' email 'inactive@idontexist.com' + password 'qwerqwer123' active false end diff --git a/test/javascripts/acceptance/sign-in-test.js.es6 b/test/javascripts/acceptance/sign-in-test.js.es6 index d8efb4be18..21403770ba 100644 --- a/test/javascripts/acceptance/sign-in-test.js.es6 +++ b/test/javascripts/acceptance/sign-in-test.js.es6 @@ -41,16 +41,40 @@ test("sign in - not activated", () => { ok(!exists('.modal-body small'), 'it escapes the email address'); }); - click('.modal-body .resend-link'); + click('.modal-footer button.resend'); andThen(() => { equal(find('.modal-body b').text(), 'current@example.com'); ok(!exists('.modal-body small'), 'it escapes the email address'); }); - - }); }); +test("sign in - not activated - edit email", () => { + visit("/"); + andThen(() => { + click("header .login-button"); + andThen(() => { + ok(exists('.login-modal'), "it shows the login modal"); + }); + + fillIn('#login-account-name', 'eviltrout'); + fillIn('#login-account-password', 'not-activated-edit'); + click('.modal-footer .btn-primary'); + click('.modal-footer button.edit-email'); + andThen(() => { + equal(find('.activate-new-email').val(), 'current@example.com'); + equal(find('.modal-footer .btn-primary:disabled').length, 1, "must change email"); + }); + fillIn('.activate-new-email', 'different@example.com'); + andThen(() => { + equal(find('.modal-footer .btn-primary:disabled').length, 0); + }); + click(".modal-footer .btn-primary"); + andThen(() => { + equal(find('.modal-body b').text(), 'different@example.com'); + }); + }); +}); test("create account", () => { visit("/"); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index a704196dce..8ae140d922 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -195,10 +195,18 @@ export default function() { current_email: 'current@example.com' }); } + if (data.password === 'not-activated-edit') { + return response({ error: "not active", + reason: "not_activated", + sent_to_email: 'eviltrout@example.com', + current_email: 'current@example.com' }); + } + return response(400, {error: 'invalid login'}); }); this.post('/u/action/send_activation_email', success); + this.put('/u/update-activation-email', success); this.get('/u/hp.json', function() { return response({"value":"32faff1b1ef1ac3","challenge":"61a3de0ccf086fb9604b76e884d75801"});