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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52005f95e25dca6edd01fb9193500a319035974cdf3cd643133ae7c2f42e7825
4
- data.tar.gz: 25b2fa545162bb87340c1133811bacf61861fc2eb914b002618531815f576fe7
3
+ metadata.gz: 96e5afbd2a5df8b9d94f1b1cbe30a2544f6c759b9c5c4ccb4f1de2a37a8f6e3a
4
+ data.tar.gz: ccef0234148611c07c9eae801b104da63066fd856ea3d1bd592180936e1a3042
5
5
  SHA512:
6
- metadata.gz: 6f512c5c9be3eb3a5b80abc6adec76b5dd5432f4ed02e3596f9ab9c9bc297589b452556c0a828f0a64db91bd7152ad8b31f43b91a82b013e7bce32e1fd76bf13
7
- data.tar.gz: 10d177422be705e605c9287785cc0d6ec42d5b42ae40ee646d7bb8f6c08521612217fb3735ae790975bfc7da7b7f6b459eb0beeb2d3c06e9648d340f9538526a
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
@@ -1,3 +1,3 @@
1
1
  module DeviseInvitable
2
- VERSION = '3.0.0'.freeze
2
+ VERSION = '3.0.1'.freeze
3
3
  end
@@ -1,4 +1,5 @@
1
1
  module DeviseInvitable
2
+ class AmbiguousInviteKeyError < StandardError; end
2
3
  class ResourceAttributesRequirePlaceholderError < ArgumentError; end
3
4
 
4
5
  autoload :Inviter, 'devise_invitable/inviter'
@@ -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 return a record with errors if user was found by e-mail with validate_on_invite' do
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
- refute_empty user.errors[:username]
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
 
@@ -0,0 +1,11 @@
1
+ class BooleanInviterInvitationsController < Devise::InvitationsController
2
+ protected
3
+
4
+ def authenticate_inviter!
5
+ true
6
+ end
7
+
8
+ def current_user
9
+ User.find_by(email: 'boolean-inviter@example.com')
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ class RestrictedInvitationsController < Devise::InvitationsController
2
+ protected
3
+
4
+ def authenticate_inviter!
5
+ return if current_admin
6
+
7
+ redirect_back fallback_location: root_path, alert: 'You are not authorized to access this page'
8
+ end
9
+ end
@@ -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 :after_invitation_created_callback_works, :after_invitation_accepted_callback_works, :bio, :token
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.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