devise_invitable 2.0.14 → 3.0.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +166 -97
  4. data/app/controllers/devise/invitations_controller.rb +8 -2
  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 +353 -86
  10. data/lib/devise_invitable/rails.rb +10 -0
  11. data/lib/devise_invitable/version.rb +1 -1
  12. data/lib/devise_invitable.rb +40 -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 +24 -3
  26. data/test/mailers/invitation_mail_test.rb +1 -0
  27. data/test/models/invitable_test.rb +40 -2
  28. data/test/models/separate_invitations_test.rb +181 -0
  29. data/test/models_test.rb +34 -0
  30. data/test/rails_app/app/controllers/boolean_inviter_invitations_controller.rb +11 -0
  31. data/test/rails_app/app/controllers/restricted_invitations_controller.rb +9 -0
  32. data/test/rails_app/app/models/user.rb +8 -2
  33. data/test/rails_app/config/initializers/devise.rb +0 -9
  34. data/test/rails_app/config/routes.rb +2 -0
  35. data/test/rails_app/db/migrate/20100401102949_create_tables.rb +22 -0
  36. data/test/routes_test.rb +6 -0
  37. metadata +26 -13
  38. data/lib/generators/active_record/devise_invitable_generator.rb +0 -19
  39. data/lib/generators/active_record/templates/migration.rb +0 -22
  40. 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: e29dc7f305f5cb11941463a2115645c6de83ac0fde470243f8c4ee9c23535c7f
4
- data.tar.gz: 1351e523f14e6dcb0243df3a6b0eca0156e0185e25fd488335c8acf4b260c7b7
3
+ metadata.gz: 96e5afbd2a5df8b9d94f1b1cbe30a2544f6c759b9c5c4ccb4f1de2a37a8f6e3a
4
+ data.tar.gz: ccef0234148611c07c9eae801b104da63066fd856ea3d1bd592180936e1a3042
5
5
  SHA512:
6
- metadata.gz: 667c2b93fe46151947c3845fd19c4b370d5c9435331cc84ef52e2d375ba06e9748aa57539333b7951dd8b6a09ae6ebdc8ad8e9bb177b5e5d7741bec1691f9b6a
7
- data.tar.gz: a7403ae4e341a1a1eaf8431addfc9336534ef1fbaacee60614880f0fa94d772bface4433929088c7025766c264c8820b5608914eabb1f8a76624b2ea6116a62c
6
+ metadata.gz: e81ee3d1513dded1dbaaf4685bea5fe71c6d4f08bc57bec248407488ce9f0122498a3dac8c9e80a0db7cf8812bc36c88b570d4688cad52360076d439e172a8ae
7
+ data.tar.gz: db4f74177f9f44b361f689a3ebb58e49e42827e07ee39f98dfb9c907a0d1e4e2704381d24e101d41014fc8a1125eb9dbdd01131e694b572580669bb7c8ae2b9d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,69 @@
1
+ ## Unreleased
2
+
3
+ ## 3.0.1
4
+
5
+ - Raise `DeviseInvitable::AmbiguousInviteKeyError` instead of selecting an
6
+ arbitrary resource when an invite key matches multiple records
7
+ ([#878](https://github.com/scambra/devise_invitable/issues/878))
8
+ - Use the signed-in resource as the inviter when a custom
9
+ `authenticate_inviter!` method returns a boolean success value
10
+ ([#825](https://github.com/scambra/devise_invitable/issues/825))
11
+ - Do not assign attributes or modify associations on an existing resource when
12
+ its invitation is rejected ([#747](https://github.com/scambra/devise_invitable/issues/747))
13
+ - Avoid uninitialized `@accepting_invitation` warnings on supported Ruby 2.7
14
+ ([#776](https://github.com/scambra/devise_invitable/issues/776))
15
+ - Document personal-data storage and retention considerations for deferred and
16
+ placeholder invitations ([#777](https://github.com/scambra/devise_invitable/issues/777))
17
+ - Add regression coverage for custom `authenticate_inviter!` methods that
18
+ redirect unauthorized inviters ([#893](https://github.com/scambra/devise_invitable/issues/893))
19
+ - Add regression coverage to ensure `devise_for` preserves a custom controller
20
+ `module` instead of replacing its registrations controller
21
+ ([#877](https://github.com/scambra/devise_invitable/issues/877))
22
+ - Add regression coverage that instance-level `invite!` runs both the before
23
+ and after invitation-created callbacks
24
+ ([#640](https://github.com/scambra/devise_invitable/issues/640))
25
+
26
+ ## 3.0.0
27
+
28
+ - Drop support for Ruby < 2.7, Rails < 7.0, and Devise < 5.0
29
+ - Store invitations in a shared `devise_invitations` table/collection with
30
+ polymorphic `invitable` and `invited_by` associations, allowing multiple
31
+ invitations and inviters for the same resource ([#228](https://github.com/scambra/devise_invitable/issues/228),
32
+ [#388](https://github.com/scambra/devise_invitable/issues/388))
33
+ - Defer resource creation until invitation acceptance by default. Add
34
+ `create_resource_on_invitation` to optionally create placeholder resources
35
+ immediately; additional resource attributes require this option
36
+ - Add `invitation_pending?` and prevent pending placeholders from
37
+ authenticating through both password validation and
38
+ `active_for_authentication?`
39
+ - Accept only the invitation whose token was used and supersede other pending
40
+ invitations. Direct registration supersedes matching invitations without
41
+ marking them accepted
42
+ - Make the install generator create the shared invitations store through ORM
43
+ hooks. The model generator now only adds `:invitable` and no longer creates
44
+ per-model invitation-column migrations
45
+ - Add `devise_invitable:upgrade MODEL` to generate an Active Record data
46
+ migration from the legacy per-model columns, with `--invite-keys` support
47
+ - Add `devise_invitable:migrate_invitations[MODEL]` for Mongoid legacy-data
48
+ upgrades; Mongoid indexes continue to be created with
49
+ `db:mongoid:create_indexes`
50
+
51
+ ## 2.0.14
52
+ - Fix `NameError` when `Devise.parent_mailer` is an application mailer ([#930](https://github.com/scambra/devise_invitable/pull/930))
53
+
54
+ ## 2.0.13
55
+ - Avoid loading Action Mailer and Active Job before application initialization ([#926](https://github.com/scambra/devise_invitable/pull/926))
56
+ - Test against Devise 5 while retaining support for Devise 4.6 and newer
57
+ - Fix email interpolation in the Hong Kong and Taiwanese Chinese translations ([#927](https://github.com/scambra/devise_invitable/pull/927))
58
+
59
+ ## 2.0.12
60
+ - Add Ruby 3.4 and Rails 8.0 compatibility coverage ([#920](https://github.com/scambra/devise_invitable/pull/920))
61
+ - Fix the Italian invitation instructions ([#923](https://github.com/scambra/devise_invitable/issues/923))
62
+ - Correct the Brazilian Portuguese locale identifier ([#924](https://github.com/scambra/devise_invitable/pull/924))
63
+
64
+ ## 2.0.11
65
+ - Make `invitation_due_at` return `nil` for users who were not invited ([#919](https://github.com/scambra/devise_invitable/pull/919))
66
+
1
67
  ## 2.0.10
2
68
  - Use invited_by_class_name and invited_by_foreign_key settings from the model
3
69
 
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
@@ -261,6 +246,34 @@ above is `'users/invitations'`, so our routes.rb would have this line:
261
246
  devise_for :users, controllers: { invitations: 'users/invitations' }
262
247
  ```
263
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
+
264
277
  be sure that you generate the views and put them into the controller that you
265
278
  generated, so for this example it would be:
266
279
 
@@ -378,24 +391,75 @@ end
378
391
 
379
392
  ### Send an invitation
380
393
 
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.
394
+ To send an invitation to a user, use the `invite!` class method. `:email` must
395
+ be present in the parameters hash. By default this stores an invitation without
396
+ creating a user. Set `create_resource_on_invitation: true` on the Devise model
397
+ to create a placeholder user immediately and allow other resource attributes.
385
398
 
386
399
  ```ruby
387
- User.invite!(email: 'new_user@example.com', name: 'John Doe')
400
+ User.invite!(email: 'new_user@example.com')
388
401
  # => an invitation email will be sent to new_user@example.com
389
402
  ```
390
403
 
404
+ To persist additional resource attributes, enable placeholders:
405
+
406
+ ```ruby
407
+ class User < ApplicationRecord
408
+ devise :database_authenticatable, :invitable,
409
+ create_resource_on_invitation: true
410
+ end
411
+
412
+ User.invite!(email: 'new_user@example.com', name: 'John Doe')
413
+ ```
414
+
415
+ Multiple invitations can be sent for the same invite keys. Accepting one marks
416
+ that invitation accepted and supersedes the others. If the invitee registers
417
+ directly, all matching pending invitations are superseded rather than marked
418
+ accepted.
419
+
420
+ Placeholder resources expose `invitation_pending?`. Pending placeholders are
421
+ rejected by both Devise's password validation and
422
+ `active_for_authentication?` checks until an invitation is accepted or the
423
+ resource is claimed by direct registration.
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
+
391
455
  If you want to create the invitation but not send it, you can set
392
456
  `skip_invitation` to `true`.
393
457
 
394
458
  ```ruby
395
- user = User.invite!(email: 'new_user@example.com', name: 'John Doe') do |u|
459
+ user = User.invite!(email: 'new_user@example.com') do |u|
396
460
  u.skip_invitation = true
397
461
  end
398
- # => the record will be created, but the invitation email will not be sent
462
+ # => the invitation will be stored, but its email will not be sent
399
463
  ```
400
464
 
401
465
  When generating the `accept_user_invitation_url` yourself, you must use the
@@ -420,12 +484,13 @@ You can add `:skip_invitation` to attributes hash if `skip_invitation` is
420
484
  added to `attr_accessible`.
421
485
 
422
486
  ```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
487
+ User.invite!(email: 'new_user@example.com', skip_invitation: true)
488
+ # => the invitation will be stored, but its email will not be sent
425
489
  ```
426
490
 
427
- `skip_invitation` skips sending the email, but sets `invitation_token`, so
428
- `invited_to_sign_up?` on the resulting user returns `true`.
491
+ `skip_invitation` skips sending the email but still creates a pending
492
+ `DeviseInvitable::Invitation`. With placeholders enabled,
493
+ `invited_to_sign_up?` and `invitation_pending?` return `true` on the resource.
429
494
 
430
495
  To check if a particular user is created by invitation, irrespective to state
431
496
  of invitation one can use `created_by_invite?`
@@ -452,6 +517,14 @@ user = User.find(42)
452
517
  user.invite!(current_user) # current user is optional to set the invited_by attribute
453
518
  ```
454
519
 
520
+ Whenever possible, prefer the `User.invite!` class method described above. The
521
+ instance method creates another invitation and does not perform the
522
+ class method's checks for an existing registered account. Only call it from a
523
+ trusted, authorized workflow after verifying that the user is eligible to be
524
+ invited. In particular, do not expose it through an unauthenticated endpoint,
525
+ as doing so could allow an attacker to disrupt or take over an existing
526
+ account.
527
+
455
528
  ### Find by invitation token
456
529
 
457
530
  To find by invitation token use the `find_by_invitation_token` class method.
@@ -499,9 +572,9 @@ A pair of scopes to find those users that have accepted, and those that have
499
572
  not accepted, invitations are defined:
500
573
 
501
574
  ```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
575
+ User.invitation_accepted # => resources with at least one accepted invitation
576
+ User.invitation_not_accepted # => resources provisioned by a pending invitation
577
+ User.created_by_invite # => resources created through the invitation workflow
505
578
  ```
506
579
 
507
580
  ## Integration in a Rails application
@@ -533,8 +606,9 @@ the `invitation_token` is not present or not valid, the invited is redirected
533
606
  to `invalid_token_path_for(resource_name)`, which by default is
534
607
  `after_sign_out_path_for(resource_name)`.
535
608
 
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.
609
+ The controller sets the invitation's polymorphic `invited_by` association to
610
+ the current user. This keeps every inviter when the same address is invited
611
+ more than once.
538
612
 
539
613
  ## Controller filter
540
614
 
@@ -571,21 +645,16 @@ end
571
645
 
572
646
  ## Has many invitations
573
647
 
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:
648
+ The `DeviseInvitable::Inviter` module defines a `sent_invitations` association
649
+ containing every invitation sent by the resource:
578
650
 
579
651
  ```ruby
580
- has_many :invitations, class_name: self.to_s, as: :invited_by
652
+ current_user.sent_invitations
653
+ current_admin.sent_invitations
581
654
  ```
582
655
 
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
- ```
656
+ Deleting an inviter preserves its sent invitations and clears their
657
+ `invited_by` association.
589
658
 
590
659
  ## I18n
591
660
 
@@ -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
@@ -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?
@@ -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