devise_invitable 3.0.4 → 3.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +259 -23
- data/app/controllers/devise/invitations_controller.rb +2 -1
- data/app/controllers/devise_invitable/registrations_controller.rb +1 -1
- data/lib/devise_invitable/acceptance_token.rb +58 -0
- data/lib/devise_invitable/invitation.rb +26 -5
- data/lib/devise_invitable/invitation_delivery.rb +114 -0
- data/lib/devise_invitable/invitation_delivery_job.rb +15 -0
- data/lib/devise_invitable/models.rb +160 -35
- data/lib/devise_invitable/version.rb +1 -1
- data/lib/devise_invitable.rb +42 -2
- data/lib/generators/devise_invitable/install_generator.rb +21 -0
- data/test/integration/invitation_test.rb +26 -0
- data/test/jobs/invitation_delivery_job_test.rb +138 -0
- data/test/lazy_loading_test.rb +17 -0
- data/test/mailers/invitation_mail_test.rb +9 -5
- data/test/models/invitable_test.rb +61 -7
- data/test/models/separate_invitations_test.rb +144 -3
- data/test/models_test.rb +17 -0
- data/test/rails_app/app/models/user.rb +4 -1
- data/test/rails_app/db/migrate/20261103170000_add_options_to_users.rb +5 -0
- metadata +10 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'active_support/deprecation'
|
|
2
|
+
require 'active_support/security_utils'
|
|
2
3
|
require 'devise_invitable/models/authenticatable'
|
|
3
4
|
|
|
4
5
|
module Devise
|
|
@@ -30,7 +31,7 @@ module Devise
|
|
|
30
31
|
attr_accessor :completing_invite
|
|
31
32
|
attr_accessor :registering_directly
|
|
32
33
|
attr_accessor :invited_by_for_next_invitation
|
|
33
|
-
attr_reader :
|
|
34
|
+
attr_reader :acceptance_token
|
|
34
35
|
|
|
35
36
|
included do
|
|
36
37
|
include ::DeviseInvitable::Inviter
|
|
@@ -145,6 +146,13 @@ module Devise
|
|
|
145
146
|
current_invitation&.invitation_token = value
|
|
146
147
|
end
|
|
147
148
|
|
|
149
|
+
# The public raw token is an encrypted acceptance envelope for new
|
|
150
|
+
# invitations. Before persistence it falls back to the underlying Devise
|
|
151
|
+
# token so invitation callbacks retain their historical behavior.
|
|
152
|
+
def raw_invitation_token
|
|
153
|
+
acceptance_token || @raw_invitation_token
|
|
154
|
+
end
|
|
155
|
+
|
|
148
156
|
def invitation_created_at
|
|
149
157
|
current_invitation&.invitation_created_at
|
|
150
158
|
end
|
|
@@ -201,38 +209,42 @@ module Devise
|
|
|
201
209
|
current_invitation.invitation_token = nil
|
|
202
210
|
end
|
|
203
211
|
|
|
204
|
-
# Save the resource, accept the selected invitation, and
|
|
205
|
-
# other outstanding invitations for the same
|
|
212
|
+
# Save the resource, accept the selected invitation, and resolve the
|
|
213
|
+
# other outstanding invitations for the same invite keys.
|
|
206
214
|
def accept_invitation!
|
|
207
215
|
if current_invitation&.pending?
|
|
216
|
+
@accepted_invitation_for_provisioned_resource = resource_previously_provisioned?
|
|
217
|
+
provisioning_acceptance = !accepted_invitation_for_provisioned_resource?
|
|
218
|
+
previous_confirmed_at = confirmed_at if respond_to?(:confirmed_at)
|
|
208
219
|
@accepting_invitation = true
|
|
220
|
+
skip_reconfirmation! if respond_to?(:skip_reconfirmation!)
|
|
209
221
|
run_callbacks :invitation_accepted do
|
|
210
222
|
accepted_at = Time.now.utc
|
|
211
223
|
self.confirmed_at ||= accepted_at if respond_to?(:confirmed_at=)
|
|
212
224
|
|
|
213
225
|
if save
|
|
214
226
|
current_invitation.invitable = self
|
|
215
|
-
current_invitation.resource_created_at ||= accepted_at
|
|
227
|
+
current_invitation.resource_created_at ||= accepted_at if provisioning_acceptance
|
|
216
228
|
current_invitation.invitation_accepted_at = accepted_at
|
|
217
229
|
current_invitation.invitation_token = nil
|
|
218
230
|
current_invitation.save!
|
|
219
|
-
|
|
231
|
+
resolve_other_invitations_after_acceptance!(accepted_at)
|
|
220
232
|
reset_devise_invitation_associations
|
|
221
233
|
true
|
|
222
234
|
else
|
|
223
235
|
false
|
|
224
236
|
end
|
|
225
237
|
end.tap do |saved|
|
|
226
|
-
rollback_accepted_invitation unless saved
|
|
238
|
+
rollback_accepted_invitation(previous_confirmed_at) unless saved
|
|
227
239
|
@accepting_invitation = false
|
|
228
240
|
end
|
|
229
241
|
end
|
|
230
242
|
end
|
|
231
243
|
|
|
232
|
-
def rollback_accepted_invitation
|
|
244
|
+
def rollback_accepted_invitation(previous_confirmed_at = nil)
|
|
233
245
|
invitation = current_invitation
|
|
234
246
|
invitation.reload if invitation&.persisted?
|
|
235
|
-
self.confirmed_at =
|
|
247
|
+
self.confirmed_at = previous_confirmed_at if respond_to?(:confirmed_at=)
|
|
236
248
|
end
|
|
237
249
|
|
|
238
250
|
# Verify wheather a user is created by invitation, irrespective to invitation status
|
|
@@ -240,6 +252,14 @@ module Devise
|
|
|
240
252
|
devise_invitations.provisioning.exists?
|
|
241
253
|
end
|
|
242
254
|
|
|
255
|
+
def resource_previously_provisioned?
|
|
256
|
+
persisted? && !invitation_pending?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def accepted_invitation_for_provisioned_resource?
|
|
260
|
+
!!@accepted_invitation_for_provisioned_resource
|
|
261
|
+
end
|
|
262
|
+
|
|
243
263
|
# True only while this record is a placeholder created by an unresolved
|
|
244
264
|
# invitation. An active resource may have other pending invitations.
|
|
245
265
|
def invitation_pending?
|
|
@@ -274,6 +294,8 @@ module Devise
|
|
|
274
294
|
|
|
275
295
|
# Reset invitation token and send invitation again
|
|
276
296
|
def invite!(invited_by = nil, options = {})
|
|
297
|
+
options = options.dup
|
|
298
|
+
invitation_data = options.delete(:invitation_data)
|
|
277
299
|
resource_was_new = new_record?
|
|
278
300
|
# Required to workaround confirmable model's confirmation_required? method
|
|
279
301
|
# being implemented to check for non-nil value of confirmed_at
|
|
@@ -286,6 +308,7 @@ module Devise
|
|
|
286
308
|
self.downcase_keys if new_record_and_responds_to?(:downcase_keys)
|
|
287
309
|
self.strip_whitespace if new_record_and_responds_to?(:strip_whitespace)
|
|
288
310
|
invitation = build_devise_invitation(invited_by)
|
|
311
|
+
invitation.invitation_data = invitation_data
|
|
289
312
|
if instance_variable_defined?(:@invited_by_for_next_invitation)
|
|
290
313
|
remove_instance_variable(:@invited_by_for_next_invitation)
|
|
291
314
|
end
|
|
@@ -293,6 +316,8 @@ module Devise
|
|
|
293
316
|
generate_invitation_token
|
|
294
317
|
|
|
295
318
|
run_callbacks :invitation_created do
|
|
319
|
+
invitation.invitation_data = DeviseInvitable::AcceptanceToken
|
|
320
|
+
.normalize_invitation_data(invitation.invitation_data)
|
|
296
321
|
now = Time.now.utc
|
|
297
322
|
invitation.invitation_created_at = now
|
|
298
323
|
|
|
@@ -305,8 +330,15 @@ module Devise
|
|
|
305
330
|
end
|
|
306
331
|
invitation.save!
|
|
307
332
|
reset_devise_invitation_associations
|
|
333
|
+
generate_acceptance_token
|
|
308
334
|
invitation.invited_by.decrement_invitation_limit! if invitation.invited_by.present?
|
|
309
|
-
|
|
335
|
+
unless skip_invitation
|
|
336
|
+
if self.class.deliver_invitation_later
|
|
337
|
+
enqueue_invitation_delivery(options)
|
|
338
|
+
else
|
|
339
|
+
deliver_invitation(options)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
310
342
|
end
|
|
311
343
|
end
|
|
312
344
|
end
|
|
@@ -338,7 +370,8 @@ module Devise
|
|
|
338
370
|
|
|
339
371
|
# Enforce password when invitation is being accepted
|
|
340
372
|
def password_required?
|
|
341
|
-
(accepting_invitation? &&
|
|
373
|
+
(accepting_invitation? && !resource_previously_provisioned? &&
|
|
374
|
+
self.class.require_password_on_accepting) || super
|
|
342
375
|
end
|
|
343
376
|
|
|
344
377
|
def unauthenticated_message
|
|
@@ -359,16 +392,12 @@ module Devise
|
|
|
359
392
|
|
|
360
393
|
# Deliver the invitation email
|
|
361
394
|
def deliver_invitation(options = {})
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
send_devise_notification(:invitation_instructions, @raw_invitation_token, options)
|
|
369
|
-
rescue
|
|
370
|
-
current_invitation.update_attribute(:invitation_sent_at, nil) if marked_as_sent
|
|
371
|
-
raise
|
|
395
|
+
DeviseInvitable::InvitationDelivery.call(
|
|
396
|
+
current_invitation,
|
|
397
|
+
resource: self,
|
|
398
|
+
acceptance_token: acceptance_token,
|
|
399
|
+
options: options
|
|
400
|
+
)
|
|
372
401
|
end
|
|
373
402
|
|
|
374
403
|
# provide alias to the encrypted invitation_token stored by devise
|
|
@@ -414,6 +443,15 @@ module Devise
|
|
|
414
443
|
association._loaded?
|
|
415
444
|
end
|
|
416
445
|
|
|
446
|
+
def enqueue_invitation_delivery(options)
|
|
447
|
+
DeviseInvitable::InvitationDelivery.enqueue(
|
|
448
|
+
current_invitation,
|
|
449
|
+
resource: self,
|
|
450
|
+
acceptance_token: acceptance_token,
|
|
451
|
+
options: options
|
|
452
|
+
)
|
|
453
|
+
end
|
|
454
|
+
|
|
417
455
|
def assign_invitation_attribute(attribute, value)
|
|
418
456
|
return unless current_invitation
|
|
419
457
|
|
|
@@ -470,19 +508,49 @@ module Devise
|
|
|
470
508
|
generate_invitation_token && current_invitation.save!
|
|
471
509
|
end
|
|
472
510
|
|
|
511
|
+
def generate_acceptance_token
|
|
512
|
+
@acceptance_token = DeviseInvitable::AcceptanceToken.generate(
|
|
513
|
+
invitation: current_invitation,
|
|
514
|
+
raw_token: @raw_invitation_token,
|
|
515
|
+
invite_key: current_invitation.invite_key_for_acceptance.presence ||
|
|
516
|
+
self.class.normalized_invite_keys(attributes),
|
|
517
|
+
invitation_data: current_invitation.invitation_data
|
|
518
|
+
)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def prepare_invitation_delivery
|
|
522
|
+
generate_invitation_token! unless @raw_invitation_token
|
|
523
|
+
generate_acceptance_token unless acceptance_token
|
|
524
|
+
acceptance_token
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def send_invitation_notification_now(token, options)
|
|
528
|
+
devise_mailer
|
|
529
|
+
.send(:invitation_instructions, self, token, options)
|
|
530
|
+
.deliver_now
|
|
531
|
+
end
|
|
532
|
+
|
|
473
533
|
def new_record_and_responds_to?(method)
|
|
474
534
|
self.new_record? && self.respond_to?(method, true)
|
|
475
535
|
end
|
|
476
536
|
|
|
477
537
|
def build_devise_invitation(invited_by)
|
|
478
|
-
invite_key_values = self.class.
|
|
479
|
-
|
|
480
|
-
end
|
|
481
|
-
DeviseInvitable::Invitation.new(
|
|
538
|
+
invite_key_values = self.class.normalized_invite_keys(attributes)
|
|
539
|
+
invitation = DeviseInvitable::Invitation.new(
|
|
482
540
|
invitable_type: self.class.devise_invitable_base_class.name,
|
|
483
|
-
invite_key:
|
|
541
|
+
invite_key: {},
|
|
484
542
|
invited_by: invited_by || invited_by_for_next_invitation
|
|
485
543
|
)
|
|
544
|
+
invitation.transient_invite_key = invite_key_values
|
|
545
|
+
invitation
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def resolve_other_invitations_after_acceptance!(time)
|
|
549
|
+
if self.class.supersede_other_invitations_on_accept
|
|
550
|
+
supersede_other_invitations!(time)
|
|
551
|
+
else
|
|
552
|
+
preserve_other_invitations!
|
|
553
|
+
end
|
|
486
554
|
end
|
|
487
555
|
|
|
488
556
|
def supersede_other_invitations!(time)
|
|
@@ -499,6 +567,16 @@ module Devise
|
|
|
499
567
|
end
|
|
500
568
|
end
|
|
501
569
|
|
|
570
|
+
def preserve_other_invitations!
|
|
571
|
+
self.class.pending_invitations_for(attributes).each do |invitation|
|
|
572
|
+
next if invitation.id == current_invitation.id
|
|
573
|
+
|
|
574
|
+
invitation.invitable = self
|
|
575
|
+
invitation.resource_created_at = nil if current_invitation.provisioning?
|
|
576
|
+
invitation.save!
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
502
580
|
def resolve_invitations_after_registration!
|
|
503
581
|
self.class.pending_invitations_for(attributes).each do |invitation|
|
|
504
582
|
invitation.update!(
|
|
@@ -608,6 +686,10 @@ module Devise
|
|
|
608
686
|
original_token = attributes.delete(:invitation_token)
|
|
609
687
|
invitable = find_by_invitation_token(original_token, false)
|
|
610
688
|
if invitable.errors.empty?
|
|
689
|
+
if invitable.resource_previously_provisioned?
|
|
690
|
+
attributes.delete(:password)
|
|
691
|
+
attributes.delete(:password_confirmation)
|
|
692
|
+
end
|
|
611
693
|
invitable.assign_attributes(attributes)
|
|
612
694
|
invitable.accept_invitation!
|
|
613
695
|
end
|
|
@@ -622,12 +704,9 @@ module Devise
|
|
|
622
704
|
return
|
|
623
705
|
end
|
|
624
706
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
invitation_token: invitation_token
|
|
629
|
-
).first
|
|
630
|
-
invitable = invitation&.invitable || new(invitation ? invitation.invite_key : {})
|
|
707
|
+
invitation, invite_key = invitation_and_key_from_token(original_token)
|
|
708
|
+
invitable = invitation&.invitable || new(invite_key || {})
|
|
709
|
+
invitable.assign_attributes(invite_key) if invitation && invite_key.present?
|
|
631
710
|
invitable.instance_variable_set(:@current_invitation, invitation)
|
|
632
711
|
if invitation.nil?
|
|
633
712
|
invitable.errors.add(:invitation_token, :invalid)
|
|
@@ -640,9 +719,14 @@ module Devise
|
|
|
640
719
|
def pending_invitations_for(attributes)
|
|
641
720
|
keys = normalized_invite_keys(attributes)
|
|
642
721
|
digest = DeviseInvitable::Invitation.digest_for(devise_invitable_base_class.name, keys)
|
|
643
|
-
DeviseInvitable::Invitation.
|
|
644
|
-
|
|
645
|
-
|
|
722
|
+
DeviseInvitable::Invitation.to_adapter.find_all(
|
|
723
|
+
conditions: {
|
|
724
|
+
invitation_accepted_at: nil,
|
|
725
|
+
superseded_at: nil,
|
|
726
|
+
revoked_at: nil,
|
|
727
|
+
invitable_type: devise_invitable_base_class.name,
|
|
728
|
+
invite_key_digest: digest
|
|
729
|
+
}
|
|
646
730
|
).to_a
|
|
647
731
|
end
|
|
648
732
|
|
|
@@ -683,6 +767,9 @@ module Devise
|
|
|
683
767
|
Devise::Models.config(self, :allow_insecure_sign_in_after_accept)
|
|
684
768
|
Devise::Models.config(self, :require_password_on_accepting)
|
|
685
769
|
Devise::Models.config(self, :create_resource_on_invitation)
|
|
770
|
+
Devise::Models.config(self, :store_invite_keys_until_delivery)
|
|
771
|
+
Devise::Models.config(self, :deliver_invitation_later)
|
|
772
|
+
Devise::Models.config(self, :supersede_other_invitations_on_accept)
|
|
686
773
|
|
|
687
774
|
def invited_by_class_name=(value)
|
|
688
775
|
DeviseInvitable.warn_deprecated_invited_by_setting(:invited_by_class_name)
|
|
@@ -696,6 +783,44 @@ module Devise
|
|
|
696
783
|
|
|
697
784
|
private
|
|
698
785
|
|
|
786
|
+
def invitation_and_key_from_token(original_token)
|
|
787
|
+
payload = DeviseInvitable::AcceptanceToken.decode(original_token)
|
|
788
|
+
return legacy_invitation_and_key(original_token) unless payload
|
|
789
|
+
return [nil, nil] unless payload[:invitable_type] == devise_invitable_base_class.name
|
|
790
|
+
|
|
791
|
+
invitation_token = Devise.token_generator.digest(
|
|
792
|
+
self,
|
|
793
|
+
:invitation_token,
|
|
794
|
+
payload[:raw_token]
|
|
795
|
+
)
|
|
796
|
+
invitation = DeviseInvitable::Invitation.to_adapter.find_first(
|
|
797
|
+
id: payload[:invitation_id],
|
|
798
|
+
invitable_type: devise_invitable_base_class.name,
|
|
799
|
+
invitation_token: invitation_token
|
|
800
|
+
)
|
|
801
|
+
invitation.invitation_data = payload[:invitation_data] || {} if invitation
|
|
802
|
+
invite_key = normalized_invite_keys(payload[:invite_key] || {})
|
|
803
|
+
expected_digest = DeviseInvitable::Invitation.digest_for(
|
|
804
|
+
devise_invitable_base_class.name,
|
|
805
|
+
invite_key
|
|
806
|
+
)
|
|
807
|
+
invitation = nil unless invitation &&
|
|
808
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
809
|
+
invitation.invite_key_digest,
|
|
810
|
+
expected_digest
|
|
811
|
+
)
|
|
812
|
+
[invitation, invite_key]
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def legacy_invitation_and_key(original_token)
|
|
816
|
+
invitation_token = Devise.token_generator.digest(self, :invitation_token, original_token)
|
|
817
|
+
invitation = DeviseInvitable::Invitation.to_adapter.find_first(
|
|
818
|
+
invitable_type: devise_invitable_base_class.name,
|
|
819
|
+
invitation_token: invitation_token
|
|
820
|
+
)
|
|
821
|
+
[invitation, invitation&.invite_key]
|
|
822
|
+
end
|
|
823
|
+
|
|
699
824
|
def ensure_unique_invite_key_match!(required_attributes, attributes)
|
|
700
825
|
filtered_attributes = attributes.to_h.with_indifferent_access
|
|
701
826
|
.slice(*required_attributes)
|
|
@@ -703,7 +828,7 @@ module Devise
|
|
|
703
828
|
return unless filtered_attributes.size == required_attributes.size
|
|
704
829
|
|
|
705
830
|
filtered_attributes = devise_parameter_filter.filter(filtered_attributes)
|
|
706
|
-
matches =
|
|
831
|
+
matches = to_adapter.find_all(conditions: filtered_attributes, limit: 2).to_a
|
|
707
832
|
return unless matches.many?
|
|
708
833
|
|
|
709
834
|
raise DeviseInvitable::AmbiguousInviteKeyError,
|
data/lib/devise_invitable.rb
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
module DeviseInvitable
|
|
2
2
|
class AmbiguousInviteKeyError < StandardError; end
|
|
3
|
+
class InvitationDataTooLargeError < ArgumentError; end
|
|
4
|
+
class MissingInviteKeyError < StandardError; end
|
|
3
5
|
class ResourceAttributesRequirePlaceholderError < ArgumentError; end
|
|
4
6
|
|
|
7
|
+
autoload :AcceptanceToken, 'devise_invitable/acceptance_token'
|
|
5
8
|
autoload :Inviter, 'devise_invitable/inviter'
|
|
6
9
|
autoload :Invitation, 'devise_invitable/invitation'
|
|
10
|
+
autoload :InvitationDelivery, 'devise_invitable/invitation_delivery'
|
|
11
|
+
autoload :InvitationDeliveryJob, 'devise_invitable/invitation_delivery_job'
|
|
7
12
|
autoload :Mailer, 'devise_invitable/mailer'
|
|
8
13
|
autoload :Migrator, 'devise_invitable/migrator'
|
|
9
14
|
autoload :Mapping, 'devise_invitable/mapping'
|
|
@@ -12,6 +17,15 @@ module DeviseInvitable
|
|
|
12
17
|
autoload :Registrations, 'devise_invitable/controllers/registrations'
|
|
13
18
|
autoload :Helpers, 'devise_invitable/controllers/helpers'
|
|
14
19
|
end
|
|
20
|
+
|
|
21
|
+
def self.invitation_delivery_job
|
|
22
|
+
job = Devise.invitation_delivery_job
|
|
23
|
+
job = job.constantize if job.respond_to?(:constantize)
|
|
24
|
+
return job if job.respond_to?(:perform_later)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
'Devise.invitation_delivery_job must respond to perform_later'
|
|
28
|
+
end
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
require 'devise'
|
|
@@ -101,10 +115,36 @@ module Devise
|
|
|
101
115
|
@@require_password_on_accepting = true
|
|
102
116
|
|
|
103
117
|
# Public: Create the invited resource as soon as an invitation is sent.
|
|
104
|
-
# When false, only the invitation
|
|
105
|
-
#
|
|
118
|
+
# When false, only the invitation record is persisted and the resource is
|
|
119
|
+
# created when the invitation is accepted.
|
|
106
120
|
mattr_accessor :create_resource_on_invitation
|
|
107
121
|
@@create_resource_on_invitation = false
|
|
122
|
+
|
|
123
|
+
# Public: Store invite keys until an invitation is delivered so a skipped,
|
|
124
|
+
# resource-less invitation can be delivered later. Stored keys are cleared
|
|
125
|
+
# after successful delivery.
|
|
126
|
+
mattr_accessor :store_invite_keys_until_delivery
|
|
127
|
+
@@store_invite_keys_until_delivery = false
|
|
128
|
+
|
|
129
|
+
# Public: Deliver invitation emails through the configured invitation job.
|
|
130
|
+
# Other Devise notifications continue to use send_devise_notification.
|
|
131
|
+
mattr_accessor :deliver_invitation_later
|
|
132
|
+
@@deliver_invitation_later = false
|
|
133
|
+
|
|
134
|
+
# Public: Active Job class used by Invitation#deliver_later. It may be a
|
|
135
|
+
# class or a class name and must respond to perform_later.
|
|
136
|
+
mattr_accessor :invitation_delivery_job
|
|
137
|
+
@@invitation_delivery_job = 'DeviseInvitable::InvitationDeliveryJob'
|
|
138
|
+
|
|
139
|
+
# Public: Queue used by the default invitation delivery job.
|
|
140
|
+
mattr_accessor :invitation_delivery_queue_name
|
|
141
|
+
@@invitation_delivery_queue_name = :mailers
|
|
142
|
+
|
|
143
|
+
# Public: Supersede other pending invitations for the same invite keys when
|
|
144
|
+
# one invitation is accepted. Disable this only when each invitation carries
|
|
145
|
+
# an independently acceptable application-specific context.
|
|
146
|
+
mattr_accessor :supersede_other_invitations_on_accept
|
|
147
|
+
@@supersede_other_invitations_on_accept = true
|
|
108
148
|
end
|
|
109
149
|
|
|
110
150
|
module DeviseInvitable
|
|
@@ -51,6 +51,27 @@ module DeviseInvitable
|
|
|
51
51
|
# Default: false
|
|
52
52
|
# config.create_resource_on_invitation = true
|
|
53
53
|
|
|
54
|
+
# Store invite keys until delivery so skipped invitations without placeholder
|
|
55
|
+
# resources can be delivered later. Keys are cleared after successful delivery.
|
|
56
|
+
# Default: false
|
|
57
|
+
# config.store_invite_keys_until_delivery = true
|
|
58
|
+
|
|
59
|
+
# Deliver invitation emails with the configured invitation delivery job.
|
|
60
|
+
# Other Devise notifications still use send_devise_notification.
|
|
61
|
+
# Default: false
|
|
62
|
+
# config.deliver_invitation_later = true
|
|
63
|
+
|
|
64
|
+
# Active Job class and queue used by invitation.deliver_later. Configure a
|
|
65
|
+
# custom job class when invitations should inherit from ApplicationJob or use
|
|
66
|
+
# application-specific retry and callback behavior.
|
|
67
|
+
# config.invitation_delivery_job = "MyInvitationDeliveryJob"
|
|
68
|
+
# config.invitation_delivery_queue_name = :mailers
|
|
69
|
+
|
|
70
|
+
# Supersede other pending invitations for the same invite keys when one is
|
|
71
|
+
# accepted. Disable this only for independently acceptable invitation data.
|
|
72
|
+
# Default: true
|
|
73
|
+
# config.supersede_other_invitations_on_accept = false
|
|
74
|
+
|
|
54
75
|
# The column name used for counter_cache column. If this is nil,
|
|
55
76
|
# the #invited_by association is declared without counter_cache.
|
|
56
77
|
# Default: nil
|
|
@@ -147,6 +147,32 @@ class InvitationTest < ActionDispatch::IntegrationTest
|
|
|
147
147
|
Devise.allow_insecure_sign_in_after_accept = original_option_value
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
test 'does not sign in an already provisioned user when accepting another invitation' do
|
|
151
|
+
original_create_resource = User.create_resource_on_invitation
|
|
152
|
+
original_supersede = User.supersede_other_invitations_on_accept
|
|
153
|
+
User.create_resource_on_invitation = false
|
|
154
|
+
User.supersede_other_invitations_on_accept = false
|
|
155
|
+
|
|
156
|
+
first_invited = User.invite!(email: 'valid@email.com')
|
|
157
|
+
first_token = first_invited.raw_invitation_token
|
|
158
|
+
second_invited = User.invite!(email: 'valid@email.com')
|
|
159
|
+
second_token = second_invited.raw_invitation_token
|
|
160
|
+
user = User.accept_invitation!(
|
|
161
|
+
invitation_token: first_token,
|
|
162
|
+
password: '123456789',
|
|
163
|
+
password_confirmation: '123456789'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
set_password invitation_token: second_token
|
|
167
|
+
|
|
168
|
+
assert_equal new_user_session_path, current_path
|
|
169
|
+
assert user.reload.valid_password?('123456789')
|
|
170
|
+
refute user.valid_password?('987654321')
|
|
171
|
+
ensure
|
|
172
|
+
User.create_resource_on_invitation = original_create_resource
|
|
173
|
+
User.supersede_other_invitations_on_accept = original_supersede
|
|
174
|
+
end
|
|
175
|
+
|
|
150
176
|
test 'clear token and set invitation_accepted_at after recover password instead of accept_invitation' do
|
|
151
177
|
user = User.invite!(email: 'valid@email.com')
|
|
152
178
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require 'test_helper'
|
|
2
|
+
require 'model_tests_helper'
|
|
3
|
+
|
|
4
|
+
class InvitationDeliveryJobTest < ActiveSupport::TestCase
|
|
5
|
+
setup do
|
|
6
|
+
@invitation_delivery_job = Devise.invitation_delivery_job
|
|
7
|
+
@invitation_delivery_queue_name = Devise.invitation_delivery_queue_name
|
|
8
|
+
@create_resource_on_invitation = User.create_resource_on_invitation
|
|
9
|
+
@store_invite_keys_until_delivery = User.store_invite_keys_until_delivery
|
|
10
|
+
@deliver_invitation_later = User.deliver_invitation_later
|
|
11
|
+
User.create_resource_on_invitation = false
|
|
12
|
+
User.deliver_invitation_later = false
|
|
13
|
+
DeviseInvitable::Invitation.delete_all
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
teardown do
|
|
17
|
+
Devise.invitation_delivery_job = @invitation_delivery_job
|
|
18
|
+
Devise.invitation_delivery_queue_name = @invitation_delivery_queue_name
|
|
19
|
+
User.create_resource_on_invitation = @create_resource_on_invitation
|
|
20
|
+
User.store_invite_keys_until_delivery = @store_invite_keys_until_delivery
|
|
21
|
+
User.deliver_invitation_later = @deliver_invitation_later
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
test 'deliver_later passes a persisted invitation and acceptance token to the configured job' do
|
|
25
|
+
job_class = Class.new do
|
|
26
|
+
class << self
|
|
27
|
+
attr_reader :arguments
|
|
28
|
+
|
|
29
|
+
def perform_later(*arguments)
|
|
30
|
+
@arguments = arguments
|
|
31
|
+
:enqueued
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
Devise.invitation_delivery_job = job_class
|
|
36
|
+
|
|
37
|
+
user = User.invite!(email: 'queued@example.com', skip_invitation: true)
|
|
38
|
+
invitation = user.current_invitation
|
|
39
|
+
|
|
40
|
+
assert_equal :enqueued, invitation.deliver_later
|
|
41
|
+
enqueued_invitation, acceptance_token, options = job_class.arguments
|
|
42
|
+
assert_equal invitation, enqueued_invitation
|
|
43
|
+
assert_equal({}, options)
|
|
44
|
+
assert_equal 'queued@example.com',
|
|
45
|
+
DeviseInvitable::AcceptanceToken.decode(acceptance_token).dig(:invite_key, :email)
|
|
46
|
+
|
|
47
|
+
assert_difference('ActionMailer::Base.deliveries.size', 1) do
|
|
48
|
+
DeviseInvitable::InvitationDeliveryJob.perform_now(
|
|
49
|
+
DeviseInvitable::Invitation.find(invitation.id),
|
|
50
|
+
acceptance_token,
|
|
51
|
+
options
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
assert_predicate invitation.reload, :invitation_sent_at
|
|
55
|
+
assert_equal ['queued@example.com'], ActionMailer::Base.deliveries.last.to
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test 'the default job delivers a reloaded resource-less invitation from stored keys' do
|
|
59
|
+
User.store_invite_keys_until_delivery = true
|
|
60
|
+
user = User.invite!(email: 'stored@example.com', skip_invitation: true)
|
|
61
|
+
invitation = DeviseInvitable::Invitation.find(user.current_invitation.id)
|
|
62
|
+
|
|
63
|
+
assert_difference('ActionMailer::Base.deliveries.size', 1) do
|
|
64
|
+
DeviseInvitable::InvitationDeliveryJob.perform_now(invitation)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
assert_predicate invitation.reload, :invitation_sent_at
|
|
68
|
+
assert_empty invitation.invite_key
|
|
69
|
+
assert_equal ['stored@example.com'], ActionMailer::Base.deliveries.last.to
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
test 'the default job does not enqueue through send_devise_notification' do
|
|
73
|
+
user = User.invite!(email: 'direct@example.com', skip_invitation: true)
|
|
74
|
+
invitation = user.current_invitation
|
|
75
|
+
acceptance_token = user.acceptance_token
|
|
76
|
+
User.any_instance.stubs(:send_devise_notification).raises('must not be called')
|
|
77
|
+
|
|
78
|
+
assert_difference('ActionMailer::Base.deliveries.size', 1) do
|
|
79
|
+
DeviseInvitable::InvitationDeliveryJob.perform_now(
|
|
80
|
+
invitation,
|
|
81
|
+
acceptance_token
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
assert_predicate invitation.reload, :invitation_sent_at
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test 'the setting enqueues the normal invitation flow with the configured job' do
|
|
89
|
+
job_class = Class.new do
|
|
90
|
+
class << self
|
|
91
|
+
attr_reader :arguments
|
|
92
|
+
|
|
93
|
+
def perform_later(*arguments)
|
|
94
|
+
@arguments = arguments
|
|
95
|
+
:enqueued
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
Devise.invitation_delivery_job = job_class
|
|
100
|
+
User.deliver_invitation_later = true
|
|
101
|
+
User.any_instance.stubs(:send_devise_notification).raises('must not be called')
|
|
102
|
+
|
|
103
|
+
assert_no_difference('ActionMailer::Base.deliveries.size') do
|
|
104
|
+
User.invite!(email: 'configured@example.com')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
invitation, acceptance_token, options = job_class.arguments
|
|
108
|
+
assert_predicate invitation, :persisted?
|
|
109
|
+
assert_nil invitation.invitation_sent_at
|
|
110
|
+
assert_equal({ validate: false }, options)
|
|
111
|
+
assert_equal 'configured@example.com',
|
|
112
|
+
DeviseInvitable::AcceptanceToken.decode(acceptance_token).dig(:invite_key, :email)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
test 'the normal flow keeps using send_devise_notification when the setting is disabled' do
|
|
116
|
+
User.any_instance.expects(:send_devise_notification)
|
|
117
|
+
.with(:invitation_instructions, kind_of(String), { validate: false })
|
|
118
|
+
.returns(:application_delivery)
|
|
119
|
+
|
|
120
|
+
assert_equal :application_delivery,
|
|
121
|
+
User.invite_mail!(email: 'application-delivery@example.com')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
test 'the default job uses the configured queue' do
|
|
125
|
+
Devise.invitation_delivery_queue_name = :critical_mailers
|
|
126
|
+
|
|
127
|
+
assert_equal 'critical_mailers', DeviseInvitable::InvitationDeliveryJob.new.queue_name
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
test 'a reloaded resource-less invitation needs stored keys or an acceptance token' do
|
|
131
|
+
user = User.invite!(email: 'missing@example.com', skip_invitation: true)
|
|
132
|
+
invitation = DeviseInvitable::Invitation.find(user.current_invitation.id)
|
|
133
|
+
|
|
134
|
+
assert_raises(DeviseInvitable::MissingInviteKeyError) do
|
|
135
|
+
invitation.deliver_later
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'test_helper'
|
|
2
|
+
require 'open3'
|
|
3
|
+
|
|
4
|
+
class LazyLoadingTest < ActiveSupport::TestCase
|
|
5
|
+
LIBRARY = File.expand_path('../lib', __dir__)
|
|
6
|
+
|
|
7
|
+
test 'loading devise invitable does not load Active Job' do
|
|
8
|
+
script = <<~RUBY
|
|
9
|
+
$LOAD_PATH.unshift #{LIBRARY.inspect}
|
|
10
|
+
require 'devise_invitable'
|
|
11
|
+
abort 'Active Job was loaded' if defined?(ActiveJob::Base)
|
|
12
|
+
RUBY
|
|
13
|
+
_stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-e', script)
|
|
14
|
+
|
|
15
|
+
assert status.success?, stderr
|
|
16
|
+
end
|
|
17
|
+
end
|