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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d7694e9f3f376c8f60a5d426144eebe986967f33b6ea016ca1f01c65fa19e32
4
- data.tar.gz: 40faca7611e16510fe0d45f886e35f6622eb7d1cefe9016be0e93ba6d272e2e4
3
+ metadata.gz: 14df8dd3f270570211fc502f10b3e0a10a4f81b064a11217b0963f5118f68255
4
+ data.tar.gz: b1e0b2ffada6cc6099c6aeffef44677549732f08f842e65c6832549f69ba9ef3
5
5
  SHA512:
6
- metadata.gz: aab0c7da6b34b49475014c55ace27804cff1c95b15012a3209c0cf7f7e42e6612647dbdeb0ddacd8fd6e26353b2617355f8c4e2f25a3dd8a2e4aaf9aa0b89d7e
7
- data.tar.gz: 2d7873782c77d7c9076312eea7a57cb8cfc4e54874dbd4705e6133d1a5ed92f981291ec8bc7bd81cf4570836059c2b2aff6fffdf01b54f0056d67e62467a5a50
6
+ metadata.gz: ea404feea290eb01ed9108f1c541b8df0df3a664813c7ee630ae96c198647858560e126196d1173d521106a11e22a6d000308102d60719a5f220484ff08e4d57
7
+ data.tar.gz: 35541ff5985d4d3c85ea9b2444b5d8f4ef54f18d1a7bd3ab5d6e77b262b50c5f6b77e38f36927bdc4760e9979f47d80099227080a46fe8048ef2b557cf80f06e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 3.1.0
2
+
3
+ - Add bounded, encrypted `invitation_data` for application-specific acceptance
4
+ context, and an opt-in mode that preserves other invitations for independent
5
+ acceptance without changing its password or signing it in
6
+ - Keep invite keys out of new invitation records by carrying them in an
7
+ encrypted, authenticated acceptance token. Existing invitation links with
8
+ plaintext invite keys remain supported ([#777](https://github.com/scambra/devise_invitable/issues/777))
9
+ - Add opt-in temporary invite-key storage for resource-less invitations that
10
+ must be approved and delivered later; stored keys are cleared after delivery
11
+ - Add `DeviseInvitable::Invitation#deliver_later`, a lazily loaded default
12
+ Active Job, an opt-in `deliver_invitation_later` setting for the normal flow,
13
+ and configurable job class and queue settings for background invitation email
14
+ delivery without nesting jobs created by `send_devise_notification` overrides
15
+
1
16
  ## 3.0.4
2
17
 
3
18
  - Avoid using distinct which may fail in postgresql if the model has json column ([935](https://github.com/scambra/devise_invitable/issues/935))
data/README.md CHANGED
@@ -150,7 +150,9 @@ no limit for any user, `invitation_limit` column is not used. A setting
150
150
  of `0` means they can't send invitations. A setting `n > 0` means they can
151
151
  send `n` invitations. You can change `invitation_limit` column for some
152
152
  users so they can send more or less invitations, even with global
153
- `invitation_limit = 0`.
153
+ `invitation_limit = 0`. This setting controls how many invitations an inviter
154
+ may send; use `authenticate_inviter!` for authorization and other
155
+ application-specific conditions.
154
156
 
155
157
  * `invite_key`: The key to be used to check existing users when sending an
156
158
  invitation. You can use multiple keys. This value must be a hash with the
@@ -172,11 +174,34 @@ polymorphic `invited_by_id` and `invited_by_type` columns in the shared
172
174
  invitations table.
173
175
 
174
176
  * `create_resource_on_invitation`: create a placeholder resource when the
175
- invitation is sent. It is disabled by default, so only invite keys are stored
176
- until acceptance. Enable it when the application needs an addressable resource
177
- before signup or needs to store additional permitted resource attributes from
178
- the invitation form. Arbitrary resource attributes are rejected when this is
179
- disabled.
177
+ invitation is sent. It is disabled by default, so only the invitation record is
178
+ persisted while invite keys remain in the encrypted acceptance token. Enable it
179
+ when the application needs an addressable resource before signup or needs to
180
+ store additional permitted resource attributes from the invitation form.
181
+ Arbitrary resource attributes are rejected when this is disabled.
182
+
183
+ * `store_invite_keys_until_delivery`: temporarily store invite keys in the
184
+ invitation record so a skipped, resource-less invitation can be loaded and
185
+ delivered later. It is disabled by default. Stored keys are cleared after
186
+ successful delivery, when they are available in the encrypted acceptance token.
187
+
188
+ * `deliver_invitation_later`: enqueue invitation emails with the configured
189
+ invitation delivery job. It is disabled by default, preserving the normal
190
+ `send_devise_notification` hook.
191
+
192
+ * `invitation_delivery_job`: job class used by `Invitation#deliver_later`.
193
+ It may be a class or class name and must respond to `perform_later`. The default
194
+ is `DeviseInvitable::InvitationDeliveryJob`.
195
+
196
+ * `invitation_delivery_queue_name`: queue used by the default invitation
197
+ delivery job. The default is `:mailers`.
198
+
199
+ * `supersede_other_invitations_on_accept`: supersede other pending
200
+ invitations for the same invite keys when one is accepted. Enabled by default.
201
+ Disable it only when each invitation carries independently acceptable
202
+ application context. Remaining invitations are linked to the provisioned
203
+ resource and their tokens stay valid; accepting them does not require or change
204
+ the resource's password.
180
205
 
181
206
  * `invited_by_counter_cache`: the column name used for counter_cache column.
182
207
  If this is `nil` (default value), the `invited_by` association is declared
@@ -417,6 +442,58 @@ that invitation accepted and supersedes the others. If the invitee registers
417
442
  directly, all matching pending invitations are superseded rather than marked
418
443
  accepted.
419
444
 
445
+ ### Pass application context with an invitation
446
+
447
+ Small application-specific values can be carried in the encrypted acceptance
448
+ token with the `invitation_data` option:
449
+
450
+ ```ruby
451
+ User.invite!(
452
+ { email: 'new_user@example.com' },
453
+ current_user,
454
+ invitation_data: { organization_id: organization.id, role: 'member' }
455
+ )
456
+ ```
457
+
458
+ `invitation_data` must be a JSON-compatible hash whose serialized form is no
459
+ larger than 1,024 bytes. It is deliberately not persisted by devise_invitable.
460
+ After token decoding, JSON object keys are strings. The data is available as
461
+ `current_invitation.invitation_data` in invitation callbacks and controller
462
+ code. For example:
463
+
464
+ ```ruby
465
+ after_invitation_created do |user|
466
+ InvitationContext.create!(
467
+ devise_invitation: user.current_invitation,
468
+ data: user.current_invitation.invitation_data
469
+ )
470
+ end
471
+
472
+ after_invitation_accepted do |user|
473
+ organization_id = user.current_invitation.invitation_data['organization_id']
474
+ user.memberships.find_or_create_by!(organization_id: organization_id)
475
+ end
476
+ ```
477
+
478
+ Persist the context in an application model when it must survive independently
479
+ of the email link, be processed after another invitation is accepted, or be
480
+ combined with other invitations. Keep it URL-only when each invitation should
481
+ be accepted separately. To preserve the other links in that case:
482
+
483
+ ```ruby
484
+ class User < ApplicationRecord
485
+ devise :database_authenticatable, :invitable,
486
+ supersede_other_invitations_on_accept: false
487
+ end
488
+ ```
489
+
490
+ The first accepted invitation provisions the resource normally. Later
491
+ invitations run the acceptance callbacks with their own `invitation_data`, but
492
+ cannot replace or require the existing password and do not automatically sign
493
+ the resource in. Invitation data is a bearer credential; applications must
494
+ re-check referenced resources, authorization, and current roles when accepting
495
+ it.
496
+
420
497
  Placeholder resources expose `invitation_pending?`. Pending placeholders are
421
498
  rejected by both Devise's password validation and
422
499
  `active_for_authentication?` checks until an invitation is accepted or the
@@ -426,15 +503,21 @@ resource is claimed by direct registration.
426
503
 
427
504
  Invitation keys commonly contain personal data such as an email address. With
428
505
  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.
506
+ and new invitations do not persist the invite-key values in the
507
+ `devise_invitations.invite_key` JSON column. The values are carried in the
508
+ encrypted and authenticated acceptance token and restored only when the link
509
+ is accepted. `invite_key_digest` is stored so the values in the token can be
510
+ verified against the invitation record. Invitations created before this change
511
+ may still have plaintext invite keys; their existing links remain supported.
433
512
 
434
513
  With `create_resource_on_invitation: true`, the resource and any additional
435
514
  attributes or associations supplied to `invite!` are persisted immediately.
436
515
  Choose this mode only when the application needs a placeholder and its privacy
437
- policy permits storing that data before acceptance.
516
+ policy permits storing that data before acceptance. An application may clear
517
+ the placeholder's invite-key attributes after delivery, for example in an
518
+ `after_invitation_created` callback; the encrypted acceptance token restores
519
+ them during acceptance. The application is responsible for allowing the blank
520
+ placeholder attributes and persisting the cleanup.
438
521
 
439
522
  `invite_for` limits token validity but does not delete invitation or resource
440
523
  records. Applications are responsible for choosing a lawful basis for
@@ -445,12 +528,10 @@ Consent is one possible lawful basis under GDPR, but it is not the only one.
445
528
  See the [European Commission GDPR principles](https://commission.europa.eu/law/law-topic/data-protection/information-business-and-organisations/principles-gdpr_en)
446
529
  and obtain appropriate legal advice for the application.
447
530
 
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.
531
+ The acceptance token is a bearer credential: avoid logging it or exposing it
532
+ to third parties. Its encryption key is derived from the Rails application's
533
+ key generator, so changing `secret_key_base` invalidates outstanding encrypted
534
+ invitation links.
454
535
 
455
536
  If you want to create the invitation but not send it, you can set
456
537
  `skip_invitation` to `true`.
@@ -463,8 +544,10 @@ end
463
544
  ```
464
545
 
465
546
  When generating the `accept_user_invitation_url` yourself, you must use the
466
- `raw_invitation_token`. This value is temporarily available when you invite a
467
- user and will be decrypted when received.
547
+ `raw_invitation_token`. For new invitations this is an encrypted, authenticated
548
+ envelope containing the underlying Devise token and invite keys. It is
549
+ temporarily available on the object that creates the invitation and is not
550
+ persisted in plaintext.
468
551
 
469
552
  ```ruby
470
553
  accept_user_invitation_url(invitation_token: user.raw_invitation_token)
@@ -474,15 +557,22 @@ When `skip_invitation` is used, the resulting invitation remains pending with
474
557
  an unset `invitation_sent_at`. Deliver it later by calling `deliver` on the
475
558
  invitation:
476
559
 
560
+ For resource-less invitations, first enable temporary invite-key storage:
561
+
562
+ ```ruby
563
+ devise :invitable, store_invite_keys_until_delivery: true
564
+ ```
565
+
477
566
  ```ruby
478
567
  invitation = user.current_invitation
479
568
  invitation.deliver
480
569
  ```
481
570
 
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.
571
+ This works for placeholder-backed invitations without additional configuration.
572
+ For a resource-less invitation, `deliver` reconstructs the resource from the
573
+ temporarily stored invite key. It generates a fresh encrypted acceptance token,
574
+ sends the email, clears the stored invite key, and sets `invitation_sent_at`.
575
+ Calling it again after successful delivery does nothing.
486
576
 
487
577
  For example, an approval workflow can find unsent pending invitations and
488
578
  deliver an approved one after applying its own authorization policy:
@@ -508,6 +598,98 @@ User.invite!(email: 'new_user@example.com', skip_invitation: true)
508
598
  `DeviseInvitable::Invitation`. With placeholders enabled,
509
599
  `invited_to_sign_up?` and `invitation_pending?` return `true` on the resource.
510
600
 
601
+ ### Background delivery
602
+
603
+ Devise Invitable includes a lazily loaded Active Job for background invitation
604
+ delivery. To use it for the normal invitation flow, enable it in the Devise
605
+ initializer:
606
+
607
+ ```ruby
608
+ config.deliver_invitation_later = true
609
+ ```
610
+
611
+ ```ruby
612
+ User.invite!(email: 'new_user@example.com')
613
+ ```
614
+
615
+ It can also enqueue an invitation that was created without sending its email:
616
+
617
+ ```ruby
618
+ user = User.invite!(email: 'new_user@example.com', skip_invitation: true)
619
+ user.current_invitation.deliver_later
620
+ ```
621
+
622
+ The default job runs on the `mailers` queue. It reconstructs a resource-less
623
+ invitee from the encrypted acceptance token and calls `deliver_now` on the
624
+ configured Devise mailer because it is already executing inside a background
625
+ job. It retains `invitation_sent_at` after successful delivery and leaves
626
+ failed invitations retryable by clearing that timestamp if delivery raises.
627
+
628
+ The example above does not require `store_invite_keys_until_delivery` because
629
+ the invitation is enqueued while its acceptance token is still available. If
630
+ an invitation will be reloaded and approved before it is enqueued, enable
631
+ temporary invite-key storage:
632
+
633
+ ```ruby
634
+ class User < ApplicationRecord
635
+ devise :invitable, store_invite_keys_until_delivery: true
636
+ end
637
+
638
+ invitation = DeviseInvitable::Invitation.find(approved_invitation_id)
639
+ invitation.deliver_later
640
+ ```
641
+
642
+ Stored invite keys are cleared after successful delivery. Placeholder-backed
643
+ invitations do not need this setting because their persisted resource contains
644
+ the delivery address.
645
+
646
+ Configure the default queue in the Devise initializer:
647
+
648
+ ```ruby
649
+ config.invitation_delivery_queue_name = :critical_mailers
650
+ ```
651
+
652
+ To inherit from the application's `ApplicationJob`, add retries or callbacks,
653
+ or use another compatible job implementation, configure a custom job class:
654
+
655
+ ```ruby
656
+ class ApplicationInvitationDeliveryJob < ApplicationJob
657
+ queue_as :mailers
658
+ retry_on MailProviderError
659
+
660
+ def perform(invitation, acceptance_token = nil, options = {})
661
+ DeviseInvitable::InvitationDelivery.deliver_now(
662
+ invitation,
663
+ acceptance_token: acceptance_token,
664
+ options: options
665
+ )
666
+ end
667
+ end
668
+ ```
669
+
670
+ ```ruby
671
+ config.invitation_delivery_job = 'ApplicationInvitationDeliveryJob'
672
+ ```
673
+
674
+ By default, the normal invitation flow and synchronous `Invitation#deliver`
675
+ continue to use `send_devise_notification`. This is compatible with Devise's
676
+ documented model override that calls Action Mailer's `deliver_later`, including
677
+ for persisted placeholder resources. Action Mailer cannot enqueue an
678
+ unpersisted, resource-less invitee, so enable `deliver_invitation_later` when
679
+ `create_resource_on_invitation` is disabled. Enabling the setting or calling
680
+ `Invitation#deliver_later` gives the invitation job ownership of the queue
681
+ boundary. Inside that job, the gem calls the configured Devise mailer directly
682
+ with `deliver_now`, so a `send_devise_notification` override does not create a
683
+ second job. Other Devise notifications continue to use that override normally.
684
+
685
+ Applications without Active Job can leave `deliver_invitation_later` disabled
686
+ and continue to use synchronous delivery, or configure any compatible job class
687
+ that responds to `perform_later`.
688
+
689
+ For immediate resource-less background delivery, the encrypted acceptance
690
+ token is a job argument. Treat the queue as sensitive storage and configure job
691
+ logging so bearer tokens are not exposed. Mail options must also be serializable.
692
+
511
693
  To check if a particular user is created by invitation, irrespective to state
512
694
  of invitation one can use `created_by_invite?`
513
695
 
@@ -653,6 +835,60 @@ class Admin < ActiveRecord::Base
653
835
  end
654
836
  ```
655
837
 
838
+ ### Restricting or disabling invitation endpoints
839
+
840
+ `authenticate_inviter!` may enforce any application-specific condition, not
841
+ only authentication. For example, it can check roles, permissions,
842
+ relationships, tenant membership, or whether an application workflow has been
843
+ completed.
844
+
845
+ The filter protects only the invitation controller's `new` and `create`
846
+ actions. Calling `User.invite!` from an authorized application service,
847
+ controller, callback, or background job is unaffected.
848
+
849
+ To prevent invitations from being sent through the standard invitation form
850
+ while still allowing controlled application workflows, override the filter in
851
+ a custom invitations controller:
852
+
853
+ ```ruby
854
+ class Users::InvitationsController < Devise::InvitationsController
855
+ protected
856
+
857
+ def authenticate_inviter!
858
+ redirect_to root_path, alert: "Invitations cannot be sent from this page."
859
+ end
860
+ end
861
+ ```
862
+
863
+ Configure Devise to use the controller:
864
+
865
+ ```ruby
866
+ devise_for :users, controllers: { invitations: "users/invitations" }
867
+ ```
868
+
869
+ The application can then create invitations from its authorized workflow:
870
+
871
+ ```ruby
872
+ User.invite!(email: email)
873
+ ```
874
+
875
+ When the standard form should remain available to some users, call `super` and
876
+ apply the additional policy:
877
+
878
+ ```ruby
879
+ class Users::InvitationsController < Devise::InvitationsController
880
+ protected
881
+
882
+ def authenticate_inviter!
883
+ super
884
+ return if performed?
885
+ return if current_user.allowed_to_invite_users?
886
+
887
+ redirect_to root_path, alert: "You are not allowed to send invitations."
888
+ end
889
+ end
890
+ ```
891
+
656
892
  ## Has many invitations
657
893
 
658
894
  The `DeviseInvitable::Inviter` module defines a `sent_invitations` association
@@ -51,7 +51,8 @@ class Devise::InvitationsController < DeviseController
51
51
  yield resource if block_given?
52
52
 
53
53
  if invitation_accepted
54
- if resource.class.allow_insecure_sign_in_after_accept
54
+ if resource.class.allow_insecure_sign_in_after_accept &&
55
+ !resource.accepted_invitation_for_provisioned_resource?
55
56
  flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
56
57
  set_flash_message :notice, flash_message if is_flashing_format?
57
58
  resource.after_database_authentication
@@ -11,7 +11,7 @@ class DeviseInvitable::RegistrationsController < Devise::RegistrationsController
11
11
  end
12
12
 
13
13
  def build_invited_resource(hash)
14
- self.resource = resource_class.where(email: hash[:email]).first
14
+ self.resource = resource_class.to_adapter.find_first(email: hash[:email])
15
15
  if self.resource && self.resource.respond_to?(:invited_to_sign_up?) && self.resource.invited_to_sign_up?
16
16
  self.resource.attributes = hash
17
17
  self.resource.send_confirmation_instructions if self.resource.confirmation_required_for_invited?
@@ -0,0 +1,58 @@
1
+ require 'active_support/key_generator'
2
+ require 'active_support/message_encryptor'
3
+ require 'active_support/core_ext/hash/indifferent_access'
4
+ require 'json'
5
+
6
+ module DeviseInvitable
7
+ class AcceptanceToken
8
+ CIPHER = 'aes-256-gcm'.freeze
9
+ MAX_INVITATION_DATA_BYTES = 1_024
10
+ PURPOSE = 'devise_invitable.invitation'.freeze
11
+ SALT = 'devise_invitable encrypted invitation'.freeze
12
+
13
+ class << self
14
+ def generate(invitation:, raw_token:, invite_key:, invitation_data: nil)
15
+ invitation_data = normalize_invitation_data(invitation_data)
16
+ encryptor.encrypt_and_sign(
17
+ {
18
+ invitation_id: invitation.id.to_s,
19
+ invitable_type: invitation.invitable_type,
20
+ raw_token: raw_token,
21
+ invite_key: invite_key.to_h,
22
+ invitation_data: invitation_data,
23
+ },
24
+ purpose: PURPOSE
25
+ )
26
+ end
27
+
28
+ def normalize_invitation_data(invitation_data)
29
+ return {} if invitation_data.nil?
30
+
31
+ data = invitation_data.to_h
32
+ serialized_data = JSON.generate(data)
33
+ if serialized_data.bytesize > MAX_INVITATION_DATA_BYTES
34
+ raise InvitationDataTooLargeError,
35
+ "invitation_data exceeds #{MAX_INVITATION_DATA_BYTES} serialized bytes"
36
+ end
37
+ data
38
+ rescue NoMethodError, TypeError, JSON::GeneratorError
39
+ raise ArgumentError, 'invitation_data must be a JSON-compatible hash'
40
+ end
41
+
42
+ def decode(token)
43
+ payload = encryptor.decrypt_and_verify(token, purpose: PURPOSE)
44
+ payload&.with_indifferent_access
45
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ def encryptor
52
+ key_length = ActiveSupport::MessageEncryptor.key_len(CIPHER)
53
+ key = Rails.application.key_generator.generate_key(SALT, key_length)
54
+ ActiveSupport::MessageEncryptor.new(key, cipher: CIPHER, serializer: JSON)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -9,6 +9,8 @@ module DeviseInvitable
9
9
  end
10
10
 
11
11
  class Invitation < invitation_base
12
+ attr_accessor :invitation_data, :transient_invite_key
13
+
12
14
  self.table_name = 'devise_invitations' if respond_to?(:table_name=)
13
15
 
14
16
  if defined?(Mongoid::Document) && !defined?(ActiveRecord::Base)
@@ -80,11 +82,11 @@ module DeviseInvitable
80
82
  # Deliver a pending invitation that has not been sent yet. Resource-less
81
83
  # invitations are reconstructed from their stored invite key.
82
84
  def deliver(options = {})
83
- return unless pending? && invitation_sent_at.nil?
85
+ DeviseInvitable::InvitationDelivery.call(self, options: options)
86
+ end
84
87
 
85
- resource = invitable || invitable_type.constantize.new(invite_key)
86
- resource.instance_variable_set(:@current_invitation, self)
87
- resource.deliver_invitation(options)
88
+ def deliver_later(options = {})
89
+ DeviseInvitable::InvitationDelivery.enqueue(self, options: options)
88
90
  end
89
91
 
90
92
  def invite_key
@@ -92,6 +94,15 @@ module DeviseInvitable
92
94
  value.respond_to?(:with_indifferent_access) ? value.with_indifferent_access : value
93
95
  end
94
96
 
97
+ def invite_key_for_acceptance
98
+ transient_invite_key.presence || invite_key
99
+ end
100
+
101
+ def clear_invite_key
102
+ self.transient_invite_key = nil
103
+ self.invite_key = {} if invite_key.present?
104
+ end
105
+
95
106
  def self.digest_for(resource_type, invite_key)
96
107
  components = invite_key.to_h.map { |key, value| [key.to_s, value.to_s] }.sort.flatten
97
108
  Digest::SHA256.hexdigest(([resource_type] + components).join("\0"))
@@ -100,7 +111,17 @@ module DeviseInvitable
100
111
  private
101
112
 
102
113
  def set_invite_key_digest
103
- self.invite_key_digest = self.class.digest_for(invitable_type, invite_key)
114
+ return if transient_invite_key.nil? && invite_key.blank? && invite_key_digest.present?
115
+
116
+ self.invite_key_digest = self.class.digest_for(invitable_type, invite_key_for_acceptance)
117
+ unless transient_invite_key.nil?
118
+ self.invite_key =
119
+ if invitable_type.constantize.store_invite_keys_until_delivery
120
+ transient_invite_key
121
+ else
122
+ {}
123
+ end
124
+ end
104
125
  end
105
126
  end
106
127
  end
@@ -0,0 +1,114 @@
1
+ module DeviseInvitable
2
+ class InvitationDelivery
3
+ def self.call(invitation, resource: nil, acceptance_token: nil, options: {})
4
+ new(
5
+ invitation,
6
+ resource: resource,
7
+ acceptance_token: acceptance_token,
8
+ options: options
9
+ ).deliver
10
+ end
11
+
12
+ def self.deliver_now(invitation, resource: nil, acceptance_token: nil, options: {})
13
+ new(
14
+ invitation,
15
+ resource: resource,
16
+ acceptance_token: acceptance_token,
17
+ options: options,
18
+ deliver_now: true
19
+ ).deliver
20
+ end
21
+
22
+ def self.enqueue(invitation, resource: nil, acceptance_token: nil, options: {})
23
+ delivery = new(
24
+ invitation,
25
+ resource: resource,
26
+ acceptance_token: acceptance_token,
27
+ options: options
28
+ )
29
+ return unless delivery.deliverable?
30
+
31
+ DeviseInvitable.invitation_delivery_job.perform_later(
32
+ invitation,
33
+ delivery.acceptance_token,
34
+ options
35
+ )
36
+ end
37
+
38
+ def initialize(invitation, resource: nil, acceptance_token: nil, options: {}, deliver_now: false)
39
+ @invitation = invitation
40
+ @resource = resource
41
+ @acceptance_token = acceptance_token
42
+ @options = options
43
+ @deliver_now = deliver_now
44
+ end
45
+
46
+ def deliver
47
+ return unless deliverable?
48
+
49
+ token = acceptance_token # Token generation may save the invitation.
50
+ invitation.invitation_sent_at = Time.now.utc
51
+ delivery = send_notification(token)
52
+ invitation.clear_invite_key
53
+ invitation.save!
54
+ delivery
55
+ rescue
56
+ invitation.invitation_sent_at = nil
57
+ raise
58
+ end
59
+
60
+ def deliverable?
61
+ invitation.pending? && invitation.invitation_sent_at.nil?
62
+ end
63
+
64
+ def acceptance_token
65
+ @acceptance_token ||= resource.send(:prepare_invitation_delivery)
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :invitation, :options
71
+
72
+ def send_notification(token)
73
+ if @deliver_now
74
+ resource.send(:send_invitation_notification_now, token, options)
75
+ else
76
+ resource.send(
77
+ :send_devise_notification,
78
+ :invitation_instructions,
79
+ token,
80
+ options
81
+ )
82
+ end
83
+ end
84
+
85
+ def resource
86
+ @resource ||= begin
87
+ resolved_resource = invitation.invitable || resource_from_invite_key
88
+ resolved_resource.instance_variable_set(:@current_invitation, invitation)
89
+ resolved_resource
90
+ end
91
+ end
92
+
93
+ def resource_from_invite_key
94
+ invite_key = invitation.invite_key_for_acceptance.presence || invite_key_from_token
95
+ if invite_key.blank?
96
+ raise DeviseInvitable::MissingInviteKeyError,
97
+ 'Cannot deliver a resource-less invitation without an acceptance token or stored invite key'
98
+ end
99
+
100
+ invitation.invitable_type.constantize.new(invite_key)
101
+ end
102
+
103
+ def invite_key_from_token
104
+ return unless @acceptance_token
105
+
106
+ resource_class = invitation.invitable_type.constantize
107
+ token_invitation, invite_key = resource_class.send(
108
+ :invitation_and_key_from_token,
109
+ @acceptance_token
110
+ )
111
+ invite_key if token_invitation&.id == invitation.id
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,15 @@
1
+ require 'active_job'
2
+
3
+ module DeviseInvitable
4
+ class InvitationDeliveryJob < ActiveJob::Base
5
+ queue_as { Devise.invitation_delivery_queue_name }
6
+
7
+ def perform(invitation, acceptance_token = nil, options = {})
8
+ DeviseInvitable::InvitationDelivery.deliver_now(
9
+ invitation,
10
+ acceptance_token: acceptance_token,
11
+ options: options
12
+ )
13
+ end
14
+ end
15
+ end