devise_invitable 2.0.13 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +108 -97
  4. data/app/controllers/devise/invitations_controller.rb +1 -1
  5. data/app/controllers/devise_invitable/registrations_controller.rb +15 -10
  6. data/lib/devise_invitable/invitation.rb +89 -0
  7. data/lib/devise_invitable/inviter.rb +23 -1
  8. data/lib/devise_invitable/migrator.rb +113 -0
  9. data/lib/devise_invitable/models.rb +328 -81
  10. data/lib/devise_invitable/rails.rb +15 -16
  11. data/lib/devise_invitable/version.rb +1 -1
  12. data/lib/devise_invitable.rb +39 -4
  13. data/lib/generators/active_record/devise_invitable_install_generator.rb +22 -0
  14. data/lib/generators/active_record/devise_invitable_upgrade_generator.rb +28 -0
  15. data/lib/generators/active_record/templates/create_devise_invitations.rb +23 -0
  16. data/lib/generators/active_record/templates/upgrade_devise_invitations.rb +72 -0
  17. data/lib/generators/devise_invitable/devise_invitable_generator.rb +2 -4
  18. data/lib/generators/devise_invitable/install_generator.rb +8 -10
  19. data/lib/generators/devise_invitable/upgrade_generator.rb +16 -0
  20. data/lib/generators/mongoid/devise_invitable_install_generator.rb +7 -0
  21. data/lib/generators/mongoid/devise_invitable_upgrade_generator.rb +11 -0
  22. data/lib/tasks/devise_invitable_tasks.rake +10 -0
  23. data/test/functional/registrations_controller_test.rb +24 -3
  24. data/test/generators_test.rb +16 -9
  25. data/test/integration/invitation_test.rb +3 -3
  26. data/test/mailers/invitation_mail_test.rb +1 -0
  27. data/test/mailers/parent_mailer_test.rb +31 -0
  28. data/test/models/separate_invitations_test.rb +181 -0
  29. data/test/models_test.rb +34 -0
  30. data/test/rails_app/app/mailers/application_mailer.rb +2 -0
  31. data/test/rails_app/app/models/user.rb +2 -1
  32. data/test/rails_app/config/initializers/devise.rb +0 -9
  33. data/test/rails_app/db/migrate/20100401102949_create_tables.rb +22 -0
  34. metadata +26 -13
  35. data/lib/generators/active_record/devise_invitable_generator.rb +0 -19
  36. data/lib/generators/active_record/templates/migration.rb +0 -22
  37. data/lib/generators/mongoid/devise_invitable_generator.rb +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5d960efc9d5ef35fa1fdfefbde6fa6ab687d0c3d0de519a1112d1dec57224bd
4
- data.tar.gz: 9e6e81c91625d7bce472c5055d0f9f16bfc349d462eb90bbfa57bc33c766cded
3
+ metadata.gz: 52005f95e25dca6edd01fb9193500a319035974cdf3cd643133ae7c2f42e7825
4
+ data.tar.gz: 25b2fa545162bb87340c1133811bacf61861fc2eb914b002618531815f576fe7
5
5
  SHA512:
