diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb index a6ea8ca1..a5c4585e 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.erb +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -1,5 +1,5 @@
Welcome <%= @resource.email %>!
-You can confirm your account through the link below:
+You can confirm your account email through the link below:
<%= link_to 'Confirm my account', confirmation_url(@resource, :confirmation_token => @resource.confirmation_token) %>
diff --git a/lib/devise.rb b/lib/devise.rb index d05dba07..e147d98f 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -139,6 +139,9 @@ module Devise mattr_accessor :confirmation_keys @@confirmation_keys = [ :email ] + mattr_accessor :confirmation_on_email_change + @@confirmation_on_email_change = false + # Time interval to timeout the user session without activity. mattr_accessor :timeout_in @@timeout_in = 30.minutes diff --git a/lib/devise/models/confirmable.rb b/lib/devise/models/confirmable.rb index 1fdfe5d1..139078e6 100644 --- a/lib/devise/models/confirmable.rb +++ b/lib/devise/models/confirmable.rb @@ -27,15 +27,20 @@ module Devise included do before_create :generate_confirmation_token, :if => :confirmation_required? after_create :send_confirmation_instructions, :if => :confirmation_required? + before_update :prevent_email_change, :if => :prevent_email_change? + after_update :send_confirmation_instructions, :if => :email_change_confirmation_required? end - # Confirm a user by setting its confirmed_at to actual time. If the user - # is already confirmed, add en error to email field + # Confirm a user by setting it's confirmed_at to actual time. If the user + # is already confirmed, add en error to email field. If the user is invalid + # add errors def confirm! unless_confirmed do self.confirmation_token = nil self.confirmed_at = Time.now - save(:validate => false) + self.email = unconfirmed_email if unconfirmed_email.present? + self.unconfirmed_email = nil + save end end @@ -46,6 +51,7 @@ module Devise # Send confirmation instructions by email def send_confirmation_instructions + @email_change_confirmation_required = false generate_confirmation_token! if self.confirmation_token.nil? ::Devise.mailer.confirmation_instructions(self).deliver end @@ -104,10 +110,10 @@ module Devise confirmation_sent_at && confirmation_sent_at.utc >= self.class.confirm_within.ago end - # Checks whether the record is confirmed or not, yielding to the block + # Checks whether the record is confirmed or not or a new email has been added, yielding to the block # if it's already confirmed, otherwise adds an error to email. def unless_confirmed - unless confirmed? + unless confirmed? && unconfirmed_email.blank? yield else self.errors.add(:email, :already_confirmed) @@ -118,7 +124,6 @@ module Devise # Generates a new random token for confirmation, and stores the time # this token is being generated def generate_confirmation_token - self.confirmed_at = nil self.confirmation_token = self.class.confirmation_token self.confirmation_sent_at = Time.now.utc end @@ -132,13 +137,29 @@ module Devise confirm! unless confirmed? end + def prevent_email_change + @email_change_confirmation_required = true + self.unconfirmed_email = self.email + self.email = self.email_was + end + + def prevent_email_change? + self.class.confirmation_on_email_change && email_changed? && email != unconfirmed_email_was + end + + def email_change_confirmation_required? + self.class.confirmation_on_email_change && @email_change_confirmation_required + end + module ClassMethods # Attempt to find a user by its email. If a record is found, send new - # confirmation instructions to it. If not user is found, returns a new user - # with an email not found error. + # confirmation instructions to it. If not, try searching for a user by unconfirmed_email + # field. If no user is found, returns a new user with an email not found error. # Options must contain the user email def send_confirmation_instructions(attributes={}) confirmable = find_or_initialize_with_errors(confirmation_keys, attributes, :not_found) + temp = find_by_unconfirmed_email(confirmation_keys, attributes, :not_found) + confirmable = temp if temp.persisted? confirmable.resend_confirmation_token if confirmable.persisted? confirmable end @@ -158,7 +179,14 @@ module Devise generate_token(:confirmation_token) end - Devise::Models.config(self, :confirm_within, :confirmation_keys) + # Find a record for confirmation by unconfirmed email field + def find_by_unconfirmed_email(required_attributes, attributes, error=:invalid) + confirmation_keys_with_replaced_email = required_attributes.map{ |k| k == :email ? :unconfirmed_email : k } + attributes[:unconfirmed_email] = attributes.delete(:email) + find_or_initialize_with_errors(confirmation_keys_with_replaced_email, attributes, :not_found) + end + + Devise::Models.config(self, :confirm_within, :confirmation_keys, :confirmation_on_email_change) end end end diff --git a/lib/devise/schema.rb b/lib/devise/schema.rb index 222a6e33..bfa69cfb 100644 --- a/lib/devise/schema.rb +++ b/lib/devise/schema.rb @@ -38,6 +38,7 @@ module Devise apply_devise_schema :confirmation_token, String apply_devise_schema :confirmed_at, DateTime apply_devise_schema :confirmation_sent_at, DateTime + apply_devise_schema :unconfirmed_email, String end # Creates reset_password_token and reset_password_sent_at. diff --git a/test/integration/confirmable_test.rb b/test/integration/confirmable_test.rb index 6add9177..c460c9a5 100644 --- a/test/integration/confirmable_test.rb +++ b/test/integration/confirmable_test.rb @@ -177,3 +177,53 @@ class ConfirmationTest < ActionController::IntegrationTest end end end + +class ConfirmationOnChangeTest < ConfirmationTest + def setup + Devise.confirmation_on_email_change = true + end + + def teardown + Devise.confirmation_on_email_change = false + end + + test 'user should be able to request a new confirmation after email changed' do + user = create_user(:confirm => true) + user.update_attributes(:email => 'new_test@example.com') + ActionMailer::Base.deliveries.clear + + visit new_user_session_path + click_link "Didn't receive confirmation instructions?" + + fill_in 'email', :with => user.unconfirmed_email + click_button 'Resend confirmation instructions' + + assert_current_url '/users/sign_in' + assert_contain 'You will receive an email with instructions about how to confirm your account in a few minutes' + assert_equal 1, ActionMailer::Base.deliveries.size + end + + test 'user with valid confirmation token should be able to confirm email after email changed' do + user = create_user(:confirm => true) + user.update_attributes(:email => 'new_test@example.com') + assert 'new_test@example.com', user.unconfirmed_email + visit_user_confirmation_with_token(user.confirmation_token) + + assert_contain 'Your account was successfully confirmed.' + assert_current_url '/' + assert user.reload.confirmed? + end + + test 'user who changed email should get a detailed message about email being not unique' do + user = create_user(:confirm => true) + user.update_attributes(:email => 'new_test@example.com') + assert 'new_test@example.com', user.unconfirmed_email + + @user = nil + create_user(:email => 'new_test@example.com', :confirm => true) + + visit_user_confirmation_with_token(user.confirmation_token) + + assert_contain /Email.*already.*taken/ + end +end diff --git a/test/models/confirmable_test.rb b/test/models/confirmable_test.rb index c5136612..19b073d4 100644 --- a/test/models/confirmable_test.rb +++ b/test/models/confirmable_test.rb @@ -236,3 +236,80 @@ class ConfirmableTest < ActiveSupport::TestCase end end end + +class ConfirmableOnChangeTest < ConfirmableTest + def setup + Devise.confirmation_on_email_change = true + end + + def teardown + Devise.confirmation_on_email_change = false + end + + def test_should_not_resend_email_instructions_if_the_user_change_his_email + #behaves differently + end + + def test_should_not_reset_confirmation_status_or_token_when_updating_email + #behaves differently + end + + test 'should generate confirmation token after changing email' do + user = create_user + assert user.confirm! + assert_nil user.confirmation_token + assert user.update_attributes(:email => 'new_test@example.com') + assert_not_nil user.confirmation_token + end + + test 'should send confirmation instructions by email after changing email' do + user = create_user + assert user.confirm! + assert_email_sent do + assert user.update_attributes(:email => 'new_test@example.com') + end + end + + test 'should not send confirmation by email after changing password' do + user = create_user + assert user.confirm! + assert_email_not_sent do + assert user.update_attributes(:password => 'newpass', :password_confirmation => 'newpass') + end + end + + test 'should stay confirmed when email is changed' do + user = create_user + assert user.confirm! + assert user.update_attributes(:email => 'new_test@example.com') + assert user.confirmed? + end + + test 'should update email only when it is confirmed' do + user = create_user + assert user.confirm! + assert user.update_attributes(:email => 'new_test@example.com') + assert_not_equal 'new_test@example.com', user.email + assert user.confirm! + assert_equal 'new_test@example.com', user.email + end + + test 'should find a user by send confirmation instructions with unconfirmed_email' do + user = create_user + assert user.confirm! + assert user.update_attributes(:email => 'new_test@example.com') + confirmation_user = User.send_confirmation_instructions(:email => user.unconfirmed_email) + assert_equal confirmation_user, user + end + + test 'should return a new user if no email or unconfirmed_email was found' do + confirmation_user = User.send_confirmation_instructions(:email => "invalid@email.com") + assert_not confirmation_user.persisted? + end + + test 'should add error to new user email if no email or unconfirmed_email was found' do + confirmation_user = User.send_confirmation_instructions(:email => "invalid@email.com") + assert confirmation_user.errors[:email] + assert_equal "not found", confirmation_user.errors[:email].join + end +end