organizations 0.2.0 → 0.3.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.
@@ -19,6 +19,7 @@ module Organizations
19
19
  #
20
20
  module ControllerHelpers
21
21
  extend ActiveSupport::Concern
22
+ include Organizations::CurrentUserResolution
22
23
 
23
24
  included do
24
25
  # Make helpers available in views
@@ -26,6 +27,9 @@ module Organizations
26
27
  helper_method :current_organization
27
28
  helper_method :current_membership
28
29
  helper_method :organization_signed_in?
30
+ helper_method :pending_organization_invitation
31
+ helper_method :pending_organization_invitation?
32
+ helper_method :pending_organization_invitation_email
29
33
  end
30
34
  end
31
35
 
@@ -46,6 +50,7 @@ module Organizations
46
50
  org_id = session[session_key]
47
51
 
48
52
  # Find organization AND verify membership
53
+ # Use is_member_of? which has DB fallback for stale loaded associations
49
54
  org = org_id ? Organizations::Organization.find_by(id: org_id) : nil
50
55
 
51
56
  if org && user.is_member_of?(org)
@@ -85,6 +90,355 @@ module Organizations
85
90
  current_organization.present?
86
91
  end
87
92
 
93
+ # === Pending Invitation Helpers ===
94
+
95
+ # Returns the pending invitation token from session
96
+ # @return [String, nil]
97
+ def pending_organization_invitation_token
98
+ session[pending_invitation_session_key]
99
+ end
100
+
101
+ # Returns the pending invitation if token is valid and invitation is usable
102
+ # Clears token if invitation is missing, expired, or already accepted
103
+ # @return [Organizations::Invitation, nil]
104
+ def pending_organization_invitation
105
+ token = pending_organization_invitation_token
106
+ return nil unless token
107
+
108
+ # Check memoized value (keyed by token to handle mid-request changes)
109
+ if defined?(@_pending_organization_invitation_token) && @_pending_organization_invitation_token == token
110
+ return @_pending_organization_invitation
111
+ end
112
+
113
+ invitation = Organizations::Invitation.find_by(token: token)
114
+
115
+ unless invitation
116
+ clear_pending_organization_invitation!
117
+ return nil
118
+ end
119
+
120
+ if invitation.expired? || invitation.accepted?
121
+ clear_pending_organization_invitation!
122
+ return nil
123
+ end
124
+
125
+ @_pending_organization_invitation_token = token
126
+ @_pending_organization_invitation = invitation
127
+ end
128
+
129
+ # Check if there's a valid pending invitation
130
+ # @return [Boolean]
131
+ def pending_organization_invitation?
132
+ pending_organization_invitation.present?
133
+ end
134
+
135
+ # Returns the email from the pending invitation, if present
136
+ # @return [String, nil]
137
+ def pending_organization_invitation_email
138
+ pending_organization_invitation&.email
139
+ end
140
+
141
+ # Clear pending invitation token and memoized values
142
+ # @return [nil]
143
+ def clear_pending_organization_invitation!
144
+ session.delete(pending_invitation_session_key)
145
+ remove_instance_variable(:@_pending_organization_invitation) if defined?(@_pending_organization_invitation)
146
+ remove_instance_variable(:@_pending_organization_invitation_token) if defined?(@_pending_organization_invitation_token)
147
+ nil
148
+ end
149
+
150
+ # Accept pending invitation (if present) and return post-accept redirect path.
151
+ # Returns nil when there is no pending/acceptable invitation.
152
+ #
153
+ # @param user [User] The user accepting the invitation
154
+ # @param token [String, nil] Explicit invitation token (optional)
155
+ # @param switch [Boolean] Whether to switch organization context
156
+ # @param skip_email_validation [Boolean] Whether to skip invitation email checks
157
+ # @param notice [Boolean, String, Proc] Flash notice behavior (default: true)
158
+ # @return [String, nil] Redirect path or nil
159
+ def pending_invitation_acceptance_redirect_path_for(
160
+ user,
161
+ token: nil,
162
+ switch: true,
163
+ skip_email_validation: false,
164
+ notice: true
165
+ )
166
+ result = accept_pending_organization_invitation!(
167
+ user,
168
+ token: token,
169
+ switch: switch,
170
+ skip_email_validation: skip_email_validation
171
+ )
172
+ return nil unless result
173
+
174
+ set_pending_invitation_acceptance_notice!(result, user: user, notice: notice)
175
+ redirect_path_after_invitation_accepted(result.invitation, user: user)
176
+ end
177
+
178
+ # Accept pending invitation and either return redirect path or perform redirect.
179
+ #
180
+ # @param user [User] The user accepting the invitation
181
+ # @param redirect [Boolean] When true, performs redirect_to and returns true/false
182
+ # @param token [String, nil] Explicit invitation token (optional)
183
+ # @param switch [Boolean] Whether to switch organization context
184
+ # @param skip_email_validation [Boolean] Whether to skip invitation email checks
185
+ # @param notice [Boolean, String, Proc] Flash notice behavior (default: true)
186
+ # @return [String, Boolean, nil]
187
+ def handle_pending_invitation_acceptance_for(
188
+ user,
189
+ redirect: false,
190
+ token: nil,
191
+ switch: true,
192
+ skip_email_validation: false,
193
+ notice: true
194
+ )
195
+ path = pending_invitation_acceptance_redirect_path_for(
196
+ user,
197
+ token: token,
198
+ switch: switch,
199
+ skip_email_validation: skip_email_validation,
200
+ notice: notice
201
+ )
202
+
203
+ return false if redirect && path.nil?
204
+ return nil unless path
205
+ return path unless redirect
206
+
207
+ redirect_to path
208
+ true
209
+ end
210
+
211
+ # Accept a pending organization invitation for a user
212
+ # This is the canonical method for handling invitation acceptance after signup/signin.
213
+ #
214
+ # @param user [User] The user accepting the invitation
215
+ # @param token [String, nil] Explicit token (uses session token if not provided)
216
+ # @param switch [Boolean] Whether to switch to the organization after acceptance (default: true)
217
+ # @param skip_email_validation [Boolean] Skip email matching check (default: false)
218
+ # @param return_failure [Boolean] Return a structured failure object instead of nil
219
+ # @return [Organizations::InvitationAcceptanceResult, Organizations::InvitationAcceptanceFailure, nil]
220
+ #
221
+ # @example Basic usage in after_sign_in_path_for
222
+ # def after_sign_in_path_for(resource)
223
+ # if (result = accept_pending_organization_invitation!(resource))
224
+ # return redirect_path_after_invitation_accepted(result.invitation, user: resource)
225
+ # end
226
+ # super
227
+ # end
228
+ #
229
+ def accept_pending_organization_invitation!(
230
+ user,
231
+ token: nil,
232
+ switch: true,
233
+ skip_email_validation: false,
234
+ return_failure: false
235
+ )
236
+ return invitation_acceptance_failure(:missing_user, return_failure: return_failure) unless user
237
+
238
+ # Track whether we're using an explicit token that differs from session
239
+ # Only skip session clearing if explicit token fails and differs from session
240
+ explicit_token = token.presence
241
+ session_token = pending_organization_invitation_token
242
+ invitation_token = explicit_token || session_token
243
+ return invitation_acceptance_failure(:missing_token, return_failure: return_failure) unless invitation_token
244
+
245
+ # When explicit token differs from session, don't clear session on failure
246
+ using_different_explicit_token = explicit_token && explicit_token != session_token
247
+
248
+ invitation = Organizations::Invitation.find_by(token: invitation_token)
249
+ unless invitation
250
+ # Only clear session if we were using the session token (or same token)
251
+ clear_pending_organization_invitation! unless using_different_explicit_token
252
+ return invitation_acceptance_failure(:invitation_not_found, return_failure: return_failure)
253
+ end
254
+
255
+ if invitation.expired?
256
+ # Only clear session if we were using the session token (or same token)
257
+ clear_pending_organization_invitation! unless using_different_explicit_token
258
+ return invitation_acceptance_failure(
259
+ :invitation_expired,
260
+ return_failure: return_failure,
261
+ invitation: invitation
262
+ )
263
+ end
264
+
265
+ # Check email match (unless skipping validation)
266
+ unless skip_email_validation
267
+ if user.respond_to?(:email) && !invitation.for_email?(user.email)
268
+ # Email mismatch - keep token intact to allow switching accounts
269
+ return invitation_acceptance_failure(
270
+ :email_mismatch,
271
+ return_failure: return_failure,
272
+ invitation: invitation
273
+ )
274
+ end
275
+ end
276
+
277
+ status = :accepted
278
+ membership = nil
279
+
280
+ begin
281
+ membership = invitation.accept!(user, skip_email_validation: skip_email_validation)
282
+ rescue Organizations::InvitationExpired
283
+ # Race condition: invitation expired between our check and accept!
284
+ clear_pending_organization_invitation! unless using_different_explicit_token
285
+ return invitation_acceptance_failure(
286
+ :invitation_expired,
287
+ return_failure: return_failure,
288
+ invitation: invitation
289
+ )
290
+ rescue Organizations::InvitationAlreadyAccepted
291
+ # Check if user is actually a member
292
+ membership = Organizations::Membership.find_by(
293
+ user_id: user.id,
294
+ organization_id: invitation.organization_id
295
+ )
296
+ unless membership
297
+ # Data integrity anomaly: invitation marked accepted but membership missing
298
+ if defined?(Rails) && Rails.respond_to?(:logger)
299
+ Rails.logger.warn "[Organizations] InvitationAlreadyAccepted raised but no membership found for user=#{user.id} org=#{invitation.organization_id}"
300
+ end
301
+ clear_pending_organization_invitation! unless using_different_explicit_token
302
+ return invitation_acceptance_failure(
303
+ :already_accepted_without_membership,
304
+ return_failure: return_failure,
305
+ invitation: invitation
306
+ )
307
+ end
308
+ status = :already_member
309
+ end
310
+
311
+ # Attempt to switch to the organization
312
+ switched = true
313
+ if switch
314
+ begin
315
+ switch_to_organization!(invitation.organization, user: user)
316
+ rescue Organizations::NotAMember
317
+ switched = false
318
+ end
319
+ else
320
+ switched = false
321
+ end
322
+
323
+ # Always clear session token on successful acceptance
324
+ clear_pending_organization_invitation!
325
+
326
+ Organizations::InvitationAcceptanceResult.new(
327
+ status: status,
328
+ invitation: invitation,
329
+ membership: membership,
330
+ switched: switched
331
+ )
332
+ end
333
+
334
+ # Returns the path to redirect to when an invitation requires authentication
335
+ # Uses configured value or falls back to registration/root path
336
+ #
337
+ # @param invitation [Organizations::Invitation, nil] The invitation (optional)
338
+ # @param user [User, nil] The user (optional)
339
+ # @return [String] The redirect path
340
+ def redirect_path_when_invitation_requires_authentication(invitation = nil, user: nil)
341
+ config_value = Organizations.configuration.redirect_path_when_invitation_requires_authentication
342
+
343
+ resolve_controller_redirect_path(
344
+ config_value,
345
+ invitation,
346
+ user,
347
+ default: -> { default_auth_required_redirect_path }
348
+ )
349
+ end
350
+
351
+ # Returns the path to redirect to after invitation is accepted
352
+ # Uses configured value or falls back to root path
353
+ #
354
+ # @param invitation [Organizations::Invitation] The invitation that was accepted
355
+ # @param user [User, nil] The user who accepted (optional)
356
+ # @return [String] The redirect path
357
+ def redirect_path_after_invitation_accepted(invitation, user: nil)
358
+ config_value = Organizations.configuration.redirect_path_after_invitation_accepted
359
+
360
+ resolve_controller_redirect_path(
361
+ config_value,
362
+ invitation,
363
+ user,
364
+ default: -> { default_after_accept_redirect_path }
365
+ )
366
+ end
367
+
368
+ # Returns the path to redirect to after organization switch
369
+ # Uses configured value or falls back to root path
370
+ #
371
+ # @param organization [Organizations::Organization] The organization switched to
372
+ # @param user [User, nil] The user who switched (optional)
373
+ # @return [String] The redirect path
374
+ def redirect_path_after_organization_switched(organization, user: nil)
375
+ config_value = Organizations.configuration.redirect_path_after_organization_switched
376
+
377
+ resolve_controller_redirect_path(
378
+ config_value,
379
+ organization,
380
+ user,
381
+ default: -> { default_after_switch_redirect_path }
382
+ )
383
+ end
384
+
385
+ # Returns the redirect path used when the user has no active organization.
386
+ # Uses configured value or falls back to /organizations/new.
387
+ #
388
+ # @param user [User, nil] Optional user context for Proc redirects
389
+ # @return [String]
390
+ def redirect_path_when_no_organization(user: nil)
391
+ config_value = Organizations.configuration.redirect_path_when_no_organization
392
+ redirect_user = user || organizations_current_user
393
+
394
+ resolve_controller_redirect_path(
395
+ config_value,
396
+ redirect_user,
397
+ default: -> { "/organizations/new" }
398
+ )
399
+ end
400
+
401
+ # Alias used in many host apps for readability.
402
+ #
403
+ # @param user [User, nil] Optional user context for Proc redirects
404
+ # @return [String]
405
+ def no_organization_redirect_path(user: nil)
406
+ redirect_path_when_no_organization(user: user)
407
+ end
408
+
409
+ # Redirect helper for no-organization flows.
410
+ #
411
+ # When both alert and notice are nil, uses the default alert message.
412
+ #
413
+ # @param alert [String, nil] Flash alert message
414
+ # @param notice [String, nil] Flash notice message
415
+ # @return [false]
416
+ def redirect_to_no_organization!(alert: nil, notice: nil)
417
+ flash_options = {}
418
+ flash_options[:alert] = alert unless alert.nil?
419
+ flash_options[:notice] = notice unless notice.nil?
420
+
421
+ # Keep current behavior for existing apps when nothing is configured/passed.
422
+ flash_options[:alert] = "Please select or create an organization." if flash_options.empty?
423
+
424
+ redirect_to no_organization_redirect_path, **flash_options
425
+ false
426
+ end
427
+
428
+ # Creates an organization and switches context in one call.
429
+ #
430
+ # @param user [User] The user who will own the created organization
431
+ # @param attributes [Hash] Attributes passed to create_organization!
432
+ # @return [Organizations::Organization] The created organization
433
+ def create_organization_and_switch!(user, attributes = {})
434
+ organization = user.create_organization!(attributes)
435
+ switch_to_organization!(organization, user: user)
436
+ organization
437
+ end
438
+
439
+ # Alias for readability in host apps.
440
+ alias_method :create_organization_with_context!, :create_organization_and_switch!
441
+
88
442
  # === Switching ===