6
- metadata.gz: 9745971bbb21f82d7f3659f594ef9ad4621844df9af9a8c0f58dd6df6f436bcf256b100712233db860a124b104185478c9efcce46d37df639ccbc57d9bc1fd06
7
- data.tar.gz: 373944acfbdb815a64aa86832be27ca6c02c0e9a70b0b6a82ec738a5bb807b27f4f9908aa4bf6f9cfdd01194d8bfa84ac287454c01d390237e54bf26d66a46f9
6
+ metadata.gz: 6f512c5c9be3eb3a5b80abc6adec76b5dd5432f4ed02e3596f9ab9c9bc297589b452556c0a828f0a64db91bd7152ad8b31f43b91a82b013e7bce32e1fd76bf13
7
+ data.tar.gz: 10d177422be705e605c9287785cc0d6ec42d5b42ae40ee646d7bb8f6c08521612217fb3735ae790975bfc7da7b7f6b459eb0beeb2d3c06e9648d340f9538526a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## 3.0.0
2
+
3
+ - Drop support for Ruby < 2.7, Rails < 7.0, and Devise < 5.0
4
+ - Store invitations in a shared `devise_invitations` table/collection with
5
+ polymorphic `invitable` and `invited_by` associations, allowing multiple
6
+ invitations and inviters for the same resource ([#228](https://github.com/scambra/devise_invitable/issues/228),
7
+ [#388](https://github.com/scambra/devise_invitable/issues/388))
8
+ - Defer resource creation until invitation acceptance by default. Add
9
+ `create_resource_on_invitation` to optionally create placeholder resources
10
+ immediately; additional resource attributes require this option
11
+ - Add `invitation_pending?` and prevent pending placeholders from
12
+ authenticating through both password validation and
13
+ `active_for_authentication?`
14
+ - Accept only the invitation whose token was used and supersede other pending
15
+ invitations. Direct registration supersedes matching invitations without
16
+ marking them accepted
17
+ - Make the install generator create the shared invitations store through ORM
18
+ hooks. The model generator now only adds `:invitable` and no longer creates
19
+ per-model invitation-column migrations
20
+ - Add `devise_invitable:upgrade MODEL` to generate an Active Record data
21
+ migration from the legacy per-model columns, with `--invite-keys` support
22
+ - Add `devise_invitable:migrate_invitations[MODEL]` for Mongoid legacy-data
23
+ upgrades; Mongoid indexes continue to be created with
24
+ `db:mongoid:create_indexes`
25
+
26
+ ## 2.0.14
27
+ - Fix `NameError` when `Devise.parent_mailer` is an application mailer ([#930](https://github.com/scambra/devise_invitable/pull/930))
28
+
29
+ ## 2.0.13
30
+ - Avoid loading Action Mailer and Active Job before application initialization ([#926](https://github.com/scambra/devise_invitable/pull/926))
31
+ - Test against Devise 5 while retaining support for Devise 4.6 and newer
32
+ - Fix email interpolation in the Hong Kong and Taiwanese Chinese translations ([#927](https://github.com/scambra/devise_invitable/pull/927))
33
+
34
+ ## 2.0.12
35
+ - Add Ruby 3.4 and Rails 8.0 compatibility coverage ([#920](https://github.com/scambra/devise_invitable/pull/920))
36
+ - Fix the Italian invitation instructions ([#923](https://github.com/scambra/devise_invitable/issues/923))
37
+ - Correct the Brazilian Portuguese locale identifier ([#924](https://github.com/scambra/devise_invitable/pull/924))
38
+
39
+ ## 2.0.11
40
+ - Make `invitation_due_at` return `nil` for users who were not invited ([#919](https://github.com/scambra/devise_invitable/pull/919))
41
+
1
42
  ## 2.0.10
2
43
  - Use invited_by_class_name and invited_by_foreign_key settings from the model
3
44
 
data/README.md CHANGED
@@ -12,10 +12,11 @@ invitation setting the password.
12
12
 
13
13
  ## Requirements
14
14
 
15
- The latest version of DeviseInvitable works with Devise >= 4.6.
15
+ The latest version of DeviseInvitable requires Ruby >= 2.7, Rails >= 7.0,
16
+ and Devise >= 5.0.
16
17
 
17
- If you want to use devise_invitable with earlier Devise releases (4.0 <= x <
18
- 4.6), use version 1.7.5.
18
+ Use DeviseInvitable 2.x with Devise 4.6 through 4.9. For earlier Devise 4
19
+ releases, use DeviseInvitable 1.7.5.
19
20
 
20
21
  ## Installation
21
22
 
@@ -28,7 +29,7 @@ gem install devise_invitable
28
29
  Add DeviseInvitable to your Gemfile:
29
30
 
30
31
  ```ruby
31
- gem 'devise_invitable', '~> 2.0.0'
32
+ gem 'devise_invitable', '~> 3.0'
32
33
  ```
33
34
 
34
35
  ### Automatic installation
@@ -49,8 +50,8 @@ rails generate devise_invitable MODEL
49
50
 
50
51
  Replace MODEL by the class name you want to add DeviseInvitable, like `User`,
51
52
  `Admin`, etc. This will add the `:invitable` flag to your model's Devise
52
- modules. The generator will also create a migration file (if your ORM supports
53
- them).
53
+ modules. The shared invitations table is created by the install generator, so
54
+ the model generator does not create a migration.
54
55
 
55
56
  ### Manual installation
56
57
 
@@ -67,81 +68,57 @@ class User < ActiveRecord::Base
67
68
  end
68
69
  ```
69
70
 
70
- #### ActiveRecord Migration
71
- Add `t.invitable` to your Devise model migration:
71
+ #### Active Record schema
72
72
 
73
- ```ruby
74
- create_table :users do
75
- ...
76
- ## Invitable
77
- t.string :invitation_token
78
- t.datetime :invitation_created_at
79
- t.datetime :invitation_sent_at
80
- t.datetime :invitation_accepted_at
81
- t.integer :invitation_limit
82
- t.integer :invited_by_id
83
- t.string :invited_by_type
84
- ...
85
- end
86
- add_index :users, :invitation_token, unique: true
87
- ```
73
+ Invitations are stored in the shared `devise_invitations` table generated by
74
+ `devise_invitable:install`. It contains the token and lifecycle timestamps,
75
+ the configured invite keys, and polymorphic `invitable` and `invited_by`
76
+ associations. Invitation columns are not added to each Devise model.
88
77
 
89
- or for a model that already exists, define a migration to add DeviseInvitable
90
- to your model:
78
+ Run the generated migration:
91
79
 
92
- ```ruby
93
- def change
94
- add_column :users, :invitation_token, :string
95
- add_column :users, :invitation_created_at, :datetime
96
- add_column :users, :invitation_sent_at, :datetime
97
- add_column :users, :invitation_accepted_at, :datetime
98
- add_column :users, :invitation_limit, :integer
99
- add_column :users, :invited_by_id, :integer
100
- add_column :users, :invited_by_type, :string
101
- add_index :users, :invitation_token, unique: true
102
- end
80
+ ```shell
81
+ rails db:migrate
103
82
  ```
104
83
 
105
- If you previously used devise_invitable with a `:limit` on
106
- `:invitation_token`, remove it:
84
+ #### Upgrading from resource-backed invitation columns
107
85
 
108
- ```ruby
109
- def up
110
- change_column :users, :invitation_token, :string, limit: nil
111
- end
86
+ Generate one upgrade migration for every model that already uses
87
+ `:invitable`:
112
88
 
113
- def down
114
- change_column :users, :invitation_token, :string, limit: 60
115
- end
89
+ ```shell
90
+ rails generate devise_invitable:upgrade User
91
+ rails db:migrate
116
92
  ```
117
93
 
118
- ## Mongoid Field Definitions
119
- If you are using Mongoid, define the following fields and indexes within your
120
- invitable model:
94
+ Pass every configured invite key when it is not just `email`:
121
95
 
122
- ```ruby
123
- field :invitation_token, type: String
124
- field :invitation_created_at, type: Time
125
- field :invitation_sent_at, type: Time
126
- field :invitation_accepted_at, type: Time
127
- field :invitation_limit, type: Integer
128
-
129
- index( { invitation_token: 1 }, { background: true} )
130
- index( { invitation_by_id: 1 }, { background: true} )
96
+ ```shell
97
+ rails generate devise_invitable:upgrade User --invite-keys email username
131
98
  ```
132
99
 
133
- You do not need to define a `belongs_to` relationship, as DeviseInvitable does
134
- this on your behalf:
135
- ```ruby
136
- belongs_to :invited_by, polymorphic: true
137
- ```
100
+ The generated migration creates the shared table if necessary, copies existing
101
+ invitation data, and removes the old invitation lifecycle and `invited_by`
102
+ columns. It intentionally leaves `invitation_limit` and `invitations_count`
103
+ alone because those describe inviters rather than individual invitations. Its
104
+ down direction is irreversible because multiple invitations cannot be moved
105
+ losslessly back onto one resource row.
106
+
107
+ ## Mongoid
108
+
109
+ Mongoid fields and indexes for `DeviseInvitable::Invitation` are defined by the
110
+ gem. Create the indexes after deployment:
138
111
 
139
- Remember to create indexes within the MongoDB database after deploying your
140
- changes.
141
112
  ```shell
142
113
  rake db:mongoid:create_indexes
143
114
  ```
144
115
 
116
+ To migrate legacy embedded invitation fields for a model, run:
117
+
118
+ ```shell
119
+ rails devise_invitable:migrate_invitations[User]
120
+ ```
121
+
145
122
  ## Model configuration
146
123
 
147
124
  DeviseInvitable adds some new configuration options:
@@ -181,17 +158,25 @@ invite key as hash keys, and values that respond to the `===` operator
181
158
  (including procs and regexes). The default value is looking for users by
182
159
  email and validating with `Devise.email_regexp`.
183
160
 
184
- * `validate_on_invite`: force a record to be valid before being actually
185
- invited.
161
+ * `validate_on_invite`: with placeholder creation enabled, force the resource
162
+ to be valid before it is invited. Deferred invitations validate their invite
163
+ keys because no resource is persisted yet.
186
164
 
187
165
  * `resend_invitation`: resend invitation if user with invited status is
188
166
  invited again. Enabled by default.
189
167
 
190
- * `invited_by_class_name`: the class name of the inviting model. If this is
191
- `nil`, polymorphic association is used.
168
+ * `invited_by_class_name` and `invited_by_foreign_key` are deprecated and
169
+ have no effect. This applies to both initializer configuration and options
170
+ passed to `devise :invitable` on a model. Invitations always use the
171
+ polymorphic `invited_by_id` and `invited_by_type` columns in the shared
172
+ invitations table.
192
173
 
193
- * `invited_by_foreign_key`: the foreign key to the inviting model (only used
194
- if `invited_by_class_name` is set, otherwise `:invited_by_id`)
174
+ * `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.
195
180
 
196
181
  * `invited_by_counter_cache`: the column name used for counter_cache column.
197
182
  If this is `nil` (default value), the `invited_by` association is declared
@@ -378,24 +363,45 @@ end
378
363
 
379
364
  ### Send an invitation
380
365
 
381
- To send an invitation to a user, use the `invite!` class method. **Note: This
382
- will create a user, and send an email for the invite.** `:email` must be
383
- present in the parameters hash. You can also include other attributes in the
384
- hash. The record will not be validated.
366
+ To send an invitation to a user, use the `invite!` class method. `:email` must
367
+ be present in the parameters hash. By default this stores an invitation without
368
+ creating a user. Set `create_resource_on_invitation: true` on the Devise model
369
+ to create a placeholder user immediately and allow other resource attributes.
385
370
 
386
371
  ```ruby
387
- User.invite!(email: 'new_user@example.com', name: 'John Doe')
372
+ User.invite!(email: 'new_user@example.com')
388
373
  # => an invitation email will be sent to new_user@example.com
389
374
  ```
390
375
 
376
+ To persist additional resource attributes, enable placeholders:
377
+
378
+ ```ruby
379
+ class User < ApplicationRecord
380
+ devise :database_authenticatable, :invitable,
381
+ create_resource_on_invitation: true
382
+ end
383
+
384
+ User.invite!(email: 'new_user@example.com', name: 'John Doe')
385
+ ```
386
+
387
+ Multiple invitations can be sent for the same invite keys. Accepting one marks
388
+ that invitation accepted and supersedes the others. If the invitee registers
389
+ directly, all matching pending invitations are superseded rather than marked
390
+ accepted.
391
+
392
+ Placeholder resources expose `invitation_pending?`. Pending placeholders are
393
+ rejected by both Devise's password validation and
394
+ `active_for_authentication?` checks until an invitation is accepted or the
395
+ resource is claimed by direct registration.
396
+
391
397
  If you want to create the invitation but not send it, you can set
392
398
  `skip_invitation` to `true`.
393
399
 
394
400
  ```ruby
395
- user = User.invite!(email: 'new_user@example.com', name: 'John Doe') do |u|
401
+ user = User.invite!(email: 'new_user@example.com') do |u|
396
402
  u.skip_invitation = true
397
403
  end
398
- # => the record will be created, but the invitation email will not be sent
404
+ # => the invitation will be stored, but its email will not be sent
399
405
  ```
400
406
 
401
407
  When generating the `accept_user_invitation_url` yourself, you must use the
@@ -420,12 +426,13 @@ You can add `:skip_invitation` to attributes hash if `skip_invitation` is
420
426
  added to `attr_accessible`.
421
427
 
422
428
  ```ruby
423
- User.invite!(email: 'new_user@example.com', name: 'John Doe', skip_invitation: true)
424
- # => the record will be created, but the invitation email will not be sent
429
+ User.invite!(email: 'new_user@example.com', skip_invitation: true)
430
+ # => the invitation will be stored, but its email will not be sent
425
431
  ```
426
432
 
427
- `skip_invitation` skips sending the email, but sets `invitation_token`, so
428
- `invited_to_sign_up?` on the resulting user returns `true`.
433
+ `skip_invitation` skips sending the email but still creates a pending
434
+ `DeviseInvitable::Invitation`. With placeholders enabled,
435
+ `invited_to_sign_up?` and `invitation_pending?` return `true` on the resource.
429
436
 
430
437
  To check if a particular user is created by invitation, irrespective to state
431
438
  of invitation one can use `created_by_invite?`
@@ -452,6 +459,14 @@ user = User.find(42)
452
459
  user.invite!(current_user) # current user is optional to set the invited_by attribute
453
460
  ```
454
461
 
462
+ Whenever possible, prefer the `User.invite!` class method described above. The
463
+ instance method creates another invitation and does not perform the
464
+ class method's checks for an existing registered account. Only call it from a
465
+ trusted, authorized workflow after verifying that the user is eligible to be
466
+ invited. In particular, do not expose it through an unauthenticated endpoint,
467
+ as doing so could allow an attacker to disrupt or take over an existing
468
+ account.
469
+
455
470
  ### Find by invitation token
456
471
 
457
472
  To find by invitation token use the `find_by_invitation_token` class method.
@@ -499,9 +514,9 @@ A pair of scopes to find those users that have accepted, and those that have
499
514
  not accepted, invitations are defined:
500
515
 
501
516
  ```ruby
502
- User.invitation_accepted # => returns all Users for whom the invitation_accepted_at attribute is not nil
503
- User.invitation_not_accepted # => returns all Users for whom the invitation_accepted_at attribute is nil
504
- User.created_by_invite # => returns all Users who are created by invitations, irrespective to invitation status
517
+ User.invitation_accepted # => resources with at least one accepted invitation
518
+ User.invitation_not_accepted # => resources provisioned by a pending invitation
519
+ User.created_by_invite # => resources created through the invitation workflow
505
520
  ```
506
521
 
507
522
  ## Integration in a Rails application
@@ -533,8 +548,9 @@ the `invitation_token` is not present or not valid, the invited is redirected
533
548
  to `invalid_token_path_for(resource_name)`, which by default is
534
549
  `after_sign_out_path_for(resource_name)`.
535
550
 
536
- The controller sets the `invited_by_id` attribute for the new user to the
537
- current user. This will let you easily keep track of who invited whom.
551
+ The controller sets the invitation's polymorphic `invited_by` association to
552
+ the current user. This keeps every inviter when the same address is invited
553
+ more than once.
538
554
 
539
555
  ## Controller filter
540
556
 
@@ -571,21 +587,16 @@ end
571
587
 
572
588
  ## Has many invitations
573
589
 
574
- If you want to get all records invited by a resource, you should define
575
- `has_many` association in the model allowed to send invitations.
576
-
577
- For the default behavior, define it like this:
590
+ The `DeviseInvitable::Inviter` module defines a `sent_invitations` association
591
+ containing every invitation sent by the resource:
578
592
 
579
593
  ```ruby
580
- has_many :invitations, class_name: self.to_s, as: :invited_by
594
+ current_user.sent_invitations
595
+ current_admin.sent_invitations
581
596
  ```
582
597
 
583
- For the previous example, where admins send invitations to users, define it
584
- like this:
585
-
586
- ```ruby
587
- has_many :invitations, class_name: 'User', as: :invited_by
588
- ```
598
+ Deleting an inviter preserves its sent invitations and clears their
599
+ `invited_by` association.
589
600
 
590
601
  ## I18n
591
602
 
@@ -69,7 +69,7 @@ class Devise::InvitationsController < DeviseController
69
69
 
70
70
  # GET /resource/invitation/remove?invitation_token=abcdef
71
71
  def destroy
72
- resource.destroy
72
+ resource.revoke_invitation!
73
73
  set_flash_message :notice, :invitation_removed if is_flashing_format?
74
74
  redirect_to after_sign_out_path_for(resource_name)
75
75
  end
@@ -2,16 +2,21 @@ class DeviseInvitable::RegistrationsController < Devise::RegistrationsController
2
2
  protected
3
3
 
4
4
  def build_resource(hash = {})
5
- if hash[:email]
6
- self.resource = resource_class.where(email: hash[:email]).first
7
- if self.resource && self.resource.respond_to?(:invited_to_sign_up?) && self.resource.invited_to_sign_up?
8
- self.resource.attributes = hash
9
- self.resource.send_confirmation_instructions if self.resource.confirmation_required_for_invited?
10
- self.resource.accept_invitation
11
- else
12
- self.resource = nil
13
- end
14
- end
5
+ build_invited_resource(hash) if hash[:email]
15
6
  self.resource ||= super
7
+ if self.resource.respond_to?(:registering_directly=) && resource_class.pending_invitations_for(hash).any?
8
+ self.resource.registering_directly = true
9
+ end
10
+ self.resource
11
+ end
12
+
13
+ def build_invited_resource(hash)
14
+ self.resource = resource_class.where(email: hash[:email]).first
15
+ if self.resource && self.resource.respond_to?(:invited_to_sign_up?) && self.resource.invited_to_sign_up?
16
+ self.resource.attributes = hash
17
+ self.resource.send_confirmation_instructions if self.resource.confirmation_required_for_invited?
18
+ else
19
+ self.resource = nil
20
+ end
16
21
  end
17
22
  end
@@ -0,0 +1,89 @@
1
+ require 'digest'
2
+
3
+ module DeviseInvitable
4
+ invitation_base =
5
+ if defined?(ActiveRecord::Base)
6
+ ActiveRecord::Base
7
+ else
8
+ Object
9
+ end
10
+
11
+ class Invitation < invitation_base
12
+ self.table_name = 'devise_invitations' if respond_to?(:table_name=)
13
+
14
+ if defined?(Mongoid::Document) && !defined?(ActiveRecord::Base)
15
+ include Mongoid::Document
16
+ include Mongoid::Timestamps
17
+
18
+ store_in collection: 'devise_invitations'
19
+
20
+ field :invitation_token, type: String
21
+ field :invitation_created_at, type: Time
22
+ field :invitation_sent_at, type: Time
23
+ field :invitation_accepted_at, type: Time
24
+ field :superseded_at, type: Time
25
+ field :superseded_reason, type: String
26
+ field :revoked_at, type: Time
27
+ field :resource_created_at, type: Time
28
+ field :invite_key, type: Hash, default: {}
29
+ field :invite_key_digest, type: String
30
+ field :invitable_type, type: String
31
+ field :invitable_id
32
+ field :invited_by_type, type: String
33
+ field :invited_by_id
34
+
35
+ index({ invitation_token: 1 }, unique: true, sparse: true)
36
+ index({ invitable_type: 1, invitable_id: 1 })
37
+ index({ invited_by_type: 1, invited_by_id: 1 })
38
+ index({ invitable_type: 1, invite_key_digest: 1 })
39
+ end
40
+
41
+ belongs_to :invitable, polymorphic: true, optional: true
42
+ invited_by_options = { polymorphic: true, optional: true, autosave: true }
43
+ if defined?(ActiveRecord::Base) && Devise.invited_by_counter_cache
44
+ invited_by_options[:counter_cache] = Devise.invited_by_counter_cache
45
+ end
46
+ belongs_to :invited_by, **invited_by_options
47
+
48
+ before_validation :set_invite_key_digest
49
+
50
+ scope :pending, lambda {
51
+ where(invitation_accepted_at: nil, superseded_at: nil, revoked_at: nil)
52
+ }
53
+ if defined?(Mongoid::Document) && !defined?(ActiveRecord::Base)
54
+ scope :provisioning, -> { where(:resource_created_at.ne => nil) }
55
+ scope :accepted, -> { where(:invitation_accepted_at.ne => nil) }
56
+ else
57
+ scope :provisioning, -> { where.not(resource_created_at: nil) }
58
+ scope :accepted, -> { where.not(invitation_accepted_at: nil) }
59
+ end
60
+
61
+ def pending?
62
+ invitation_accepted_at.nil? && superseded_at.nil? && revoked_at.nil?
63
+ end
64
+
65
+ def provisioning?
66
+ resource_created_at.present?
67
+ end
68
+
69
+ def invitation_period_valid?(period)
70
+ period.to_i.zero? || (invitation_created_at && invitation_created_at.utc >= period.ago)
71
+ end
72
+
73
+ def invite_key
74
+ value = super
75
+ value.respond_to?(:with_indifferent_access) ? value.with_indifferent_access : value
76
+ end
77
+
78
+ def self.digest_for(resource_type, invite_key)
79
+ components = invite_key.to_h.map { |key, value| [key.to_s, value.to_s] }.sort.flatten
80
+ Digest::SHA256.hexdigest(([resource_type] + components).join("\0"))
81
+ end
82
+
83
+ private
84
+
85
+ def set_invite_key_digest
86
+ self.invite_key_digest = self.class.digest_for(invitable_type, invite_key)
87
+ end
88
+ end
89
+ end
@@ -3,17 +3,32 @@ module DeviseInvitable
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
+ has_many :sent_invitations,
7
+ class_name: 'DeviseInvitable::Invitation',
8
+ as: :invited_by,
9
+ dependent: :nullify
10
+
6
11
  extend ClassMethods
7
12
  attr_writer :invitation_limit unless respond_to? :invitation_limit
8
13
  end
9
14
 
10
15
  def invitation_limit
11
- self[:invitation_limit] || self.class.invitation_limit
16
+ stored_limit =
17
+ if invitation_limit_attribute?
18
+ self[:invitation_limit]
19
+ else
20
+ @invitation_limit
21
+ end
22
+ stored_limit || self.class.invitation_limit
12
23
  end
13
24
 
14
25
  # Return true if this user has invitations left to send
15
26
  def has_invitations_left?
16
27
  if self.class.invitation_limit.present?
28
+ unless invitation_limit_attribute?
29
+ return sent_invitations.count < self.class.invitation_limit
30
+ end
31
+
17
32
  if invitation_limit
18
33
  return invitation_limit > 0
19
34
  else
@@ -28,11 +43,18 @@ module DeviseInvitable
28
43
 
29
44
  def decrement_invitation_limit!
30
45
  if self.class.invitation_limit.present?
46
+ return unless invitation_limit_attribute?
47
+
31
48
  self.invitation_limit ||= self.class.invitation_limit
32
49
  self.update_attribute(:invitation_limit, invitation_limit - 1)
33
50
  end
34
51
  end
35
52
 
53
+ def invitation_limit_attribute?
54
+ (respond_to?(:has_attribute?) && has_attribute?(:invitation_limit)) ||
55
+ (self.class.respond_to?(:fields) && self.class.fields.key?('invitation_limit'))
56
+ end
57
+
36
58
  module ClassMethods
37
59
  Devise::Models.config(self, :invitation_limit)
38
60
  end
@@ -0,0 +1,113 @@
1
+ module DeviseInvitable
2
+ class Migrator
3
+ BATCH_SIZE = 1_000
4
+
5
+ LEGACY_FIELDS = %w[
6
+ invitation_token invitation_created_at invitation_sent_at
7
+ invitation_accepted_at invited_by_id invited_by_type
8
+ ].freeze
9
+
10
+ def self.call(model_class)
11
+ new(model_class).call
12
+ end
13
+
14
+ def initialize(model_class)
15
+ unless defined?(Mongoid::Document) && model_class < Mongoid::Document
16
+ raise ArgumentError, 'DeviseInvitable::Migrator only supports Mongoid models'
17
+ end
18
+
19
+ @model_class = model_class
20
+ end
21
+
22
+ def call
23
+ migrated = 0
24
+ each_legacy_resource_batch do |resources|
25
+ migrated_identities = migrated_identities_for(resources)
26
+ resources_to_migrate = resources.reject do |resource|
27
+ migrated_identities.include?(migration_identity(resource))
28
+ end
29
+
30
+ insert_invitations(resources_to_migrate)
31
+ remove_legacy_fields(resources)
32
+ migrated += resources_to_migrate.size
33
+ end
34
+ migrated
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :model_class
40
+
41
+ def resources_with_legacy_invitations
42
+ model_class.where(:invitation_created_at.ne => nil)
43
+ end
44
+
45
+ def each_legacy_resource_batch(&block)
46
+ resources_with_legacy_invitations
47
+ .batch_size(BATCH_SIZE)
48
+ .each_slice(BATCH_SIZE, &block)
49
+ end
50
+
51
+ def migrated_identities_for(resources)
52
+ resource_ids = resources.map(&:id)
53
+ DeviseInvitable::Invitation.where(
54
+ invitable_type: model_class.devise_invitable_base_class.name
55
+ ).in(
56
+ invitable_id: resource_ids
57
+ ).pluck(
58
+ :invitable_id, :invitation_created_at
59
+ ).to_h { |identity| [identity, true] }
60
+ end
61
+
62
+ def migration_identity(resource)
63
+ [resource.id, legacy_value(resource, 'invitation_created_at')]
64
+ end
65
+
66
+ def insert_invitations(resources)
67
+ return if resources.empty?
68
+
69
+ now = Time.now.utc
70
+ attributes = resources.map { |resource| invitation_attributes(resource, now) }
71
+ DeviseInvitable::Invitation.collection.insert_many(attributes)
72
+ end
73
+
74
+ def invitation_attributes(resource, now)
75
+ created_at = legacy_value(resource, 'invitation_created_at')
76
+ invite_key = invite_key_for(resource)
77
+ {
78
+ invitation_token: legacy_value(resource, 'invitation_token'),
79
+ invitation_created_at: created_at,
80
+ invitation_sent_at: legacy_value(resource, 'invitation_sent_at'),
81
+ invitation_accepted_at: legacy_value(resource, 'invitation_accepted_at'),
82
+ resource_created_at: created_at,
83
+ invite_key: invite_key,
84
+ invite_key_digest: DeviseInvitable::Invitation.digest_for(
85
+ model_class.devise_invitable_base_class.name,
86
+ invite_key
87
+ ),
88
+ invitable_type: model_class.devise_invitable_base_class.name,
89
+ invitable_id: resource.id,
90
+ invited_by_type: legacy_value(resource, 'invited_by_type'),
91
+ invited_by_id: legacy_value(resource, 'invited_by_id'),
92
+ created_at: now,
93
+ updated_at: now
94
+ }
95
+ end
96
+
97
+ def invite_key_for(resource)
98
+ model_class.invite_key_fields.each_with_object({}) do |key, values|
99
+ values[key] = resource.public_send(key)
100
+ end
101
+ end
102
+
103
+ def legacy_value(resource, field)
104
+ resource.attributes[field]
105
+ end
106
+
107
+ def remove_legacy_fields(resources)
108
+ return if resources.empty?
109
+
110
+ model_class.in(id: resources.map(&:id)).unset(*LEGACY_FIELDS)
111
+ end
112
+ end
113
+ end