lperichon-devise_invitable 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. data/.document +5 -0
  2. data/.gitignore +22 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +92 -0
  5. data/Rakefile +54 -0
  6. data/VERSION +1 -0
  7. data/app/controllers/devise/invitations_controller.rb +52 -0
  8. data/app/views/devise/invitations/edit.html.erb +14 -0
  9. data/app/views/devise/invitations/new.html.erb +12 -0
  10. data/app/views/devise/mailer/invitation.html.erb +8 -0
  11. data/devise_invitable.gemspec +123 -0
  12. data/init.rb +1 -0
  13. data/lib/devise_invitable.rb +21 -0
  14. data/lib/devise_invitable/controllers/helpers.rb +6 -0
  15. data/lib/devise_invitable/controllers/url_helpers.rb +24 -0
  16. data/lib/devise_invitable/locales/en.yml +5 -0
  17. data/lib/devise_invitable/mailer.rb +8 -0
  18. data/lib/devise_invitable/model.rb +139 -0
  19. data/lib/devise_invitable/rails.rb +15 -0
  20. data/lib/devise_invitable/routes.rb +11 -0
  21. data/lib/devise_invitable/schema.rb +11 -0
  22. data/test/integration/invitable_test.rb +122 -0
  23. data/test/integration_tests_helper.rb +39 -0
  24. data/test/mailers/invitation_test.rb +62 -0
  25. data/test/model_tests_helper.rb +41 -0
  26. data/test/models/invitable_test.rb +172 -0
  27. data/test/models_test.rb +35 -0
  28. data/test/rails_app/app/controllers/admins_controller.rb +6 -0
  29. data/test/rails_app/app/controllers/application_controller.rb +10 -0
  30. data/test/rails_app/app/controllers/home_controller.rb +4 -0
  31. data/test/rails_app/app/controllers/users_controller.rb +12 -0
  32. data/test/rails_app/app/helpers/application_helper.rb +3 -0
  33. data/test/rails_app/app/models/user.rb +4 -0
  34. data/test/rails_app/app/views/home/index.html.erb +0 -0
  35. data/test/rails_app/config/boot.rb +110 -0
  36. data/test/rails_app/config/database.yml +22 -0
  37. data/test/rails_app/config/environment.rb +44 -0
  38. data/test/rails_app/config/environments/development.rb +17 -0
  39. data/test/rails_app/config/environments/production.rb +28 -0
  40. data/test/rails_app/config/environments/test.rb +28 -0
  41. data/test/rails_app/config/initializers/backtrace_silencers.rb +7 -0
  42. data/test/rails_app/config/initializers/devise.rb +105 -0
  43. data/test/rails_app/config/initializers/inflections.rb +2 -0
  44. data/test/rails_app/config/initializers/new_rails_defaults.rb +21 -0
  45. data/test/rails_app/config/initializers/session_store.rb +15 -0
  46. data/test/rails_app/config/routes.rb +3 -0
  47. data/test/rails_app/vendor/plugins/devise_invitable/init.rb +1 -0
  48. data/test/routes_test.rb +20 -0
  49. data/test/test_helper.rb +58 -0
  50. metadata +173 -0