89
443
 
90
444
  # Sets the current organization in session
@@ -107,20 +461,28 @@ module Organizations
107
461
 
108
462
  # Switches to a different organization
109
463
  # @param org [Organizations::Organization]
464
+ # @param user [User, nil] Explicit user to switch for (useful in auth-transition flows)
110
465
  # @raise [Organizations::NotAMember] if user is not a member
111
- def switch_to_organization!(org)
112
- user = organizations_current_user
466
+ def switch_to_organization!(org, user: nil)
467
+ acting_user = user || organizations_current_user(refresh: true)
113
468
 
114
- unless user&.is_member_of?(org)
469
+ unless membership_exists_for?(acting_user, org)
115
470
  raise Organizations::NotAMember.new(
116
471
  "You are not a member of this organization",
117
472
  organization: org,
118
- user: user
473
+ user: acting_user
119
474
  )
120
475
  end
121
476
 
122
477
  self.current_organization = org
123
- mark_membership_as_recent!(user, org)
478
+ # current_organization= calls organizations_current_user (without refresh) and
479
+ # updates that user's _current_organization_id. But in auth-transition flows:
480
+ # 1. The memoized user may still be nil (sign-in just happened)
481
+ # 2. An explicit user: was passed that differs from the memoized user
482
+ # In either case, acting_user won't be updated by current_organization=.
483
+ # This explicit assignment ensures acting_user always gets the correct org ID.
484
+ acting_user._current_organization_id = org.id if acting_user.respond_to?(:_current_organization_id=)
485
+ mark_membership_as_recent!(acting_user, org)
124
486
  end
