FEATURE: Allow users to resend/update email from confirmation page

This commit is contained in:
Robin Ward 2017-05-02 15:57:55 -04:00
parent b381372184
commit 12fb20fe1b
25 changed files with 402 additions and 102 deletions

View File

@ -134,7 +134,6 @@ export function buildResolver(baseName) {
if (full.indexOf('connectors') === 0) { if (full.indexOf('connectors') === 0) {
return Ember.TEMPLATES[`javascripts/${full}`]; return Ember.TEMPLATES[`javascripts/${full}`];
} }
}, },
resolveTemplate(parsedName) { resolveTemplate(parsedName) {

View File

@ -0,0 +1,3 @@
export default Ember.Component.extend({
classNames: 'activation-controls'
});

View File

@ -0,0 +1,27 @@
import { changeEmail } from 'discourse/lib/user-activation';
import computed from 'ember-addons/ember-computed-decorators';
import { popupAjaxError } from 'discourse/lib/ajax-error';
export default Ember.Controller.extend({
accountCreated: null,
newEmail: null,
@computed('newEmail', 'accountCreated.email')
submitDisabled(newEmail, currentEmail) {
return newEmail === currentEmail;
},
actions: {
changeEmail() {
const email = this.get('newEmail');
changeEmail({ email }).then(() => {
this.set('accountCreated.email', email);
this.transitionToRoute('account-created.resent');
}).catch(popupAjaxError);
},
cancel() {
this.transitionToRoute('account-created.index');
}
}
});

View File

@ -0,0 +1,15 @@
import { resendActivationEmail } from 'discourse/lib/user-activation';
export default Ember.Controller.extend({
actions: {
sendActivationEmail() {
resendActivationEmail(this.get('accountCreated.username')).then(() => {
this.transitionToRoute('account-created.resent');
});
},
editActivationEmail() {
this.transitionToRoute('account-created.edit-email');
}
}
});

View File

@ -1,8 +1,7 @@
import computed from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators';
import ModalFunctionality from 'discourse/mixins/modal-functionality'; import ModalFunctionality from 'discourse/mixins/modal-functionality';
import { ajax } from 'discourse/lib/ajax';
import { extractError } from 'discourse/lib/ajax-error'; import { extractError } from 'discourse/lib/ajax-error';
import { userPath } from 'discourse/lib/url'; import { changeEmail } from 'discourse/lib/user-activation';
export default Ember.Controller.extend(ModalFunctionality, { export default Ember.Controller.extend(ModalFunctionality, {
login: Ember.inject.controller(), login: Ember.inject.controller(),
@ -20,13 +19,10 @@ export default Ember.Controller.extend(ModalFunctionality, {
changeEmail() { changeEmail() {
const login = this.get('login'); const login = this.get('login');
ajax(userPath('update-activation-email'), { changeEmail({
data: { username: login.get('loginName'),
username: login.get('loginName'), password: login.get('loginPassword'),
password: login.get('loginPassword'), email: this.get('newEmail')
email: this.get('newEmail')
},
type: 'PUT'
}).then(() => { }).then(() => {
const modal = this.showModal('activation-resent', {title: 'log_in'}); const modal = this.showModal('activation-resent', {title: 'log_in'});
modal.set('currentEmail', this.get('newEmail')); modal.set('currentEmail', this.get('newEmail'));

View File

@ -1,18 +1,13 @@
import { ajax } from 'discourse/lib/ajax';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import ModalFunctionality from 'discourse/mixins/modal-functionality'; import ModalFunctionality from 'discourse/mixins/modal-functionality';
import { userPath } from 'discourse/lib/url'; import { resendActivationEmail } from 'discourse/lib/user-activation';
export default Ember.Controller.extend(ModalFunctionality, { export default Ember.Controller.extend(ModalFunctionality, {
actions: { actions: {
sendActivationEmail() { sendActivationEmail() {
ajax(userPath('action/send_activation_email'), { resendActivationEmail(this.get('username')).then(() => {
data: { username: this.get('username') },
type: 'POST'
}).then(() => {
const modal = this.showModal('activation-resent', {title: 'log_in'}); const modal = this.showModal('activation-resent', {title: 'log_in'});
modal.set('currentEmail', this.get('currentEmail')); modal.set('currentEmail', this.get('currentEmail'));
}).catch(popupAjaxError); });
}, },
editActivationEmail() { editActivationEmail() {

View File

@ -0,0 +1,15 @@
import { ajax } from 'discourse/lib/ajax';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import { userPath } from 'discourse/lib/url';
export function resendActivationEmail(username) {
return ajax(userPath('action/send_activation_email'), {
type: 'POST',
data: { username }
}).catch(popupAjaxError);
}
export function changeEmail(data) {
return ajax(userPath('update-activation-email'), { data, type: 'PUT' });
}

View File

@ -0,0 +1,7 @@
export default Ember.Route.extend({
setupController(controller) {
const accountCreated = this.controllerFor('account-created').get('accountCreated');
controller.set('accountCreated', accountCreated);
controller.set('newEmail', accountCreated.email);
}
});

View File

@ -0,0 +1,9 @@
export default Ember.Route.extend({
setupController(controller) {
controller.set(
'accountCreated',
this.controllerFor('account-created').get('accountCreated')
);
}
});

View File

@ -0,0 +1,8 @@
export default Ember.Route.extend({
setupController(controller) {
controller.set(
'email',
this.controllerFor('account-created').get('accountCreated.email')
);
}
});

View File

@ -64,7 +64,10 @@ export default function() {
// User routes // User routes
this.route('users', { resetNamespace: true, path: '/u' }); this.route('users', { resetNamespace: true, path: '/u' });
this.route('password-reset', { path: '/u/password-reset/:token' }); this.route('password-reset', { path: '/u/password-reset/:token' });
this.route('account-created', { path: '/u/account-created' }); this.route('account-created', { path: '/u/account-created' }, function() {
this.route('resent');
this.route('edit-email');
});
this.route('user', { path: '/u/:username', resetNamespace: true }, function() { this.route('user', { path: '/u/:username', resetNamespace: true }, function() {
this.route('summary'); this.route('summary');
this.route('userActivity', { path: '/activity', resetNamespace: true }, function() { this.route('userActivity', { path: '/activity', resetNamespace: true }, function() {

View File

@ -1,3 +1,5 @@
<div id='simple-container'> <div id='simple-container'>
<div class='account-created'>{{{accountCreated.message}}}</div> <div class='account-created'>
{{outlet}}
</div>
</div> </div>

View File

@ -0,0 +1,11 @@
<div class='ac-message'>
{{activation-email-form email=newEmail}}
</div>
<div class='activation-controls'>
{{d-button action="changeEmail"
label="login.submit_new_email"
disabled=submitDisabled
class="btn-primary"}}
{{d-button action=(action "cancel") label="cancel" class="edit-cancel"}}
</div>

View File

@ -0,0 +1,7 @@
<div class='ac-message'>
{{{accountCreated.message}}}
</div>
{{#if accountCreated.username}}
{{activation-controls sendActivationEmail=(action "sendActivationEmail")
editActivationEmail=(action "editActivationEmail")}}
{{/if}}

View File

@ -0,0 +1,3 @@
<div class='ac-message'>
{{{i18n 'login.sent_activation_email_again' currentEmail=email}}}
</div>

View File

@ -0,0 +1,8 @@
{{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"}}

View File

@ -0,0 +1,2 @@
<p>{{i18n "login.provide_new_email"}}</p>
{{input value=email class="activate-new-email"}}

View File

@ -1,6 +1,5 @@
{{#d-modal-body}} {{#d-modal-body}}
<p>{{i18n "login.provide_new_email"}}</p> {{activation-email-form email=newEmail}}
{{input value=newEmail class="activate-new-email"}}
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">

View File

@ -3,12 +3,6 @@
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">
{{d-button action="sendActivationEmail" {{activation-controls sendActivationEmail=(action "sendActivationEmail")
label="login.resend_title" editActivationEmail=(action "editActivationEmail")}}
icon="envelope"
class="btn-primary resend"}}
{{d-button action="editActivationEmail"
label="login.change_email"
icon="pencil"
class="edit-email"}}
</div> </div>

View File

@ -9,7 +9,15 @@
margin: 0 auto; margin: 0 auto;
.account-created { .account-created {
font-size: 16px; .ac-message {
line-height: 24px; font-size: 16px;
line-height: 24px;
}
.activation-controls {
button {
margin-right: 0.5em;
}
}
} }
} }

View File

@ -357,6 +357,7 @@ class UsersController < ApplicationController
# save user email in session, to show on account-created page # save user email in session, to show on account-created page
session["user_created_message"] = activation.message session["user_created_message"] = activation.message
session[SessionController::ACTIVATE_USER_KEY] = user.id
render json: { render json: {
success: true, success: true,
@ -534,8 +535,16 @@ class UsersController < ApplicationController
def account_created def account_created
@custom_body_class = "static-account-created" @custom_body_class = "static-account-created"
@message = session['user_created_message'] || I18n.t('activation.missing_session') @message = session['user_created_message'] || I18n.t('activation.missing_session')
store_preloaded("accountCreated", MultiJson.dump(message: @message)) @account_created = { message: @message }
if session_user_id = session[SessionController::ACTIVATE_USER_KEY]
if user = User.where(id: session_user_id.to_i).first
@account_created[:username] = user.username
@account_created[:email] = user.email
end
end
store_preloaded("accountCreated", MultiJson.dump(@account_created))
expires_now expires_now
render "default/empty" render "default/empty"
end end
@ -573,13 +582,18 @@ class UsersController < ApplicationController
def update_activation_email def update_activation_email
RateLimiter.new(nil, "activate-edit-email-hr-#{request.remote_ip}", 5, 1.hour).performed! RateLimiter.new(nil, "activate-edit-email-hr-#{request.remote_ip}", 5, 1.hour).performed!
@user = User.find_by_username_or_email(params[:username]) if params[:username].present?
@user = User.find_by_username_or_email(params[:username])
raise Discourse::InvalidAccess.new unless @user.present?
raise Discourse::InvalidAccess.new unless @user.confirm_password?(params[:password])
elsif user_key = session[SessionController::ACTIVATE_USER_KEY]
@user = User.where(id: user_key.to_i).first
end
raise Discourse::InvalidAccess.new unless @user.present? raise Discourse::InvalidAccess.new unless @user.present?
raise Discourse::InvalidAccess.new if @user.active? raise Discourse::InvalidAccess.new if @user.active?
raise Discourse::InvalidAccess.new if current_user.present? raise Discourse::InvalidAccess.new if current_user.present?
raise Discourse::InvalidAccess.new unless @user.confirm_password?(params[:password])
User.transaction do User.transaction do
@user.email = params[:email] @user.email = params[:email]
if @user.save if @user.save
@ -598,8 +612,9 @@ class UsersController < ApplicationController
RateLimiter.new(nil, "activate-min-#{request.remote_ip}", 6, 1.minute).performed! RateLimiter.new(nil, "activate-min-#{request.remote_ip}", 6, 1.minute).performed!
end end
@user = User.find_by_username_or_email(params[:username].to_s) if params[:username].present?
@user = User.find_by_username_or_email(params[:username].to_s)
end
raise Discourse::NotFound unless @user raise Discourse::NotFound unless @user
if !current_user&.staff? && if !current_user&.staff? &&

View File

@ -1661,7 +1661,7 @@ en:
incorrect_username_email_or_password: "Incorrect username, email or password" 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." 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." 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 or to input a new email address.</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, send another activation mail or 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_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." 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." admin_not_allowed_from_ip_address: "You can't log in as admin from that IP address."

View File

@ -324,6 +324,8 @@ Discourse::Application.routes.draw do
post "#{root_path}/read-faq" => "users#read_faq" post "#{root_path}/read-faq" => "users#read_faq"
get "#{root_path}/search/users" => "users#search_users" get "#{root_path}/search/users" => "users#search_users"
get "#{root_path}/account-created/" => "users#account_created" get "#{root_path}/account-created/" => "users#account_created"
get "#{root_path}/account-created/resent" => "users#account_created"
get "#{root_path}/account-created/edit-email" => "users#account_created"
get({ "#{root_path}/password-reset/:token" => "users#password_reset" }.merge(index == 1 ? { as: :password_reset_token } : {})) get({ "#{root_path}/password-reset/:token" => "users#password_reset" }.merge(index == 1 ? { as: :password_reset_token } : {}))
get "#{root_path}/confirm-email-token/:token" => "users#confirm_email_token", constraints: { format: 'json' } get "#{root_path}/confirm-email-token/:token" => "users#confirm_email_token", constraints: { format: 'json' }
put "#{root_path}/password-reset/:token" => "users#password_reset" put "#{root_path}/password-reset/:token" => "users#password_reset"

View File

@ -482,6 +482,7 @@ describe UsersController do
# should save user_created_message in session # should save user_created_message in session
expect(session["user_created_message"]).to be_present expect(session["user_created_message"]).to be_present
expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present
end end
context "and 'must approve users' site setting is enabled" do context "and 'must approve users' site setting is enabled" do
@ -591,6 +592,7 @@ describe UsersController do
# should save user_created_message in session # should save user_created_message in session
expect(session["user_created_message"]).to be_present expect(session["user_created_message"]).to be_present
expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present
end end
it "shows the 'active' message" do it "shows the 'active' message" do
@ -676,6 +678,7 @@ describe UsersController do
# should not change the session # should not change the session
expect(session["user_created_message"]).to be_blank expect(session["user_created_message"]).to be_blank
expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank
end end
end end
@ -720,6 +723,7 @@ describe UsersController do
# should not change the session # should not change the session
expect(session["user_created_message"]).to be_blank expect(session["user_created_message"]).to be_blank
expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank
end end
end end
@ -1872,74 +1876,165 @@ describe UsersController do
describe '.update_activation_email' do describe '.update_activation_email' do
it "raises an error with an invalid username" do context "with a session variable" do
xhr :put, :update_activation_email, {
username: 'eviltrout', it "raises an error with an invalid session value" do
password: 'invalid-password', session[SessionController::ACTIVATE_USER_KEY] = 1234
email: 'updatedemail@example.com' xhr :put, :update_activation_email, { email: 'updatedemail@example.com' }
} expect(response).to_not be_success
expect(response).to_not be_success end
it "raises an error for an active user" do
user = Fabricate(:walter_white)
session[SessionController::ACTIVATE_USER_KEY] = user.id
xhr :put, :update_activation_email, { email: 'updatedemail@example.com' }
expect(response).to_not be_success
end
it "raises an error when logged in" do
moderator = log_in(:moderator)
session[SessionController::ACTIVATE_USER_KEY] = moderator.id
xhr :put, :update_activation_email, { email: 'updatedemail@example.com' }
expect(response).to_not be_success
end
it "raises an error when the new email is taken" do
active_user = Fabricate(:user)
user = Fabricate(:inactive_user)
session[SessionController::ACTIVATE_USER_KEY] = user.id
xhr :put, :update_activation_email, { email: active_user.email }
expect(response).to_not be_success
end
it "can be updated" do
user = Fabricate(:inactive_user)
token = user.email_tokens.first
session[SessionController::ACTIVATE_USER_KEY] = user.id
xhr :put, :update_activation_email, { 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
it "raises an error with an invalid password" do context "with a username and password" do
xhr :put, :update_activation_email, { it "raises an error with an invalid username" do
username: Fabricate(:inactive_user).username, xhr :put, :update_activation_email, {
password: 'invalid-password', username: 'eviltrout',
email: 'updatedemail@example.com' password: 'invalid-password',
} email: 'updatedemail@example.com'
expect(response).to_not be_success }
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
it "raises an error for an active user" do end
xhr :put, :update_activation_email, {
username: Fabricate(:walter_white).username, context "account_created" do
password: 'letscook',
email: 'updatedemail@example.com' it "returns a message when no session is present" do
} get :account_created
expect(response).to_not be_success created = assigns(:account_created)
expect(created).to be_present
expect(created[:message]).to eq(I18n.t('activation.missing_session'))
expect(created[:email]).to be_blank
expect(created[:username]).to be_blank
end end
it "raises an error when logged in" do context "when the user account is created" do
log_in(:moderator) before do
session['user_created_message'] = "Donuts"
end
xhr :put, :update_activation_email, { it "returns the message when set in the session" do
username: Fabricate(:inactive_user).username, get :account_created
password: 'qwerqwer123', created = assigns(:account_created)
email: 'updatedemail@example.com' expect(created).to be_present
} expect(created[:message]).to eq('Donuts')
expect(response).to_not be_success expect(created[:email]).to be_blank
expect(created[:username]).to be_blank
end
it "includes user information when the session variable is present " do
user = Fabricate(:user, active: false)
session[SessionController::ACTIVATE_USER_KEY] = user.id
get :account_created
created = assigns(:account_created)
expect(created).to be_present
expect(created[:message]).to eq('Donuts')
expect(created[:email]).to eq(user.email)
expect(created[:username]).to eq(user.username)
end
end 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
end end

View File

@ -3,15 +3,92 @@ import PreloadStore from 'preload-store';
acceptance("Account Created"); acceptance("Account Created");
test("account created", () => { test("account created - message", assert => {
visit("/u/account-created");
PreloadStore.store('accountCreated', { PreloadStore.store('accountCreated', {
message: "Hello World" message: "Hello World"
}); });
visit("/u/account-created");
andThen(() => { andThen(() => {
ok(exists('.account-created')); assert.ok(exists('.account-created'));
equal(find('.account-created').text(), "Hello World", "it displays the message"); assert.equal(
find('.account-created .ac-message').text().trim(),
"Hello World",
"it displays the message"
);
assert.notOk(exists('.activation-controls'));
}); });
}); });
test("account created - resend email", assert => {
PreloadStore.store('accountCreated', {
message: "Hello World",
username: 'eviltrout',
email: 'eviltrout@example.com'
});
visit("/u/account-created");
andThen(() => {
assert.ok(exists('.account-created'));
assert.equal(
find('.account-created .ac-message').text().trim(),
"Hello World",
"it displays the message"
);
});
click('.activation-controls .resend');
andThen(() => {
assert.equal(currentPath(), "account-created.resent");
const email = find('.account-created .ac-message b').text();
assert.equal(email, 'eviltrout@example.com');
});
});
test("account created - update email - cancel", assert => {
PreloadStore.store('accountCreated', {
message: "Hello World",
username: 'eviltrout',
email: 'eviltrout@example.com'
});
visit("/u/account-created");
click('.activation-controls .edit-email');
andThen(() => {
assert.equal(currentPath(), "account-created.edit-email");
assert.ok(find('.activation-controls .btn-primary:disabled').length);
});
click('.activation-controls .edit-cancel');
andThen(() => {
assert.equal(currentPath(), "account-created.index");
});
});
test("account created - update email - submit", assert => {
PreloadStore.store('accountCreated', {
message: "Hello World",
username: 'eviltrout',
email: 'eviltrout@example.com'
});
visit("/u/account-created");
click('.activation-controls .edit-email');
andThen(() => {
assert.ok(find('.activation-controls .btn-primary:disabled').length);
});
fillIn('.activate-new-email', 'newemail@example.com');
andThen(() => {
assert.notOk(find('.activation-controls .btn-primary:disabled').length);
});
click('.activation-controls .btn-primary');
andThen(() => {
assert.equal(currentPath(), "account-created.resent");
const email = find('.account-created .ac-message b').text();
assert.equal(email, 'newemail@example.com');
});
});