devise_invitable 3.0.0 → 3.0.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52005f95e25dca6edd01fb9193500a319035974cdf3cd643133ae7c2f42e7825
4
- data.tar.gz: 25b2fa545162bb87340c1133811bacf61861fc2eb914b002618531815f576fe7
3
+ metadata.gz: 54bcb44af270be6f5192f01771df589f95045ee16206315e7784c16c49d93df5
4
+ data.tar.gz: 74fea42d1bae1ec385ca62291817679a59c3430fc6f4e609c966ee08db17c2d3
5
5
  SHA512:
6
- metadata.gz: 6f512c5c9be3eb3a5b80abc6adec76b5dd5432f4ed02e3596f9ab9c9bc297589b452556c0a828f0a64db91bd7152ad8b31f43b91a82b013e7bce32e1fd76bf13
7
- data.tar.gz: 10d177422be705e605c9287785cc0d6ec42d5b42ae40ee646d7bb8f6c08521612217fb3735ae790975bfc7da7b7f6b459eb0beeb2d3c06e9648d340f9538526a
6
+ metadata.gz: dd0dbba52b99e4117a02072d3f48fbb3623614697a5e972a4f99081351479c9a2846696752a112ffd63d9528631ef196763146af5cc2792996246bcdaa755a97
7
+ data.tar.gz: 406bdf51f7114cc26354cf682658a3f44cb192db2f94cb45d0c7fd72384774d1464678275aac671a973b1660031084c21b4a290279e8e52a9ea69109a8186068
data/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## Unreleased
2
+
3
+ ## 3.0.2
4
+
5
+ - Add `DeviseInvitable::Invitation#deliver` to send a skipped pending
6
+ invitation later, including resource-less invitations
7
+ - Start the `invite_for` validity period when an invitation is sent instead of
8
+ when it is created, so approval delays do not shorten the acceptance window
9
+ - Leave an invitation unsent when delivery fails so it can be retried
10
+
11
+ ## 3.0.1
12
+
13
+ - Raise `DeviseInvitable::AmbiguousInviteKeyError` instead of selecting an
14
+ arbitrary resource when an invite key matches multiple records
15
+ ([#878](https://github.com/scambra/devise_invitable/issues/878))
16
+ - Use the signed-in resource as the inviter when a custom
17
+ `authenticate_inviter!` method returns a boolean success value
18
+ ([#825](https://github.com/scambra/devise_invitable/issues/825))
19
+ - Do not assign attributes or modify associations on an existing resource when
20
+ its invitation is rejected ([#747](https://github.com/scambra/devise_invitable/issues/747))
21
+ - Avoid uninitialized `@accepting_invitation` warnings on supported Ruby 2.7
22
+ ([#776](https://github.com/scambra/devise_invitable/issues/776))
23
+ - Document personal-data storage and retention considerations for deferred and
24
+ placeholder invitations ([#777](https://github.com/scambra/devise_invitable/issues/777))
25
+ - Add regression coverage for custom `authenticate_inviter!` methods that
26
+ redirect unauthorized inviters ([#893](https://github.com/scambra/devise_invitable/issues/893))
27
+ - Add regression coverage to ensure `devise_for` preserves a custom controller
28
+ `module` instead of replacing its registrations controller
29
+ ([#877](https://github.com/scambra/devise_invitable/issues/877))
30
+ - Add regression coverage that instance-level `invite!` runs both the before
31
+ and after invitation-created callbacks
32
+ ([#640](https://github.com/scambra/devise_invitable/issues/640))
33
+
1
34
  ## 3.0.0
2
35
 
3
36
  - Drop support for Ruby < 2.7, Rails < 7.0, and Devise < 5.0
data/README.md CHANGED
@@ -246,6 +246,34 @@ above is `'users/invitations'`, so our routes.rb would have this line:
246
246
  devise_for :users, controllers: { invitations: 'users/invitations' }
247
247
  ```
248
248
 
249
+ The invitations controller prepends its authentication, invitation-limit, and
250
+ token-loading callbacks. These callbacks may render or redirect and halt the
251
+ callback chain before an ordinary callback inherited from
252
+ `ApplicationController` runs. If application context must be available even
253
+ when one of these guards halts the request, prepend that setup in the custom
254
+ invitations controller. For example, an around callback can set and restore
255
+ the locale so flash messages created by the guards use the requested locale:
256
+
257
+ ```ruby
258
+ class Users::InvitationsController < Devise::InvitationsController
259
+ prepend_around_action :use_locale
260
+
261
+ private
262
+
263
+ def use_locale(&action)
264
+ I18n.with_locale(locale_from_request, &action)
265
+ end
266
+
267
+ def locale_from_request
268
+ params[:locale].presence_in(I18n.available_locales.map(&:to_s)) ||
269
+ I18n.default_locale
270
+ end
271
+ end
272
+ ```
273
+
274
+ The same ordering rule applies to tenant selection, request tracing, and other
275
+ setup that must run before Devise Invitable's controller guards.
276
+
249
277
  be sure that you generate the views and put them into the controller that you
250
278
  generated, so for this example it would be:
251
279
 
@@ -394,6 +422,36 @@ rejected by both Devise's password validation and
394
422
  `active_for_authentication?` checks until an invitation is accepted or the
395
423
  resource is claimed by direct registration.
396
424
 
425
+ ### Privacy and data retention
426
+
427
+ Invitation keys commonly contain personal data such as an email address. With
428
+ the default `create_resource_on_invitation: false`, no resource row is created,
429
+ but the invite-key values are stored in plaintext in the
430
+ `devise_invitations.invite_key` JSON column so the resource can be created when
431
+ the invitation is accepted. `invite_key_digest` is used for matching; it does
432
+ not replace the stored values and should not be treated as anonymization.
433
+
434
+ With `create_resource_on_invitation: true`, the resource and any additional
435
+ attributes or associations supplied to `invite!` are persisted immediately.
436
+ Choose this mode only when the application needs a placeholder and its privacy
437
+ policy permits storing that data before acceptance.
438
+
439
+ `invite_for` limits token validity but does not delete invitation or resource
440
+ records. Applications are responsible for choosing a lawful basis for
441
+ processing, disclosing that processing where required, limiting invited data,
442
+ restricting access, protecting it at rest, and deleting pending, revoked,
443
+ superseded, and accepted invitation data according to their retention policy.
444
+ Consent is one possible lawful basis under GDPR, but it is not the only one.
445
+ See the [European Commission GDPR principles](https://commission.europa.eu/law/law-topic/data-protection/information-business-and-organisations/principles-gdpr_en)
446
+ and obtain appropriate legal advice for the application.
447
+
448
+ A one-way hash alone is insufficient for the built-in deferred-invitation
449
+ flow, because devise_invitable must recover the invite keys to create the
450
+ resource at acceptance. Applications that must not retain the email address
451
+ need a custom flow, such as asking the invitee to enter it during acceptance
452
+ and verifying it against a stored digest, or using suitably protected
453
+ reversible storage.
454
+
397
455
  If you want to create the invitation but not send it, you can set
398
456
  `skip_invitation` to `true`.
399
457
 
@@ -412,16 +470,32 @@ user and will be decrypted when received.
412
470
  accept_user_invitation_url(invitation_token: user.raw_invitation_token)
413
471
  ```
414
472
 
415
- When `skip_invitation` is used, you must also then set the
416
- `invitation_sent_at` field when the user is sent their token. Failure to do so
417
- will yield "Invalid invitation token" error when the user attempts to accept
418
- the invite. You can set the column, or call `deliver_invitation` to send the
419
- invitation and set the column:
473
+ When `skip_invitation` is used, the resulting invitation remains pending with
474
+ an unset `invitation_sent_at`. Deliver it later by calling `deliver` on the
475
+ invitation:
420
476
 
421
477
  ```ruby
422
- user.deliver_invitation
478
+ invitation = user.current_invitation
479
+ invitation.deliver
423
480
  ```
424
481
 
482
+ This works for both placeholder-backed and resource-less invitations. For a
483
+ resource-less invitation, `deliver` reconstructs the resource from the stored
484
+ invite key. It generates a fresh token, sends the email, and sets
485
+ `invitation_sent_at`. Calling it again after successful delivery does nothing.
486
+
487
+ For example, an approval workflow can find unsent pending invitations and
488
+ deliver an approved one after applying its own authorization policy:
489
+
490
+ ```ruby
491
+ invitations = DeviseInvitable::Invitation.pending.where(invitation_sent_at: nil)
492
+ invitation = invitations.find(approved_invitation_id)
493
+ invitation.deliver
494
+ ```
495
+
496
+ Invitation validity starts at `invitation_sent_at`, so time spent awaiting
497
+ approval does not reduce the configured `invite_for` period.
498
+
425
499
  You can add `:skip_invitation` to attributes hash if `skip_invitation` is
426
500
  added to `attr_accessible`.
427
501
 
@@ -437,12 +511,6 @@ User.invite!(email: 'new_user@example.com', skip_invitation: true)
437
511
  To check if a particular user is created by invitation, irrespective to state
438
512
  of invitation one can use `created_by_invite?`
439
513
 
440
- **Warning**
441
-
442
- When using `skip_invitation` you must send the email with the user object
443
- instance that generated the tokens, as `user.raw_invitation_token` is
444
- available only to the instance and is not persisted in the database.
445
-
446
514
  You can also set `invited_by` when using the `invite!` class method:
447
515
 
448
516
  ```ruby
@@ -85,7 +85,13 @@ class Devise::InvitationsController < DeviseController
85
85
  end
86
86
 
87
87
  def current_inviter
88
- authenticate_inviter!
88
+ authenticated = authenticate_inviter!
89
+ return authenticated if authenticated.respond_to?(:has_invitations_left?)
90
+
91
+ # Custom API/token authenticators may return true on success instead of
92
+ # the authenticated resource, so fall back to Devise's current scope.
93
+ current_resource_method = :"current_#{resource_name}"
94
+ send(current_resource_method) if respond_to?(current_resource_method, true)
89
95
  end
90
96
 
91
97
  def has_invitations_left?
@@ -67,7 +67,18 @@ module DeviseInvitable
67
67
  end
68
68
 
69
69
  def invitation_period_valid?(period)
70
- period.to_i.zero? || (invitation_created_at && invitation_created_at.utc >= period.ago)
70
+ invitation_sent_at &&
71
+ (period.to_i.zero? || invitation_sent_at.utc >= period.ago)
72
+ end
73
+
74
+ # Deliver a pending invitation that has not been sent yet. Resource-less
75
+ # invitations are reconstructed from their stored invite key.
76
+ def deliver(options = {})
77
+ return unless pending? && invitation_sent_at.nil?
78
+
79
+ resource = invitable || invitable_type.constantize.new(invite_key)
80
+ resource.instance_variable_set(:@current_invitation, self)
81
+ resource.deliver_invitation(options)
71
82
  end
72
83
 
73
84
  def invite_key
@@ -231,7 +231,7 @@ module Devise
231
231
 
232
232
  # Returns true if accept_invitation! was called
233
233
  def accepting_invitation?
234
- @accepting_invitation
234
+ instance_variable_defined?(:@accepting_invitation) && @accepting_invitation
235
235
  end
236
236
 
237
237
  # Verifies whether a user accepted an invitation (false when user is accepting it)
@@ -267,7 +267,6 @@ module Devise
267
267
  run_callbacks :invitation_created do
268
268
  now = Time.now.utc
269
269
  invitation.invitation_created_at = now
270
- invitation.invitation_sent_at = now unless skip_invitation
271
270
 
272
271
  validate = options.key?(:validate) ? options[:validate] : self.class.validate_on_invite
273
272
  save_invitation = !self.class.create_resource_on_invitation || save(validate: validate)
@@ -332,10 +331,15 @@ module Devise
332
331
  # Deliver the invitation email
333
332
  def deliver_invitation(options = {})
334
333
  generate_invitation_token! unless @raw_invitation_token
334
+ marked_as_sent = false
335
335
  unless current_invitation.invitation_sent_at
336
336
  current_invitation.update_attribute :invitation_sent_at, Time.now.utc
337
+ marked_as_sent = true
337
338
  end
338
339
  send_devise_notification(:invitation_instructions, @raw_invitation_token, options)
340
+ rescue
341
+ current_invitation.update_attribute(:invitation_sent_at, nil) if marked_as_sent
342
+ raise
339
343
  end
340
344
 
341
345
  # provide alias to the encrypted invitation_token stored by devise
@@ -350,7 +354,7 @@ module Devise
350
354
  def invitation_due_at
351
355
  return nil if (self.class.invite_for == 0 || self.class.invite_for.nil?)
352
356
 
353
- return unless (time = self.invitation_created_at || self.invitation_sent_at)
357
+ return unless (time = self.invitation_sent_at)
354
358
 
355
359
  time + self.class.invite_for
356
360
  end
@@ -494,8 +498,18 @@ module Devise
494
498
  attributes_hash[k] = attribute
495
499
  end
496
500
 
501
+ ensure_unique_invite_key_match!(invite_key_array, attributes_hash)
497
502
  invitable = find_or_initialize_with_errors(invite_key_array, attributes_hash)
498
503
  invitable.assign_attributes(attributes_hash)
504
+ # Association writers may persist changes immediately. Reject an
505
+ # existing resource before assigning non-key attributes or yielding.
506
+ if invitable.persisted? && (invitable.invitation_taken? || !self.resend_invitation)
507
+ invite_key_array.each do |key|
508
+ invitable.add_taken_error(key)
509
+ end
510
+ return [invitable, nil]
511
+ end
512
+
499
513
  invitable.assign_attributes(attributes)
500
514
  invitable.skip_invitation = skip_invitation unless skip_invitation.nil?
501
515
  invitable.invited_by_for_next_invitation = invited_by
@@ -508,10 +522,6 @@ module Devise
508
522
  invite_key_array.each { |key| invitable.add_taken_error(key) }
509
523
  elsif invitable.new_record?
510
524
  invitable.clear_errors_on_valid_keys if !self.validate_on_invite
511
- elsif invitable.invitation_taken? || !self.resend_invitation
512
- invite_key_array.each do |key|
513
- invitable.add_taken_error(key)
514
- end
515
525
  end
516
526
 
517
527
  resource_attribute_names = attributes.keys.map(&:to_sym)
@@ -639,6 +649,20 @@ module Devise
639
649
 
640
650
  private
641
651
 
652
+ def ensure_unique_invite_key_match!(required_attributes, attributes)
653
+ filtered_attributes = attributes.to_h.with_indifferent_access
654
+ .slice(*required_attributes)
655
+ .delete_if { |_key, value| value.blank? }
656
+ return unless filtered_attributes.size == required_attributes.size
657
+
658
+ filtered_attributes = devise_parameter_filter.filter(filtered_attributes)
659
+ matches = where(filtered_attributes).limit(2).to_a
660
+ return unless matches.many?
661
+
662
+ raise DeviseInvitable::AmbiguousInviteKeyError,
663
+ "Invite key matches multiple #{devise_invitable_base_class.name} records"
664
+ end
665
+
642
666
  # The random password, as set after an invitation, must conform
643
667
  # to any password format validation rules of the application.
644
668
  # This default fixes the most common scenarios: Passwords must contain
@@ -1,3 +1,3 @@
1
1
  module DeviseInvitable
2
- VERSION = '3.0.0'.freeze
2
+ VERSION = '3.0.2'.freeze
3
3
  end
@@ -1,4 +1,5 @@
1
1
  module DeviseInvitable
2
+ class AmbiguousInviteKeyError < StandardError; end
2
3
  class ResourceAttributesRequirePlaceholderError < ArgumentError; end
3
4
 
4
5
  autoload :Inviter, 'devise_invitable/inviter'
@@ -247,6 +247,27 @@ class InvitationTest < ActionDispatch::IntegrationTest
247
247
  assert_redirected_to new_admin_session_path
248
248
  end
249
249
 
250
+ test 'custom inviter authentication can redirect without rendering twice' do
251
+ sign_in_as_user
252
+
253
+ get new_restricted_invitation_path
254
+
255
+ assert_redirected_to root_path
256
+ assert_equal 'You are not authorized to access this page', flash[:alert]
257
+ end
258
+
259
+ test 'boolean inviter authentication uses the signed in resource as inviter' do
260
+ inviter = User.create!(
261
+ email: 'boolean-inviter@example.com',
262
+ password: '123456',
263
+ password_confirmation: '123456'
264
+ )
265
+
266
+ post boolean_inviter_invitation_path, params: { user: { email: 'boolean-invitee@example.com' } }
267
+
268
+ assert_equal inviter, User.find_by!(email: 'boolean-invitee@example.com').invited_by
269
+ end
270
+
250
271
  test 'authenticated admin should be able to send an admin invitation' do
251
272
  admin = Admin.create(email: 'admin@test.com', password: '123456', password_confirmation: '123456')
252
273
  sign_in_as_user admin
@@ -15,6 +15,17 @@ class InvitableTest < ActiveSupport::TestCase
15
15
  assert_nil new_user.invitation_token
16
16
  end
17
17
 
18
+ test 'accepting_invitation? does not warn before invitation acceptance' do
19
+ original_verbose = $VERBOSE
20
+ $VERBOSE = true
21
+
22
+ _, warnings = capture_io { refute_predicate new_user, :accepting_invitation? }
23
+
24
+ refute_includes warnings, 'instance variable @accepting_invitation not initialized'
25
+ ensure
26
+ $VERBOSE = original_verbose
27
+ end
28
+
18
29
  test 'should update the invitations count counter cache' do
19
30
  if defined?(ActiveRecord)
20
31
  current_user = new_user
@@ -102,27 +113,31 @@ class InvitableTest < ActiveSupport::TestCase
102
113
  user = User.invite!(email: 'valid@email.com')
103
114
 
104
115
  User.stubs(:invite_for).returns(nil)
105
- user.invitation_created_at = Time.now.utc
116
+ user.invitation_sent_at = Time.now.utc
106
117
  assert_predicate user, :valid_invitation?
107
118
 
108
119
  User.stubs(:invite_for).returns(nil)
109
- user.invitation_created_at = 1.year.ago
120
+ user.invitation_sent_at = 1.year.ago
110
121
  assert_predicate user, :valid_invitation?
111
122
 
112
123
  User.stubs(:invite_for).returns(0)
113
- user.invitation_created_at = Time.now.utc
124
+ user.invitation_sent_at = Time.now.utc
114
125
  assert_predicate user, :valid_invitation?
115
126
 
116
127
  User.stubs(:invite_for).returns(0)
117
- user.invitation_created_at = 1.day.ago
128
+ user.invitation_sent_at = 1.day.ago
118
129
  assert_predicate user, :valid_invitation?
119
130
 
120
131
  User.stubs(:invite_for).returns(1.day)
121
- user.invitation_created_at = Time.now.utc
132
+ user.invitation_sent_at = Time.now.utc
122
133
  assert_predicate user, :valid_invitation?
123
134
 
124
135
  User.stubs(:invite_for).returns(1.day)
125
- user.invitation_created_at = 2.days.ago
136
+ user.invitation_sent_at = 2.days.ago
137
+ refute_predicate user, :valid_invitation?
138
+
139
+ User.stubs(:invite_for).returns(0)
140
+ user.invitation_sent_at = nil
126
141
  refute_predicate user, :valid_invitation?
127
142
  end
128
143
 
@@ -130,8 +145,7 @@ class InvitableTest < ActiveSupport::TestCase
130
145
  User.stubs(:invite_for).returns(1.day)
131
146
 
132
147
  user = User.invite!(email: 'valid@email.com')
133
- sent_at = user.invitation_created_at || user.invitation_sent_at
134
- valid_until = sent_at + User.invite_for
148
+ valid_until = user.invitation_sent_at + User.invite_for
135
149
 
136
150
  assert_equal user.invitation_due_at, valid_until
137
151
  end
@@ -283,7 +297,7 @@ class InvitableTest < ActiveSupport::TestCase
283
297
  User.stubs(:invite_for).returns(1.day)
284
298
  user = User.invite!(email: 'valid@email.com')
285
299
  assert user.invited_to_sign_up?
286
- user.invitation_created_at = Time.now.utc - 2.days
300
+ user.invitation_sent_at = Time.now.utc - 2.days
287
301
  token, user.reset_password_token = Devise.token_generator.generate(User, :reset_password_token)
288
302
  user.reset_password_sent_at = Time.now.utc
289
303
  user.save
@@ -430,6 +444,30 @@ class InvitableTest < ActiveSupport::TestCase
430
444
  assert_equal [{error: :taken}], same_user.errors.details[:email]
431
445
  end
432
446
 
447
+ test 'should not modify associations when an existing user cannot be invited' do
448
+ existing_user = User.create!(email: 'existing@email.com', password: '123456789')
449
+ User.invite!({ email: 'invitee@email.com' }, existing_user)
450
+ sent_invitation = existing_user.sent_invitations.first
451
+
452
+ invited_user = User.invite!(
453
+ email: existing_user.email,
454
+ sent_invitation_ids: []
455
+ )
456
+
457
+ assert_equal [{ error: :taken }], invited_user.errors.details[:email]
458
+ assert_equal existing_user, sent_invitation.reload.invited_by
459
+ end
460
+
461
+ test 'should reject an invite key that matches multiple records' do
462
+ 2.times do
463
+ User.new(email: 'duplicate@email.com').save(validate: false)
464
+ end
465
+
466
+ assert_raises DeviseInvitable::AmbiguousInviteKeyError do
467
+ User.invite!(email: 'duplicate@email.com')
468
+ end
469
+ end
470
+
433
471
  test 'should return a record with errors if user with pending invitation was found by e-mail' do
434
472
  existing_user = User.invite!(email: 'valid@email.com')
435
473
  user = User.invite!(email: 'valid@email.com')
@@ -447,7 +485,7 @@ class InvitableTest < ActiveSupport::TestCase
447
485
  end
448
486
  end
449
487
 
450
- test 'should return a record with errors if user was found by e-mail with validate_on_invite' do
488
+ test 'should not assign or validate other attributes if user was found by e-mail' do
451
489
  begin
452
490
  validate_on_invite = User.validate_on_invite
453
491
  User.validate_on_invite = true
@@ -456,7 +494,8 @@ class InvitableTest < ActiveSupport::TestCase
456
494
  user = User.invite!(email: 'valid@email.com', username: 'a' * 50)
457
495
  assert_equal user, existing_user
458
496
  assert_equal [{error: :taken}], user.errors.details[:email]
459
- refute_empty user.errors[:username]
497
+ assert_empty user.errors[:username]
498
+ assert_nil user.username
460
499
  ensure
461
500
  User.validate_on_invite = validate_on_invite
462
501
  end
@@ -503,7 +542,7 @@ class InvitableTest < ActiveSupport::TestCase
503
542
  test 'should return record with errors if invitation_token has expired' do
504
543
  User.stubs(:invite_for).returns(10.hours)
505
544
  invited_user = User.invite!(email: 'valid@email.com')
506
- invited_user.invitation_created_at = 2.days.ago
545
+ invited_user.invitation_sent_at = 2.days.ago
507
546
  invited_user.save(validate: false)
508
547
  user = User.accept_invitation!(invitation_token: Thread.current[:token])
509
548
  assert_equal user, invited_user
@@ -741,8 +780,10 @@ class InvitableTest < ActiveSupport::TestCase
741
780
 
742
781
  test "user.invite! should trigger callbacks" do
743
782
  user = User.new(email: 'valid@email.com')
783
+ assert_callbacks_not_fired :before_invitation_created, user
744
784
  assert_callbacks_not_fired :after_invitation_created, user
745
785
  user.invite!
786
+ assert_callbacks_fired :before_invitation_created, user
746
787
  assert_callbacks_fired :after_invitation_created, user
747
788
  end
748
789
 
@@ -26,6 +26,38 @@ class SeparateInvitationsTest < ActiveSupport::TestCase
26
26
  assert_includes User.no_active_invitation, registered
27
27
  end
28
28
 
29
+ test 'an unsent resource-less invitation can be delivered later' do
30
+ User.create_resource_on_invitation = false
31
+ User.stubs(:invite_for).returns(1.day)
32
+
33
+ invited = User.invite!(email: 'deferred@example.com', skip_invitation: true)
34
+ invitation = invited.current_invitation
35
+ invitation.update!(invitation_created_at: 2.days.ago)
36
+
37
+ assert_nil invitation.invitation_sent_at
38
+ assert_difference('ActionMailer::Base.deliveries.size', 1) do
39
+ invitation.reload.deliver
40
+ end
41
+
42
+ assert_predicate invitation.reload, :invitation_sent_at
43
+ assert invitation.invitation_period_valid?(1.day)
44
+ assert_equal ['deferred@example.com'], ActionMailer::Base.deliveries.last.to
45
+
46
+ assert_no_difference('ActionMailer::Base.deliveries.size') do
47
+ invitation.deliver
48
+ end
49
+ end
50
+
51
+ test 'a failed later delivery remains unsent and can be retried' do
52
+ User.create_resource_on_invitation = false
53
+ invited = User.invite!(email: 'retry@example.com', skip_invitation: true)
54
+ invitation = invited.current_invitation.reload
55
+ User.any_instance.stubs(:send_devise_notification).raises('delivery failed')
56
+
57
+ assert_raises(RuntimeError) { invitation.deliver }
58
+ assert_nil invitation.reload.invitation_sent_at
59
+ end
60
+
29
61
  test 'accepting one of multiple invitations creates the resource and supersedes the others' do
30
62
  User.create_resource_on_invitation = false
31
63
  first_inviter = User.create!(email: 'first@example.com', password: '123456789')
@@ -0,0 +1,11 @@
1
+ class BooleanInviterInvitationsController < Devise::InvitationsController
2
+ protected
3
+
4
+ def authenticate_inviter!
5
+ true
6
+ end
7
+
8
+ def current_user
9
+ User.find_by(email: 'boolean-inviter@example.com')
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ class RestrictedInvitationsController < Devise::InvitationsController
2
+ protected
3
+
4
+ def authenticate_inviter!
5
+ return if current_admin
6
+
7
+ redirect_back fallback_location: root_path, alert: 'You are not authorized to access this page'
8
+ end
9
+ end
@@ -37,7 +37,8 @@ class User < PARENT_MODEL_CLASS
37
37
  devise :database_authenticatable, :registerable, :validatable, :confirmable, :invitable, :recoverable,
38
38
  create_resource_on_invitation: true
39
39
 
40
- attr_accessor :after_invitation_created_callback_works, :after_invitation_accepted_callback_works, :bio, :token
40
+ attr_accessor :before_invitation_created_callback_works, :after_invitation_created_callback_works,
41
+ :after_invitation_accepted_callback_works, :bio, :token
41
42
  validates :username, length: { maximum: 20 }
42
43
 
43
44
  attr_accessor :testing_accepted_or_not_invited
@@ -53,6 +54,10 @@ class User < PARENT_MODEL_CLASS
53
54
  object.after_invitation_created_callback_works = true
54
55
  end
55
56
 
57
+ before_invitation_created do |object|
58
+ object.before_invitation_created_callback_works = true
59
+ end
60
+
56
61
  after_invitation_accepted do |object|
57
62
  object.after_invitation_accepted_callback_works = true
58
63
  end
@@ -1,7 +1,9 @@
1
1
  RailsApp::Application.routes.draw do
2
2
  devise_for :users
3
3
  devise_scope :user do
4
+ resource :boolean_inviter_invitation, only: [:new, :create]
4
5
  resource :free_invitation, only: [:new, :create]
6
+ resource :restricted_invitation, only: [:new, :create]
5
7
  resource :admin, only: [:new, :create]
6
8
  end
7
9
  devise_for :admins
data/test/routes_test.rb CHANGED
@@ -2,6 +2,12 @@ require 'test_helper'
2
2
 
3
3
  class RoutesTest < ActionController::TestCase
4
4
 
5
+ test 'preserve custom controller module for registrations' do
6
+ mapping = Devise::Mapping.new(:users, module: :users)
7
+
8
+ assert_equal 'users/registrations', mapping.controllers[:registrations]
9
+ end
10
+
5
11
  test 'map new user invitation' do
6
12
  assert_recognizes({ controller: 'devise/invitations', action: 'new' }, { path: 'users/invitation/new', method: :get })
7
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devise_invitable
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Cambra
@@ -137,8 +137,10 @@ files:
137
137
  - test/rails_app/Rakefile
138
138
  - test/rails_app/app/controllers/admins_controller.rb
139
139
  - test/rails_app/app/controllers/application_controller.rb
140
+ - test/rails_app/app/controllers/boolean_inviter_invitations_controller.rb
140
141
  - test/rails_app/app/controllers/free_invitations_controller.rb
141
142
  - test/rails_app/app/controllers/home_controller.rb
143
+ - test/rails_app/app/controllers/restricted_invitations_controller.rb
142
144
  - test/rails_app/app/controllers/users_controller.rb
143
145
  - test/rails_app/app/helpers/application_helper.rb
144
146
  - test/rails_app/app/mailers/application_mailer.rb
@@ -219,8 +221,10 @@ test_files:
219
221
  - test/rails_app/Rakefile
220
222
  - test/rails_app/app/controllers/admins_controller.rb
221
223
  - test/rails_app/app/controllers/application_controller.rb
224
+ - test/rails_app/app/controllers/boolean_inviter_invitations_controller.rb
222
225
  - test/rails_app/app/controllers/free_invitations_controller.rb
223
226
  - test/rails_app/app/controllers/home_controller.rb
227
+ - test/rails_app/app/controllers/restricted_invitations_controller.rb
224
228
  - test/rails_app/app/controllers/users_controller.rb
225
229
  - test/rails_app/app/helpers/application_helper.rb
226
230
  - test/rails_app/app/mailers/application_mailer.rb