125
487
 
126
488
  # === Permission Guards ===
@@ -184,18 +546,21 @@ module Organizations
184
546
 
185
547
  private
186
548
 
187
- # Get the current user using the configured method
188
- # NOTE: This method safely calls the host app's current_user method
189
- def organizations_current_user
190
- return @_organizations_current_user if defined?(@_organizations_current_user)
549
+ def organizations_current_user(refresh: false)
550
+ resolve_organizations_current_user(
551
+ cache_ivar: :@_organizations_current_user,
552
+ refresh: refresh,
553
+ cache_nil: false
554
+ )
555
+ end
191
556
 
192
- method_name = Organizations.configuration.current_user_method
557
+ def invitation_acceptance_failure(reason, return_failure:, invitation: nil)
558
+ return nil unless return_failure
193
559
 
194
- # The configured method should exist on the host controller
195
- # (e.g., Devise's current_user). We call it directly.
196
- @_organizations_current_user = if respond_to?(method_name, true)
197
- send(method_name)
198
- end
560
+ Organizations::InvitationAcceptanceFailure.new(
561
+ reason: reason,
562
+ invitation: invitation
563
+ )
199
564
  end
200
565
 
201
566
  # Clear organization session and cached values
@@ -218,6 +583,16 @@ module Organizations
218
583
  user.memberships.where(organization_id: org.id).update_all(updated_at: Time.current)
