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.
- data/.document +5 -0
- data/.gitignore +22 -0
- data/LICENSE +20 -0
- data/README.rdoc +92 -0
- data/Rakefile +54 -0
- data/VERSION +1 -0
- data/app/controllers/devise/invitations_controller.rb +52 -0
- data/app/views/devise/invitations/edit.html.erb +14 -0
- data/app/views/devise/invitations/new.html.erb +12 -0
- data/app/views/devise/mailer/invitation.html.erb +8 -0
- data/devise_invitable.gemspec +123 -0
- data/init.rb +1 -0
- data/lib/devise_invitable.rb +21 -0
- data/lib/devise_invitable/controllers/helpers.rb +6 -0
- data/lib/devise_invitable/controllers/url_helpers.rb +24 -0
- data/lib/devise_invitable/locales/en.yml +5 -0
- data/lib/devise_invitable/mailer.rb +8 -0
- data/lib/devise_invitable/model.rb +139 -0
- data/lib/devise_invitable/rails.rb +15 -0
- data/lib/devise_invitable/routes.rb +11 -0
- data/lib/devise_invitable/schema.rb +11 -0
- data/test/integration/invitable_test.rb +122 -0
- data/test/integration_tests_helper.rb +39 -0
- data/test/mailers/invitation_test.rb +62 -0
- data/test/model_tests_helper.rb +41 -0
- data/test/models/invitable_test.rb +172 -0
- data/test/models_test.rb +35 -0
- data/test/rails_app/app/controllers/admins_controller.rb +6 -0
- data/test/rails_app/app/controllers/application_controller.rb +10 -0
- data/test/rails_app/app/controllers/home_controller.rb +4 -0
- data/test/rails_app/app/controllers/users_controller.rb +12 -0
- data/test/rails_app/app/helpers/application_helper.rb +3 -0
- data/test/rails_app/app/models/user.rb +4 -0
- data/test/rails_app/app/views/home/index.html.erb +0 -0
- data/test/rails_app/config/boot.rb +110 -0
- data/test/rails_app/config/database.yml +22 -0
- data/test/rails_app/config/environment.rb +44 -0
- data/test/rails_app/config/environments/development.rb +17 -0
- data/test/rails_app/config/environments/production.rb +28 -0
- data/test/rails_app/config/environments/test.rb +28 -0
- data/test/rails_app/config/initializers/backtrace_silencers.rb +7 -0
- data/test/rails_app/config/initializers/devise.rb +105 -0
- data/test/rails_app/config/initializers/inflections.rb +2 -0
- data/test/rails_app/config/initializers/new_rails_defaults.rb +21 -0
- data/test/rails_app/config/initializers/session_store.rb +15 -0
- data/test/rails_app/config/routes.rb +3 -0
- data/test/rails_app/vendor/plugins/devise_invitable/init.rb +1 -0
- data/test/routes_test.rb +20 -0
- data/test/test_helper.rb +58 -0
- 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,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
|