@@ -0,0 +1,24 @@
1
+ module DeviseInvitable
2
+ module Controllers
3
+ module UrlHelpers
4
+ [:path, :url].each do |path_or_url|
5
+ [nil, :new_, :edit_].each do |action|
6
+ class_eval <<-URL_HELPERS, __FILE__, __LINE__ + 1
7
+ def #{action}invitation_#{path_or_url}(resource, *args)
8
+ resource = case resource
9
+ when Symbol, String
10
+ resource
11
+ when Class
12
+ resource.name.underscore
13
+ else
14
+ resource.class.name.underscore
15
+ end
16
+
17
+ send("#{action}\#{resource}_invitation_#{path_or_url}", *args)
18
+ end
19
+ URL_HELPERS
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ en:
2
+ devise:
3
+ invitations:
4
+ send_instructions: 'An email with instructions about how to set the password has been sent.'
5
+ updated: 'Your password was set successfully. You are now signed in.'
@@ -0,0 +1,8 @@
1
+ module DeviseInvitable::Mailer
2
+
3
+ # Deliver an invitation when is requested
4
+ def invitation(record)
5
+ setup_mail(record, :invitation)
6
+ end
7
+
8
+ end
@@ -0,0 +1,139 @@
1
+ module Devise
2
+ module Models
3
+ # Invitable is responsible to send emails with invitations.
4
+ # When an invitation is sent to an email, an account is created for it.
5
+ # An invitation has a link to set the password, as reset password from recoverable.
6
+ #
7
+ # Configuration:
8
+ #
9
+ # invite_for: the time you want the user will have to confirm the account after
10
+ # is invited. When invite_for is zero, the invitation won't expire.
11
+ # By default invite_for is 0.
12
+ #
13
+ # Examples:
14
+ #
15
+ # User.find(1).invited? # true/false
16
+ # User.send_invitation(:email => 'someone@example.com') # send invitation
17
+ # User.accept_invitation!(:invitation_token => '...') # accept invitation with a token
18
+ # User.find(1).accept_invitation! # accept invitation
19
+ # User.find(1).resend_invitation! # reset invitation status and send invitation again
20
+ module Invitable
21
+ extend ActiveSupport::Concern
22
+
23
+ def self.included(base)
24
+ base.class_eval do
25
+ extend ClassMethods
26
+ end
27
+ end
28
+
29
+ # Accept an invitation by clearing invitation token and confirming it if model
30
+ # is confirmable
31
+ def accept_invitation!
32
+ if self.invited?
33
+ self.invitation_token = nil
34
+ save(:validate => false)
35
+ end
36
+ end
37
+
38
+ # Verifies whether a user has been invited or not
39
+ def invited?
40
+ !new_record? && !invitation_token.nil?
41
+ end
42
+
43
+ # Send invitation by email
44
+ def send_invitation
45
+ # don't know why token does not get generated unless I add these
46
+ generate_invitation_token
47
+ save(:validate => false)
48
+
49
+ ::Devise::Mailer.invitation(self).deliver
50
+ end
51
+
52
+ # Reset invitation token and send invitation again
53
+ def resend_invitation!
54
+ if new_record? || invited?
55
+ self.skip_confirmation! if self.new_record? and self.respond_to? :skip_confirmation!
56
+ generate_invitation_token
57
+ save(:validate => false)
58
+ send_invitation
59
+ end
60
+ end
61
+
62
+ # Verify whether a invitation is active or not. If the user has been
63
+ # invited, we need to calculate if the invitation time has not expired
64
+ # for this user, in other words, if the invitation is still valid.
65
+ def valid_invitation?
66
+ invited? && invitation_period_valid?
67
+ end
68
+
69
+ protected
70
+
71
+ # Checks if the invitation for the user is within the limit time.
72
+ # We do this by calculating if the difference between today and the
73
+ # invitation sent date does not exceed the invite for time configured.
74
+ # Invite_for is a model configuration, must always be an integer value.
75
+ #
76
+ # Example:
77
+ #
78
+ # # invite_for = 1.day and invitation_sent_at = today
79
+ # invitation_period_valid? # returns true
80
+ #
81
+ # # invite_for = 5.days and invitation_sent_at = 4.days.ago
82
+ # invitation_period_valid? # returns true
83
+ #
84
+ # # invite_for = 5.days and invitation_sent_at = 5.days.ago
85
+ # invitation_period_valid? # returns false
86
+ #
87
+ # # invite_for = nil
88
+ # invitation_period_valid? # will always return true
89
+ #
90
+ def invitation_period_valid?
91
+ invitation_sent_at && (self.class.invite_for.to_i.zero? || invitation_sent_at.utc >= self.class.invite_for.ago)
92
+ end
93
+
94
+ # Generates a new random token for invitation, and stores the time
95
+ # this token is being generated
96
+ def generate_invitation_token
97
+ self.invitation_token = Devise.friendly_token
98
+ self.invitation_sent_at = Time.now.utc
99
+ end
100
+
101
+ module ClassMethods
102
+ # Attempt to find a user by it's email. If a record is not found, create a new
103
+ # user and send invitation to it. If user is found, returns the user with an
104
+ # email already exists error.
105
+ # Options must contain the user email
106
+ def send_invitation(attributes={})
107
+ invitable = find_or_initialize_by_email(attributes[:email])
108
+
109
+ if invitable.new_record?
110
+ invitable.errors.add(:email, :blank) if invitable.email.blank?
111
+ invitable.errors.add(:email, :invalid) unless invitable.email.match Devise.email_regexp
112
+ else
113
+ invitable.errors.add(:email, :taken) unless invitable.invited?
114
+ end
115
+
116
+ invitable.resend_invitation! if invitable.errors.empty?
117
+ invitable
118
+ end
119
+
120
+ # Attempt to find a user by it's invitation_token to set it's password.
121
+ # If a user is found, reset it's password and automatically try saving
122
+ # the record. If not user is found, returns a new user containing an
123
+ # error in invitation_token attribute.
124
+ # Attributes must contain invitation_token, password and confirmation
125
+ def accept_invitation!(attributes={})
126
+ invitable = find_or_initialize_with_error_by(:invitation_token, attributes[:invitation_token])
127
+ invitable.errors.add(:invitation_token, :invalid) if attributes[:invitation_token] && !invitable.new_record? && !invitable.valid_invitation?
128
+ if invitable.errors.empty?
129
+ invitable.attributes = attributes
130
+ invitable.accept_invitation! if invitable.valid?
131
+ end
132
+ invitable
133
+ end
134
+
135
+ Devise::Models.config(self, :invite_for)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,15 @@
1
+ module DeviseInvitable
2
+ class Engine < ::Rails::Engine
3
+
4
+ initializer "devise_invitable.add_url_helpers" do |app|
5
+ ActionController::Base.send :include, DeviseInvitable::Controllers::UrlHelpers
6
+ ActionView::Base.send :include, DeviseInvitable::Controllers::UrlHelpers
7
+ end
8
+
9
+ config.after_initialize do
10
+ I18n.load_path.unshift File.expand_path(File.join(File.dirname(__FILE__), 'locales', 'en.yml'))
11
+ Devise::Mailer.send :include, DeviseInvitable::Mailer
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module ActionDispatch::Routing
2
+ class Mapper
3
+ protected
4
+ def devise_invitation(mapping, controllers)
5
+ scope mapping.full_path[1..-1], :name_prefix => mapping.name do
6
+ resource :invitation, :only => [:new, :create, :update, :edit], :as => mapping.path_names[:invitation], :controller => controllers[:invitations]
7
+ # get :"accept_#{mapping.name}_invitation", :controller => 'invitations', :action => 'edit' # , :name_prefix => nil, :path_prefix => "#{mapping.name}/invitation"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module DeviseInvitable
2
+ module Schema
3
+ # Creates invitation_token and invitation_sent_at.
4
+ def invitable
5
+ apply_devise_schema :invitation_token, String, :limit => 20
6
+ apply_devise_schema :invitation_sent_at, DateTime
7
+ end
8
+ end
9
+ end
10
+
11
+ Devise::Schema.send :include, DeviseInvitable::Schema
@@ -0,0 +1,122 @@
1
+ require 'test/test_helper'
2
+ require 'test/integration_tests_helper'
3
+
4
+ class InvitationTest < ActionController::IntegrationTest
5
+
6
+ def send_invitation(&block)
7
+ visit new_user_invitation_path
8
+
9
+ assert_response :success
10
+ assert_template 'invitations/new'
11
+ assert warden.authenticated?(:user)
12
+
13
+ fill_in 'email', :with => 'user@test.com'
14
+ yield if block_given?
15
+ click_button 'Send an invitation'
16
+ end
17
+
18
+ def set_password(options={}, &block)
19
+ unless options[:visit] == false
20
+ visit accept_user_invitation_path(:invitation_token => options[:invitation_token])
21
+ end
22
+ assert_response :success
23
+ assert_template 'invitations/edit'
24
+
25
+ fill_in 'Password', :with => '987654321'
26
+ fill_in 'Password confirmation', :with => '987654321'
27
+ yield if block_given?
28
+ click_button 'Set my password'
29
+ end
30
+
31
+ test 'not authenticated user should not be able to send an invitation' do
32
+ get new_user_invitation_path
33
+ assert_not warden.authenticated?(:user)
34
+
35
+ assert_redirected_to new_user_session_path(:unauthenticated => true)
36
+ end
37
+
38
+ test 'authenticated user should be able to send an invitation' do
39
+ sign_in_as_user
40
+
41
+ send_invitation
42
+ assert_template 'home/index'
43
+ assert_equal 'An email with instructions about how to set the password has been sent.', flash[:notice]
44
+ end
45
+
46
+ test 'authenticated user with invalid email should receive an error message' do
47
+ user = create_user
48
+ sign_in_as_user
49
+ send_invitation do
50
+ fill_in 'email', :with => user.email
51
+ end
52
+
53
+ assert_response :success
54
+ assert_template 'invitations/new'
55
+ assert_have_selector "input[type=text][value='#{user.email}']"
56
+ assert_contain 'Email has already been taken'
57
+ end
58
+
59
+ test 'authenticated user should not be able to visit edit invitation page' do
60
+ sign_in_as_user
61
+
62
+ get accept_user_invitation_path
63
+
64
+ assert_response :redirect
65
+ assert_redirected_to root_path
66
+ assert warden.authenticated?(:user)
67
+ end
68
+
69
+ test 'not authenticated user with invalid invitation token should not be able to set his password' do
70
+ user = create_user
71
+ set_password :invitation_token => 'invalid_token'
72
+
73
+ assert_response :success
74
+ assert_template 'invitations/edit'
75
+ assert_have_selector '#errorExplanation'
76
+ assert_contain 'Invitation token is invalid'
77
+ assert_not user.reload.valid_password?('987654321')
78
+ end
79
+
80
+ test 'not authenticated user with valid invitation token but invalid password should not be able to set his password' do
81
+ user = create_user(false)
82
+ set_password :invitation_token => user.invitation_token do
83
+ fill_in 'Password confirmation', :with => 'other_password'
84
+ end
85
+
86
+ assert_response :success
87
+ assert_template 'invitations/edit'
88
+ assert_have_selector '#errorExplanation'
89
+ assert_contain 'Password doesn\'t match confirmation'
90
+ assert_not user.reload.valid_password?('987654321')
91
+ end
92
+
93
+ test 'not authenticated user with valid data should be able to change his password' do
94
+ user = create_user(false)
95
+ set_password :invitation_token => user.invitation_token
96
+
97
+ assert_template 'home/index'
98
+ assert_equal 'Your password was set successfully. You are now signed in.', flash[:notice]
99
+ assert user.reload.valid_password?('987654321')
100
+ end
101
+
102
+ test 'after entering invalid data user should still be able to set his password' do
103
+ user = create_user(false)
104
+ set_password :invitation_token => user.invitation_token do
105
+ fill_in 'Password confirmation', :with => 'other_password'
106
+ end
107
+ assert_response :success
108
+ assert_have_selector '#errorExplanation'
109
+ assert_not user.reload.valid_password?('987654321')
110
+
111
+ set_password :invitation_token => user.invitation_token
112
+ assert_equal 'Your password was set successfully. You are now signed in.', flash[:notice]
113
+ assert user.reload.valid_password?('987654321')
114
+ end
115
+
116
+ test 'sign in user automatically after setting it\'s password' do
117
+ user = create_user(false)
118
+ set_password :invitation_token => user.invitation_token
119
+
120
+ assert warden.authenticated?(:user)
121
+ end
122
+ end
@@ -0,0 +1,39 @@
1
+ class ActionController::IntegrationTest
2
+
3
+ def warden
4
+ request.env['warden']
5
+ end
6
+
7
+ def sign_in_as_user
8
+ Warden::Proxy.any_instance.stubs(:user).at_least_once.returns(User.new)
9
+ end
10
+
11
+ def create_user(accept_invitation = true)
12
+ user = User.new :email => 'newuser@test.com'
13
+ user.skip_confirmation!
14
+ user.invitation_token = 'token'
15
+ user.invitation_sent_at = Time.now.utc
16
+ user.save(false)
17
+ user.accept_invitation! if accept_invitation
18
+ user
19
+ end
20
+
21
+ # Fix assert_redirect_to in integration sessions because they don't take into
22
+ # account Middleware redirects.
23
+ #
24
+ def assert_redirected_to(url)
25
+ assert [301, 302].include?(@integration_session.status),
26
+ "Expected status to be 301 or 302, got #{@integration_session.status}"
27
+
28
+ url = prepend_host(url)
29
+ location = prepend_host(@integration_session.headers["Location"])
30
+ assert_equal url, location
31
+ end
32
+
33
+ protected
34
+
35
+ def prepend_host(url)
36
+ url = "http://#{request.host}#{url}" if url[0] == ?/
37
+ url
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ require 'test/test_helper'
2
+
3
+ class InvitationMailTest < ActionMailer::TestCase
4
+
5
+ def setup
6
+ setup_mailer
7
+ Devise.mailer_sender = 'test@example.com'
8
+ end
9
+
10
+ def user
11
+ @user ||= begin
12
+ user = create_user_with_invitation('token')
13
+ user.send_invitation
14
+ user
15
+ end
16
+ end
17
+
18
+ def mail
19
+ @mail ||= begin
20
+ user
21
+ ActionMailer::Base.deliveries.last
22
+ end
23
+ end
24
+
25
+ test 'email sent after reseting the user password' do
26
+ assert_not_nil mail
27
+ end
28
+
29
+ test 'content type should be set to html' do
30
+ assert_equal 'text/html', mail.content_type
31
+ end
32
+
33
+ test 'send invitation to the user email' do
34
+ assert_equal [user.email], mail.to
35
+ end
36
+
37
+ test 'setup sender from configuration' do
38
+ assert_equal ['test@example.com'], mail.from
39
+ end
40
+
41
+ test 'setup subject from I18n' do
42
+ store_translations :en, :devise => { :mailer => { :invitation => 'Invitation' } } do
43
+ assert_equal 'Invitation', mail.subject
44
+ end
45
+ end
46
+
47
+ test 'subject namespaced by model' do
48
+ store_translations :en, :devise => { :mailer => { :user => { :invitation => 'User Invitation' } } } do
49
+ assert_equal 'User Invitation', mail.subject
50
+ end
51
+ end
52
+
53
+ test 'body should have user info' do
54
+ assert_match /#{user.email}/, mail.body
55
+ end
56
+
57
+ test 'body should have link to confirm the account' do
58
+ host = ActionMailer::Base.default_url_options[:host]
59
+ invitation_url_regexp = %r{<a href=\"http://#{host}/users/invitation/accept\?invitation_token=#{user.invitation_token}">}
60
+ assert_match invitation_url_regexp, mail.body
61
+ end
62
+ end