219
584
  end
220
585
 
586
+ # DB-authoritative membership check to avoid stale loaded association issues
587
+ # @param user [User, nil]
588
+ # @param org [Organization, nil]
589
+ # @return [Boolean]
590
+ def membership_exists_for?(user, org)
591
+ return false unless user && org
592
+
593
+ Organizations::Membership.exists?(user_id: user.id, organization_id: org.id)
594
+ end
595
+
221
596
  # Handle unauthorized access
222
597
  def handle_unauthorized(permission: nil, required_role: nil)
223
598
  config = Organizations.configuration
@@ -282,11 +657,131 @@ module Organizations
282
657
  # Default behavior
283
658
  respond_to do |format|
284
659
  format.html do
285
- path = config.redirect_path_when_no_organization
286
- redirect_to path, alert: "Please select or create an organization."
660
+ redirect_to_no_organization!(
661
+ alert: config.no_organization_alert,
662
+ notice: config.no_organization_notice
663
+ )
287
664
  end
288
665
  format.json { render json: { error: "Organization required" }, status: :forbidden }
289
666
  end
290
667
  end
668
+
669
+ def set_pending_invitation_acceptance_notice!(result, user:, notice:)
670
+ return unless notice
671
+ return unless respond_to?(:flash) && flash
672
+
673
+ message = case notice
674
+ when true
675
+ default_pending_invitation_acceptance_notice(result)
676
+ when Proc
677
+ resolve_pending_invitation_notice_message(notice, result, user)
678
+ when String
679
+ notice
680
+ else
681
+ notice.to_s
682
+ end
683
+
684
+ flash[:notice] = message if message.present?
685
+ end
686
+
687
+ def resolve_pending_invitation_notice_message(notice_proc, result, user)
688
+ case notice_proc.arity
689
+ when 0
690
+ instance_exec(&notice_proc)
691
+ when 1
692
+ instance_exec(result, &notice_proc)
693
+ when 2
694
+ instance_exec(result.invitation, user, &notice_proc)
695
+ else
696
+ instance_exec(result, result.invitation, user, &notice_proc)
697
+ end
698
+ rescue StandardError => e
699
+ if defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
700
+ raise
701
+ end
702
+ if defined?(Rails) && Rails.respond_to?(:logger)
703
+ Rails.logger.error "[Organizations] Invitation notice proc failed: #{e.message}"
704
+ end
705
+ default_pending_invitation_acceptance_notice(result)
706
+ end
707
+
708
+ # Session key for pending invitation token
709
+ # @return [Symbol]
710
+ def pending_invitation_session_key
711
+ :organizations_pending_invitation_token
712
+ end
713
+
714
+ # Resolve a redirect path from config value (nil, String, or Proc)
715
+ # @param config_value [nil, String, Proc] The configured value
716
+ # @param args [Array] Optional proc arguments
717
+ # @param default [Proc] Lambda returning default path
718
+ # @return [String]
719
+ def resolve_controller_redirect_path(config_value, *args, default:)
720
+ return default.call if config_value.nil?
721
+ return config_value if config_value.is_a?(String)
722
+ return default.call unless config_value.is_a?(Proc)
723
+
724
+ begin
725
+ case config_value.arity
726
+ when 0
727
+ instance_exec(&config_value)
728
+ when 1
729
+ instance_exec(args[0], &config_value)
730
+ when 2
731
+ instance_exec(args[0], args[1], &config_value)
732
+ else
733
+ exec_args = config_value.arity.negative? ? args : args.first(config_value.arity)
734
+ instance_exec(*exec_args, &config_value)
735
+ end
736
+ rescue StandardError => e
737
+ # Re-raise in dev/test to surface misconfigurations; fall back in production
738
+ if defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
739
+ raise
740
+ end
741
+ if defined?(Rails) && Rails.respond_to?(:logger)
742
+ Rails.logger.error "[Organizations] Redirect path proc failed: #{e.message}"
743
+ end
744
+ default.call
745
+ end
746
+ end
747
+
748
+ # Default path when invitation requires authentication
749
+ # @return [String]
750
+ def default_auth_required_redirect_path
751
+ if main_app.respond_to?(:new_user_registration_path)
752
+ main_app.new_user_registration_path
753
+ elsif main_app.respond_to?(:root_path)
754
+ main_app.root_path
755
+ else
756
+ "/"
757
+ end
758
+ end
759
+
760
+ # Default path after invitation acceptance
761
+ # @return [String]
762
+ def default_after_accept_redirect_path
763
+ if main_app.respond_to?(:root_path)
764
+ main_app.root_path
765
+ else
766
+ "/"
767
+ end
768
+ end
769
+
770
+ # Default path after organization switch
771
+ # @return [String]
772
+ def default_after_switch_redirect_path
773
+ if main_app.respond_to?(:root_path)
774
+ main_app.root_path
775
+ else
776
+ "/"
777
+ end
778
+ end
779
+
780
+ def default_pending_invitation_acceptance_notice(result)
781
+ organization_name = result.invitation.organization.name
782
+ return "You're already a member of #{organization_name}." if result.already_member?
783
+
784
+ "Welcome to #{organization_name}!"
785
+ end
291
786
  end
