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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +148 -11
- data/Rakefile +2 -1
- data/app/controllers/organizations/application_controller.rb +23 -196
- data/app/controllers/organizations/invitations_controller.rb +8 -145
- data/app/controllers/organizations/organizations_controller.rb +14 -6
- data/app/controllers/organizations/public_controller.rb +61 -0
- data/app/controllers/organizations/public_invitations_controller.rb +196 -0
- data/app/controllers/organizations/switch_controller.rb +15 -5
- data/app/models/organizations/invitation.rb +6 -0
- data/app/models/organizations/membership.rb +6 -0
- data/app/models/organizations/organization.rb +6 -0
- data/config/routes.rb +4 -2
- data/lib/generators/organizations/install/templates/initializer.rb +76 -0
- data/lib/organizations/configuration.rb +109 -0
- data/lib/organizations/controller_helpers.rb +512 -17
- data/lib/organizations/current_user_resolution.rb +89 -0
- data/lib/organizations/engine.rb +7 -9
- data/lib/organizations/invitation_acceptance_failure.rb +44 -0
- data/lib/organizations/invitation_acceptance_result.rb +60 -0
- data/lib/organizations/models/concerns/has_organizations.rb +28 -15
- data/lib/organizations/models/organization.rb +8 -2
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +28 -0
- data/lib/organizations.rb +27 -6
- metadata +10 -3
- data/LICENSE +0 -21
|
@@ -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
|
-
|
|
466
|
+
def switch_to_organization!(org, user: nil)
|
|
467
|
+
acting_user = user || organizations_current_user(refresh: true)
|
|
113
468
|
|
|
114
|
-
unless
|
|
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:
|
|
473
|
+
user: acting_user
|
|
119
474
|
)
|
|
120
475
|
end
|
|
121
476
|
|
|
122
477
|
self.current_organization = org
|
|
123
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
557
|
+
def invitation_acceptance_failure(reason, return_failure:, invitation: nil)
|
|
558
|
+
return nil unless return_failure
|
|
193
559
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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(¬ice_proc)
|
|
691
|
+
when 1
|
|
692
|
+
instance_exec(result, ¬ice_proc)
|
|
693
|
+
when 2
|
|
694
|
+
instance_exec(result.invitation, user, ¬ice_proc)
|
|
695
|
+
else
|
|
696
|
+
instance_exec(result, result.invitation, user, ¬ice_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
|