FEATURE: Let users update their emails before confirming

This allows users who entered a typo or invalid email address when
signing up an opportunity to fix it and resending the confirmation
email to that address.
This commit is contained in:
Robin Ward 2017-04-05 16:14:22 -04:00
parent d1c79372d7
commit 40ab2e5667
19 changed files with 255 additions and 30 deletions

View File

@ -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

View File

@ -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'));
}
}
});

View File

@ -0,0 +1,5 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
export default Ember.Controller.extend(ModalFunctionality, {
modal: null
});

View File

@ -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);
}
}
});

View File

@ -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;
};

View File

@ -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');
}
}
});

View File

@ -0,0 +1,3 @@
<div class="modal-footer">
<button class='btn btn-primary' {{action closeModal}}>{{i18n 'close'}}</button>
</div>

View File

@ -0,0 +1,12 @@
{{#d-modal-body}}
<p>{{i18n "login.provide_new_email"}}</p>
{{input value=newEmail class="activate-new-email"}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action="changeEmail"
label="login.submit_new_email"
disabled=submitDisabled
class="btn-primary"}}
{{d-button action="closeModal" label="close"}}
</div>

View File

@ -0,0 +1,5 @@
{{#d-modal-body}}
{{{i18n 'login.sent_activation_email_again' currentEmail=currentEmail}}}
{{/d-modal-body}}
{{modal-footer-close closeModal=(action "closeModal")}}

View File

@ -1,12 +1,14 @@
{{#d-modal-body}}
{{#if emailSent}}
{{{i18n 'login.sent_activation_email_again' currentEmail=currentEmail}}}
{{else}}
{{{i18n 'login.not_activated' sentTo=sentTo}}}
<a href {{action "sendActivationEmail"}} class="resend-link">{{i18n 'login.resend_activation_email'}}</a>
{{/if}}
{{{i18n 'login.not_activated' sentTo=sentTo}}}
{{/d-modal-body}}
<div class="modal-footer">
<button class='btn btn-primary' {{action "closeModal"}}>{{i18n 'close'}}</button>
{{d-button action="sendActivationEmail"
label="login.resend_title"
icon="envelope"
class="btn-primary resend"}}
{{d-button action="editActivationEmail"
label="login.change_email"
icon="pencil"
class="edit-email"}}
</div>

View File

@ -376,3 +376,11 @@
}
}
}
.modal-button-bar {
margin-top: 1em;
button {
margin-right: 0.5em;
}
}

View File

@ -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!

View File

@ -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 <b>{{currentEmail}}</b>. 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."

View File

@ -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: "<p>You're almost done! We sent an activation mail to <b>%{email}</b>. Please follow the instructions in the email to activate your account.</p><p>If it doesn't arrive, check your spam folder, or try to log in again to send another activation mail.</p>"
activate_email: "<p>You're almost done! We sent an activation mail to <b>%{email}</b>. Please follow the instructions in the email to activate your account.</p><p>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.</p>"
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."

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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(), '<small>current@example.com</small>');
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("/");

View File

@ -195,10 +195,18 @@ export default function() {
current_email: '<small>current@example.com</small>' });
}
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"});