292
787
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ module CurrentUserResolution
5
+ private
6
+
7
+ # Resolve current user for organizations integration with configurable caching behavior.
8
+ #
9
+ # @param cache_ivar [Symbol] Instance variable used for memoization (e.g. :@_current_user)
10
+ # @param refresh [Boolean] Clear memoized value before resolving
11
+ # @param cache_nil [Boolean] Whether nil values should be memoized
12
+ # @param prefer_super_for_current_user [Boolean]
13
+ # When true and configured method is :current_user, call super instead of send(:current_user)
14
+ # @param prefer_warden_for_current_user [Boolean]
15
+ # When true, resolve via Warden before trying super.
16
+ #
17
+ # @return [Object, nil] Resolved current user object
18
+ def resolve_organizations_current_user(
19
+ cache_ivar:,
20
+ refresh: false,
21
+ cache_nil: false,
22
+ prefer_super_for_current_user: false,
23
+ prefer_warden_for_current_user: false
24
+ )
25
+ remove_instance_variable(cache_ivar) if refresh && instance_variable_defined?(cache_ivar)
26
+
27
+ if instance_variable_defined?(cache_ivar)
28
+ cached = instance_variable_get(cache_ivar)
29
+ return cached if cache_nil || !cached.nil?
30
+ end
31
+
32
+ method_name = Organizations.configuration.current_user_method
33
+
34
+ attempted_warden = false
35
+
36
+ resolved_user = nil
37
+
38
+ if method_name && respond_to?(method_name, true)
39
+ if method_name == :current_user
40
+ if prefer_warden_for_current_user
41
+ attempted_warden = true
42
+ resolved_user = warden_user
43
+ end
44
+
45
+ if resolved_user.nil?
46
+ resolved_user = prefer_super_for_current_user ? safe_super_current_user : send(method_name)
47
+ end
48
+ else
49
+ resolved_user = send(method_name)
50
+ end
51
+ elsif prefer_warden_for_current_user
52
+ attempted_warden = true
53
+ resolved_user = warden_user
54
+ end
55
+
56
+ if resolved_user.nil? && prefer_warden_for_current_user && !attempted_warden
57
+ resolved_user = warden_user
58
+ end
59
+
60
+ if resolved_user.nil? && prefer_super_for_current_user
61
+ resolved_user = safe_super_current_user
62
+ end
63
+
64
+ instance_variable_set(cache_ivar, resolved_user) if cache_nil || !resolved_user.nil?
65
+ resolved_user
66
+ end
67
+
68
+ # Resolve current user via Warden when available.
69
+ # Uses warden.user (read-only) to avoid triggering authentication strategies.
70
+ def warden_user
71
+ return nil unless respond_to?(:warden, true)
72
+
73
+ w = warden
74
+ return nil unless w
75
+
76
+ scope = defined?(Devise) ? Devise.default_scope : :user
77
+ w.user(scope)
78
+ end
79
+
80
+ def safe_super_current_user
81
+ super_method = method(:current_user).super_method
82
+ return nil unless super_method
83
+
84
+ super_method.call
85
+ rescue NoMethodError, NameError
86
+ nil
87
+ end
88
+ end
89
+ end