devise_invitable 3.0.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +58 -0
- data/app/controllers/devise/invitations_controller.rb +7 -1
- data/lib/devise_invitable/models.rb +25 -5
- data/lib/devise_invitable/version.rb +1 -1
- data/lib/devise_invitable.rb +1 -0
- data/test/integration/invitation_test.rb +21 -0
- data/test/models/invitable_test.rb +40 -2
- data/test/rails_app/app/controllers/boolean_inviter_invitations_controller.rb +11 -0
- data/test/rails_app/app/controllers/restricted_invitations_controller.rb +9 -0
- data/test/rails_app/app/models/user.rb +6 -1
- data/test/rails_app/config/routes.rb +2 -0
- data/test/routes_test.rb +6 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96e5afbd2a5df8b9d94f1b1cbe30a2544f6c759b9c5c4ccb4f1de2a37a8f6e3a
|
|
4
|
+
data.tar.gz: ccef0234148611c07c9eae801b104da63066fd856ea3d1bd592180936e1a3042
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e81ee3d1513dded1dbaaf4685bea5fe71c6d4f08bc57bec248407488ce9f0122498a3dac8c9e80a0db7cf8812bc36c88b570d4688cad52360076d439e172a8ae
|
|
7
|
+
data.tar.gz: db4f74177f9f44b361f689a3ebb58e49e42827e07ee39f98dfb9c907a0d1e4e2704381d24e101d41014fc8a1125eb9dbdd01131e694b572580669bb7c8ae2b9d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
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
|
+
|
|
1
26
|
## 3.0.0
|
|
2
27
|
|
|
3
28
|
- Drop support for Ruby < 2.7, Rails < 7.0, and Devise < 5.0
|
data/README.md
CHANGED
|
@@ -246,6 +246,34 @@ above is `'users/invitations'`, so our routes.rb would have this line:
|
|
|
246
246
|
devise_for :users, controllers: { invitations: 'users/invitations' }
|
|
247
247
|
```
|
|
248
248
|
|
|
249
|
+
The invitations controller prepends its authentication, invitation-limit, and
|
|
250
|
+
token-loading callbacks. These callbacks may render or redirect and halt the
|
|
251
|
+
callback chain before an ordinary callback inherited from
|
|
252
|
+
`ApplicationController` runs. If application context must be available even
|
|
253
|
+
when one of these guards halts the request, prepend that setup in the custom
|
|
254
|
+
invitations controller. For example, an around callback can set and restore
|
|
255
|
+
the locale so flash messages created by the guards use the requested locale:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
class Users::InvitationsController < Devise::InvitationsController
|
|
259
|
+
prepend_around_action :use_locale
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
def use_locale(&action)
|
|
264
|
+
I18n.with_locale(locale_from_request, &action)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def locale_from_request
|
|
268
|
+
params[:locale].presence_in(I18n.available_locales.map(&:to_s)) ||
|
|
269
|
+
I18n.default_locale
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The same ordering rule applies to tenant selection, request tracing, and other
|
|
275
|
+
setup that must run before Devise Invitable's controller guards.
|
|
276
|
+
|
|
249
277
|
be sure that you generate the views and put them into the controller that you
|
|
250
278
|
generated, so for this example it would be:
|
|
251
279
|
|
|
@@ -394,6 +422,36 @@ rejected by both Devise's password validation and
|
|
|
394
422
|
`active_for_authentication?` checks until an invitation is accepted or the
|
|
395
423
|
resource is claimed by direct registration.
|
|
396
424
|
|
|
425
|
+
### Privacy and data retention
|
|
426
|
+
|
|
427
|
+
Invitation keys commonly contain personal data such as an email address. With
|
|
428
|
+
the default `create_resource_on_invitation: false`, no resource row is created,
|
|
429
|
+
but the invite-key values are stored in plaintext in the
|
|
430
|
+
`devise_invitations.invite_key` JSON column so the resource can be created when
|
|
431
|
+
the invitation is accepted. `invite_key_digest` is used for matching; it does
|
|
432
|
+
not replace the stored values and should not be treated as anonymization.
|
|
433
|
+
|
|
434
|
+
With `create_resource_on_invitation: true`, the resource and any additional
|
|
435
|
+
attributes or associations supplied to `invite!` are persisted immediately.
|
|
436
|
+
Choose this mode only when the application needs a placeholder and its privacy
|
|
437
|
+
policy permits storing that data before acceptance.
|
|
438
|
+
|
|
439
|
+
`invite_for` limits token validity but does not delete invitation or resource
|
|
440
|
+
records. Applications are responsible for choosing a lawful basis for
|
|
441
|
+
processing, disclosing that processing where required, limiting invited data,
|
|
442
|
+
restricting access, protecting it at rest, and deleting pending, revoked,
|
|
443
|
+
superseded, and accepted invitation data according to their retention policy.
|
|
444
|
+
Consent is one possible lawful basis under GDPR, but it is not the only one.
|
|
445
|
+
See the [European Commission GDPR principles](https://commission.europa.eu/law/law-topic/data-protection/information-business-and-organisations/principles-gdpr_en)
|
|
446
|
+
and obtain appropriate legal advice for the application.
|
|
447
|
+
|
|
448
|
+
A one-way hash alone is insufficient for the built-in deferred-invitation
|
|
449
|
+
flow, because devise_invitable must recover the invite keys to create the
|
|
450
|
+
resource at acceptance. Applications that must not retain the email address
|
|
451
|
+
need a custom flow, such as asking the invitee to enter it during acceptance
|
|
452
|
+
and verifying it against a stored digest, or using suitably protected
|
|
453
|
+
reversible storage.
|
|
454
|
+
|
|
397
455
|
If you want to create the invitation but not send it, you can set
|
|
398
456
|
`skip_invitation` to `true`.
|
|
399
457
|
|
|
@@ -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?
|
|
@@ -231,7 +231,7 @@ module Devise
|
|
|
231
231
|
|
|
232
232
|
# Returns true if accept_invitation! was called
|
|
233
233
|
def accepting_invitation?
|
|
234
|
-
@accepting_invitation
|
|
234
|
+
instance_variable_defined?(:@accepting_invitation) && @accepting_invitation
|
|
235
235
|
end
|
|
236
236
|
|
|
237
237
|
# Verifies whether a user accepted an invitation (false when user is accepting it)
|
|
@@ -494,8 +494,18 @@ module Devise
|
|
|
494
494
|
attributes_hash[k] = attribute
|
|
495
495
|
end
|
|
496
496
|
|
|
497
|
+
ensure_unique_invite_key_match!(invite_key_array, attributes_hash)
|
|
497
498
|
invitable = find_or_initialize_with_errors(invite_key_array, attributes_hash)
|
|
498
499
|
invitable.assign_attributes(attributes_hash)
|
|
500
|
+
# Association writers may persist changes immediately. Reject an
|
|
501
|
+
# existing resource before assigning non-key attributes or yielding.
|
|
502
|
+
if invitable.persisted? && (invitable.invitation_taken? || !self.resend_invitation)
|
|
503
|
+
invite_key_array.each do |key|
|
|
504
|
+
invitable.add_taken_error(key)
|
|
505
|
+
end
|
|
506
|
+
return [invitable, nil]
|
|
507
|
+
end
|
|
508
|
+
|
|
499
509
|
invitable.assign_attributes(attributes)
|
|
500
510
|
invitable.skip_invitation = skip_invitation unless skip_invitation.nil?
|
|
501
511
|
invitable.invited_by_for_next_invitation = invited_by
|
|
@@ -508,10 +518,6 @@ module Devise
|
|
|
508
518
|
invite_key_array.each { |key| invitable.add_taken_error(key) }
|
|
509
519
|
elsif invitable.new_record?
|
|
510
520
|
invitable.clear_errors_on_valid_keys if !self.validate_on_invite
|
|
511
|
-
elsif invitable.invitation_taken? || !self.resend_invitation
|
|
512
|
-
invite_key_array.each do |key|
|
|
513
|
-
invitable.add_taken_error(key)
|
|
514
|
-
end
|
|
515
521
|
end
|
|
516
522
|
|
|
517
523
|
resource_attribute_names = attributes.keys.map(&:to_sym)
|
|
@@ -639,6 +645,20 @@ module Devise
|
|
|
639
645
|
|
|
640
646
|
private
|
|
641
647
|
|
|
648
|
+
def ensure_unique_invite_key_match!(required_attributes, attributes)
|
|
649
|
+
filtered_attributes = attributes.to_h.with_indifferent_access
|
|
650
|
+
.slice(*required_attributes)
|
|
651
|
+
.delete_if { |_key, value| value.blank? }
|
|
652
|
+
return unless filtered_attributes.size == required_attributes.size
|
|
653
|
+
|
|
654
|
+
filtered_attributes = devise_parameter_filter.filter(filtered_attributes)
|
|
655
|
+
matches = where(filtered_attributes).limit(2).to_a
|
|
656
|
+
return unless matches.many?
|
|
657
|
+
|
|
658
|
+
raise DeviseInvitable::AmbiguousInviteKeyError,
|
|
659
|
+
"Invite key matches multiple #{devise_invitable_base_class.name} records"
|
|
660
|
+
end
|
|
661
|
+
|
|
642
662
|
# The random password, as set after an invitation, must conform
|
|
643
663
|
# to any password format validation rules of the application.
|
|
644
664
|
# This default fixes the most common scenarios: Passwords must contain
|
data/lib/devise_invitable.rb
CHANGED
|
@@ -247,6 +247,27 @@ class InvitationTest < ActionDispatch::IntegrationTest
|
|
|
247
247
|
assert_redirected_to new_admin_session_path
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
test 'custom inviter authentication can redirect without rendering twice' do
|
|
251
|
+
sign_in_as_user
|
|
252
|
+
|
|
253
|
+
get new_restricted_invitation_path
|
|
254
|
+
|
|
255
|
+
assert_redirected_to root_path
|
|
256
|
+
assert_equal 'You are not authorized to access this page', flash[:alert]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
test 'boolean inviter authentication uses the signed in resource as inviter' do
|
|
260
|
+
inviter = User.create!(
|
|
261
|
+
email: 'boolean-inviter@example.com',
|
|
262
|
+
password: '123456',
|
|
263
|
+
password_confirmation: '123456'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
post boolean_inviter_invitation_path, params: { user: { email: 'boolean-invitee@example.com' } }
|
|
267
|
+
|
|
268
|
+
assert_equal inviter, User.find_by!(email: 'boolean-invitee@example.com').invited_by
|
|
269
|
+
end
|
|
270
|
+
|
|
250
271
|
test 'authenticated admin should be able to send an admin invitation' do
|
|
251
272
|
admin = Admin.create(email: 'admin@test.com', password: '123456', password_confirmation: '123456')
|
|
252
273
|
sign_in_as_user admin
|
|
@@ -15,6 +15,17 @@ class InvitableTest < ActiveSupport::TestCase
|
|
|
15
15
|
assert_nil new_user.invitation_token
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
test 'accepting_invitation? does not warn before invitation acceptance' do
|
|
19
|
+
original_verbose = $VERBOSE
|
|
20
|
+
$VERBOSE = true
|
|
21
|
+
|
|
22
|
+
_, warnings = capture_io { refute_predicate new_user, :accepting_invitation? }
|
|
23
|
+
|
|
24
|
+
refute_includes warnings, 'instance variable @accepting_invitation not initialized'
|
|
25
|
+
ensure
|
|
26
|
+
$VERBOSE = original_verbose
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
test 'should update the invitations count counter cache' do
|
|
19
30
|
if defined?(ActiveRecord)
|
|
20
31
|
current_user = new_user
|
|
@@ -430,6 +441,30 @@ class InvitableTest < ActiveSupport::TestCase
|
|
|
430
441
|
assert_equal [{error: :taken}], same_user.errors.details[:email]
|
|
431
442
|
end
|
|
432
443
|
|
|
444
|
+
test 'should not modify associations when an existing user cannot be invited' do
|
|
445
|
+
existing_user = User.create!(email: 'existing@email.com', password: '123456789')
|
|
446
|
+
User.invite!({ email: 'invitee@email.com' }, existing_user)
|
|
447
|
+
sent_invitation = existing_user.sent_invitations.first
|
|
448
|
+
|
|
449
|
+
invited_user = User.invite!(
|
|
450
|
+
email: existing_user.email,
|
|
451
|
+
sent_invitation_ids: []
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
assert_equal [{ error: :taken }], invited_user.errors.details[:email]
|
|
455
|
+
assert_equal existing_user, sent_invitation.reload.invited_by
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
test 'should reject an invite key that matches multiple records' do
|
|
459
|
+
2.times do
|
|
460
|
+
User.new(email: 'duplicate@email.com').save(validate: false)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
assert_raises DeviseInvitable::AmbiguousInviteKeyError do
|
|
464
|
+
User.invite!(email: 'duplicate@email.com')
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
433
468
|
test 'should return a record with errors if user with pending invitation was found by e-mail' do
|
|
434
469
|
existing_user = User.invite!(email: 'valid@email.com')
|
|
435
470
|
user = User.invite!(email: 'valid@email.com')
|
|
@@ -447,7 +482,7 @@ class InvitableTest < ActiveSupport::TestCase
|
|
|
447
482
|
end
|
|
448
483
|
end
|
|
449
484
|
|
|
450
|
-
test 'should
|
|
485
|
+
test 'should not assign or validate other attributes if user was found by e-mail' do
|
|
451
486
|
begin
|
|
452
487
|
validate_on_invite = User.validate_on_invite
|
|
453
488
|
User.validate_on_invite = true
|
|
@@ -456,7 +491,8 @@ class InvitableTest < ActiveSupport::TestCase
|
|
|
456
491
|
user = User.invite!(email: 'valid@email.com', username: 'a' * 50)
|
|
457
492
|
assert_equal user, existing_user
|
|
458
493
|
assert_equal [{error: :taken}], user.errors.details[:email]
|
|
459
|
-
|
|
494
|
+
assert_empty user.errors[:username]
|
|
495
|
+
assert_nil user.username
|
|
460
496
|
ensure
|
|
461
497
|
User.validate_on_invite = validate_on_invite
|
|
462
498
|
end
|
|
@@ -741,8 +777,10 @@ class InvitableTest < ActiveSupport::TestCase
|
|
|
741
777
|
|
|
742
778
|
test "user.invite! should trigger callbacks" do
|
|
743
779
|
user = User.new(email: 'valid@email.com')
|
|
780
|
+
assert_callbacks_not_fired :before_invitation_created, user
|
|
744
781
|
assert_callbacks_not_fired :after_invitation_created, user
|
|
745
782
|
user.invite!
|
|
783
|
+
assert_callbacks_fired :before_invitation_created, user
|
|
746
784
|
assert_callbacks_fired :after_invitation_created, user
|
|
747
785
|
end
|
|
748
786
|
|
|
@@ -37,7 +37,8 @@ class User < PARENT_MODEL_CLASS
|
|
|
37
37
|
devise :database_authenticatable, :registerable, :validatable, :confirmable, :invitable, :recoverable,
|
|
38
38
|
create_resource_on_invitation: true
|
|
39
39
|
|
|
40
|
-
attr_accessor :
|
|
40
|
+
attr_accessor :before_invitation_created_callback_works, :after_invitation_created_callback_works,
|
|
41
|
+
:after_invitation_accepted_callback_works, :bio, :token
|
|
41
42
|
validates :username, length: { maximum: 20 }
|
|
42
43
|
|
|
43
44
|
attr_accessor :testing_accepted_or_not_invited
|
|
@@ -53,6 +54,10 @@ class User < PARENT_MODEL_CLASS
|
|
|
53
54
|
object.after_invitation_created_callback_works = true
|
|
54
55
|
end
|
|
55
56
|
|
|
57
|
+
before_invitation_created do |object|
|
|
58
|
+
object.before_invitation_created_callback_works = true
|
|
59
|
+
end
|
|
60
|
+
|
|
56
61
|
after_invitation_accepted do |object|
|
|
57
62
|
object.after_invitation_accepted_callback_works = true
|
|
58
63
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
RailsApp::Application.routes.draw do
|
|
2
2
|
devise_for :users
|
|
3
3
|
devise_scope :user do
|
|
4
|
+
resource :boolean_inviter_invitation, only: [:new, :create]
|
|
4
5
|
resource :free_invitation, only: [:new, :create]
|
|
6
|
+
resource :restricted_invitation, only: [:new, :create]
|
|
5
7
|
resource :admin, only: [:new, :create]
|
|
6
8
|
end
|
|
7
9
|
devise_for :admins
|
data/test/routes_test.rb
CHANGED
|
@@ -2,6 +2,12 @@ require 'test_helper'
|
|
|
2
2
|
|
|
3
3
|
class RoutesTest < ActionController::TestCase
|
|
4
4
|
|
|
5
|
+
test 'preserve custom controller module for registrations' do
|
|
6
|
+
mapping = Devise::Mapping.new(:users, module: :users)
|
|
7
|
+
|
|
8
|
+
assert_equal 'users/registrations', mapping.controllers[:registrations]
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
test 'map new user invitation' do
|
|
6
12
|
assert_recognizes({ controller: 'devise/invitations', action: 'new' }, { path: 'users/invitation/new', method: :get })
|
|
7
13
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: devise_invitable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.
|
|
4
|
+
version: 3.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergio Cambra
|
|
@@ -137,8 +137,10 @@ files:
|
|
|
137
137
|
- test/rails_app/Rakefile
|
|
138
138
|
- test/rails_app/app/controllers/admins_controller.rb
|
|
139
139
|
- test/rails_app/app/controllers/application_controller.rb
|
|
140
|
+
- test/rails_app/app/controllers/boolean_inviter_invitations_controller.rb
|
|
140
141
|
- test/rails_app/app/controllers/free_invitations_controller.rb
|
|
141
142
|
- test/rails_app/app/controllers/home_controller.rb
|
|
143
|
+
- test/rails_app/app/controllers/restricted_invitations_controller.rb
|
|
142
144
|
- test/rails_app/app/controllers/users_controller.rb
|
|
143
145
|
- test/rails_app/app/helpers/application_helper.rb
|
|
144
146
|
- test/rails_app/app/mailers/application_mailer.rb
|
|
@@ -219,8 +221,10 @@ test_files:
|
|
|
219
221
|
- test/rails_app/Rakefile
|
|
220
222
|
- test/rails_app/app/controllers/admins_controller.rb
|
|
221
223
|
- test/rails_app/app/controllers/application_controller.rb
|
|
224
|
+
- test/rails_app/app/controllers/boolean_inviter_invitations_controller.rb
|
|
222
225
|
- test/rails_app/app/controllers/free_invitations_controller.rb
|
|
223
226
|
- test/rails_app/app/controllers/home_controller.rb
|
|
227
|
+
- test/rails_app/app/controllers/restricted_invitations_controller.rb
|
|
224
228
|
- test/rails_app/app/controllers/users_controller.rb
|
|
225
229
|
- test/rails_app/app/helpers/application_helper.rb
|
|
226
230
|
- test/rails_app/app/mailers/application_mailer.rb
|