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
data/lib/organizations/engine.rb
CHANGED
|
@@ -6,17 +6,15 @@ module Organizations
|
|
|
6
6
|
class Engine < ::Rails::Engine
|
|
7
7
|
isolate_namespace Organizations
|
|
8
8
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# Make has_organizations available on all ActiveRecord models.
|
|
10
|
+
#
|
|
11
|
+
# NOTE: We intentionally avoid explicit `require` calls for models here.
|
|
12
|
+
# In Rails apps, Organizations models are loaded through Zeitwerk from app/models,
|
|
13
|
+
# which keeps them reload-safe in development. Explicit requires would create
|
|
14
|
+
# non-reloadable class references that cause STI errors with gems like Pay
|
|
15
|
+
# after code reloads.
|
|
12
16
|
initializer "organizations.active_record" do
|
|
13
17
|
ActiveSupport.on_load(:active_record) do
|
|
14
|
-
require "organizations/models/organization"
|
|
15
|
-
require "organizations/models/membership"
|
|
16
|
-
require "organizations/models/invitation"
|
|
17
|
-
require "organizations/models/concerns/has_organizations"
|
|
18
|
-
|
|
19
|
-
# Make has_organizations available on all AR models
|
|
20
18
|
extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
|
|
21
19
|
end
|
|
22
20
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Value object representing a failed invitation acceptance attempt.
|
|
5
|
+
class InvitationAcceptanceFailure
|
|
6
|
+
REASONS = %i[
|
|
7
|
+
missing_user
|
|
8
|
+
missing_token
|
|
9
|
+
invitation_not_found
|
|
10
|
+
invitation_expired
|
|
11
|
+
email_mismatch
|
|
12
|
+
already_accepted_without_membership
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :reason, :invitation
|
|
16
|
+
|
|
17
|
+
def initialize(reason:, invitation: nil)
|
|
18
|
+
unless REASONS.include?(reason)
|
|
19
|
+
raise ArgumentError, "Invalid reason: #{reason.inspect}. Must be one of: #{REASONS.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@reason = reason
|
|
23
|
+
@invitation = invitation
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def success?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def failure?
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def failure_reason
|
|
35
|
+
reason
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
REASONS.each do |value|
|
|
39
|
+
define_method("#{value}?") do
|
|
40
|
+
reason == value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Value object returned by accept_pending_organization_invitation!
|
|
5
|
+
# Encapsulates the result of an invitation acceptance attempt.
|
|
6
|
+
#
|
|
7
|
+
# @example Checking result status
|
|
8
|
+
# result = accept_pending_organization_invitation!(user)
|
|
9
|
+
# if result&.accepted?
|
|
10
|
+
# redirect_to dashboard_path, notice: "Welcome!"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
class InvitationAcceptanceResult
|
|
14
|
+
STATUSES = %i[accepted already_member].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :status, :invitation, :membership
|
|
17
|
+
|
|
18
|
+
# @param status [Symbol] :accepted or :already_member
|
|
19
|
+
# @param invitation [Organizations::Invitation] The invitation that was accepted
|
|
20
|
+
# @param membership [Organizations::Membership] The resulting membership
|
|
21
|
+
# @param switched [Boolean] Whether organization context was switched (default: false)
|
|
22
|
+
def initialize(status:, invitation:, membership:, switched: false)
|
|
23
|
+
unless STATUSES.include?(status)
|
|
24
|
+
raise ArgumentError, "Invalid status: #{status.inspect}. Must be one of: #{STATUSES.join(', ')}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@status = status
|
|
28
|
+
@invitation = invitation
|
|
29
|
+
@membership = membership
|
|
30
|
+
@switched = switched
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] true if invitation was freshly accepted
|
|
34
|
+
def accepted?
|
|
35
|
+
status == :accepted
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def success?
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def failure?
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def failure_reason
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true if user was already a member
|
|
51
|
+
def already_member?
|
|
52
|
+
status == :already_member
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] true if organization context was switched
|
|
56
|
+
def switched?
|
|
57
|
+
!!@switched
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -204,9 +204,12 @@ module Organizations
|
|
|
204
204
|
|
|
205
205
|
# Check if user belongs to any organization
|
|
206
206
|
# Uses efficient EXISTS query
|
|
207
|
+
# Falls back to DB query when loaded association is empty to avoid stale false negatives
|
|
207
208
|
# @return [Boolean]
|
|
208
209
|
def belongs_to_any_organization?
|
|
209
|
-
memberships.loaded?
|
|
210
|
+
return true if memberships.loaded? && memberships.any?
|
|
211
|
+
|
|
212
|
+
memberships.exists?
|
|
210
213
|
end
|
|
211
214
|
|
|
212
215
|
# Check if user has pending invitations
|
|
@@ -271,31 +274,35 @@ module Organizations
|
|
|
271
274
|
# === Role Checks (specific organization) ===
|
|
272
275
|
|
|
273
276
|
# Get user's role in a specific organization
|
|
274
|
-
# Smart about reusing loaded associations
|
|
277
|
+
# Smart about reusing loaded associations, with DB fallback for stale caches
|
|
275
278
|
# @param org [Organization] The organization
|
|
276
279
|
# @return [Symbol, nil]
|
|
277
280
|
def role_in(org)
|
|
278
281
|
return nil unless org
|
|
279
282
|
|
|
283
|
+
# Try org's loaded memberships first
|
|
280
284
|
if org.respond_to?(:association) && org.association(:memberships).loaded?
|
|
281
285
|
membership = org.memberships.find { |m| m.user_id == id }
|
|
282
|
-
return membership
|
|
286
|
+
return membership.role.to_sym if membership
|
|
283
287
|
end
|
|
284
288
|
|
|
285
|
-
# Try
|
|
289
|
+
# Try user's loaded memberships
|
|
286
290
|
if memberships.loaded?
|
|
287
291
|
membership = memberships.find { |m| m.organization_id == org.id }
|
|
288
|
-
return membership
|
|
292
|
+
return membership.role.to_sym if membership
|
|
289
293
|
end
|
|
290
294
|
|
|
295
|
+
# Try loaded organizations' memberships
|
|
291
296
|
if organizations.loaded?
|
|
292
297
|
loaded_org = organizations.find { |candidate| candidate.id == org.id }
|
|
293
298
|
if loaded_org && loaded_org.respond_to?(:association) && loaded_org.association(:memberships).loaded?
|
|
294
299
|
membership = loaded_org.memberships.find { |m| m.user_id == id }
|
|
295
|
-
return membership
|
|
300
|
+
return membership.role.to_sym if membership
|
|
296
301
|
end
|
|
297
302
|
end
|
|
298
303
|
|
|
304
|
+
# DB fallback - loaded associations may be stale if membership was created
|
|
305
|
+
# via another association path (e.g., organization.memberships.create!)
|
|
299
306
|
memberships.find_by(organization_id: org.id)&.role&.to_sym
|
|
300
307
|
end
|
|
301
308
|
|
|
@@ -318,16 +325,15 @@ module Organizations
|
|
|
318
325
|
|
|
319
326
|
# Check if user is a member of specific organization
|
|
320
327
|
# Uses efficient EXISTS query
|
|
328
|
+
# Falls back to DB query when loaded association misses to avoid stale false negatives
|
|
329
|
+
# (e.g., membership created via organization.memberships.create! bypasses user cache)
|
|
321
330
|
# @param org [Organization] The organization
|
|
322
331
|
# @return [Boolean]
|
|
323
332
|
def is_member_of?(org)
|
|
324
333
|
return false unless org
|
|
334
|
+
return true if memberships.loaded? && memberships.any? { |m| m.organization_id == org.id }
|
|
325
335
|
|
|
326
|
-
|
|
327
|
-
memberships.any? { |membership| membership.organization_id == org.id }
|
|
328
|
-
else
|
|
329
|
-
memberships.exists?(organization_id: org.id)
|
|
330
|
-
end
|
|
336
|
+
memberships.exists?(organization_id: org.id)
|
|
331
337
|
end
|
|
332
338
|
|
|
333
339
|
# Check if user is viewer (or higher) of specific organization
|
|
@@ -369,11 +375,18 @@ module Organizations
|
|
|
369
375
|
|
|
370
376
|
# Creates a new organization with this user as owner
|
|
371
377
|
# Sets the new organization as the current organization
|
|
372
|
-
# @param
|
|
378
|
+
# @param name_or_attributes [String, Hash] Organization name or attributes hash
|
|
373
379
|
# @return [Organizations::Organization]
|
|
374
380
|
# @raise [OrganizationLimitReached] if user has reached their organization limit
|
|
375
|
-
|
|
376
|
-
|
|
381
|
+
#
|
|
382
|
+
# @example With just a name
|
|
383
|
+
# user.create_organization!("Acme Corp")
|
|
384
|
+
#
|
|
385
|
+
# @example With additional attributes
|
|
386
|
+
# user.create_organization!(name: "Acme Corp", support_email: "support@acme.com")
|
|
387
|
+
#
|
|
388
|
+
def create_organization!(name_or_attributes)
|
|
389
|
+
attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes : { name: name_or_attributes }
|
|
377
390
|
|
|
378
391
|
# Check max organizations limit
|
|
379
392
|
settings = self.class.organization_settings
|
|
@@ -384,7 +397,7 @@ module Organizations
|
|
|
384
397
|
|
|
385
398
|
org = nil
|
|
386
399
|
ActiveRecord::Base.transaction do
|
|
387
|
-
org = Organizations::Organization.create!(
|
|
400
|
+
org = Organizations::Organization.create!(attributes)
|
|
388
401
|
|
|
389
402
|
Organizations::Membership.create!(
|
|
390
403
|
user: self,
|
|
@@ -68,7 +68,11 @@ module Organizations
|
|
|
68
68
|
# Get the owner's membership
|
|
69
69
|
# @return [Membership, nil]
|
|
70
70
|
def owner_membership
|
|
71
|
-
|
|
71
|
+
if association(:memberships).loaded?
|
|
72
|
+
memberships.find { |membership| membership.role == "owner" }
|
|
73
|
+
else
|
|
74
|
+
memberships.find_by(role: "owner")
|
|
75
|
+
end
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
# Get all admins (users with admin role or higher)
|
|
@@ -244,7 +248,9 @@ module Organizations
|
|
|
244
248
|
# Lock organization first to prevent concurrent operations
|
|
245
249
|
lock!
|
|
246
250
|
|
|
247
|
-
|
|
251
|
+
# Always perform a fresh read in this write path, even if memberships
|
|
252
|
+
# are preloaded on this instance, to avoid stale-owner selection.
|
|
253
|
+
old_owner_membership = memberships.find_by(role: "owner")
|
|
248
254
|
new_owner_membership = memberships.find_by(user_id: new_owner.id)
|
|
249
255
|
|
|
250
256
|
unless old_owner_membership
|
|
@@ -21,6 +21,34 @@ module Organizations
|
|
|
21
21
|
# </div>
|
|
22
22
|
#
|
|
23
23
|
module ViewHelpers
|
|
24
|
+
# === Host App Route Helper Delegation ===
|
|
25
|
+
#
|
|
26
|
+
# When views are rendered from engine controllers, route helpers like `root_path`
|
|
27
|
+
# resolve to the engine's routes, not the host app's routes. This delegation
|
|
28
|
+
# forwards missing route methods to `main_app` so host app routes work transparently.
|
|
29
|
+
#
|
|
30
|
+
# Instead of requiring `main_app.root_path` everywhere, you can just use `root_path`.
|
|
31
|
+
#
|
|
32
|
+
def method_missing(method, *args, &block)
|
|
33
|
+
if _organizations_route_helper?(method) && respond_to?(:main_app) && main_app.respond_to?(method)
|
|
34
|
+
main_app.public_send(method, *args, &block)
|
|
35
|
+
else
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def respond_to_missing?(method, include_private = false)
|
|
41
|
+
(_organizations_route_helper?(method) && respond_to?(:main_app) && main_app.respond_to?(method)) || super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def _organizations_route_helper?(method)
|
|
47
|
+
method_name = method.to_s
|
|
48
|
+
method_name.end_with?("_path") || method_name.end_with?("_url")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
public
|
|
24
52
|
# === Organization Switcher ===
|
|
25
53
|
|
|
26
54
|
# Returns optimized data for building an organization switcher
|
data/lib/organizations.rb
CHANGED
|
@@ -51,23 +51,44 @@ module Organizations
|
|
|
51
51
|
autoload :CallbackContext, "organizations/callback_context"
|
|
52
52
|
autoload :ActsAsTenantIntegration, "organizations/acts_as_tenant_integration"
|
|
53
53
|
autoload :TestHelpers, "organizations/test_helpers"
|
|
54
|
+
autoload :CurrentUserResolution, "organizations/current_user_resolution"
|
|
55
|
+
autoload :InvitationAcceptanceResult, "organizations/invitation_acceptance_result"
|
|
56
|
+
autoload :InvitationAcceptanceFailure, "organizations/invitation_acceptance_failure"
|
|
54
57
|
|
|
55
58
|
# Alias for README compatibility: `include Organizations::Controller`
|
|
56
59
|
Controller = ControllerHelpers
|
|
57
60
|
|
|
58
|
-
# Models - autoload directly under Organizations namespace
|
|
59
|
-
# Model files define Organizations::Organization, etc.
|
|
60
|
-
autoload :Organization, "organizations/models/organization"
|
|
61
|
-
autoload :Membership, "organizations/models/membership"
|
|
62
|
-
autoload :Invitation, "organizations/models/invitation"
|
|
63
|
-
|
|
64
61
|
# Models module kept for backwards compatibility
|
|
65
62
|
module Models
|
|
66
63
|
module Concerns
|
|
64
|
+
# HasOrganizations is always autoloaded from lib/ because:
|
|
65
|
+
# 1. It's extended onto ActiveRecord::Base at boot time via the engine initializer
|
|
66
|
+
# 2. It doesn't define any associations that point to reloadable classes
|
|
67
67
|
autoload :HasOrganizations, "organizations/models/concerns/has_organizations"
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# In Rails apps, Organization/Membership/Invitation models are loaded from
|
|
72
|
+
# app/models via Zeitwerk (reload-safe). This is critical because these models
|
|
73
|
+
# define associations to other reloadable classes (like Pay::Customer), and we
|
|
74
|
+
# need the association reflections to point to current class objects after reload.
|
|
75
|
+
#
|
|
76
|
+
# In non-Rails environments (plain Ruby, tests without Rails), use lib-based autoloading.
|
|
77
|
+
#
|
|
78
|
+
# NOTE on the guard: Rails::Engine is defined when `railties` has been required.
|
|
79
|
+
# In typical usage, this means a Rails app context where Zeitwerk manages app/models.
|
|
80
|
+
# Edge cases (e.g., requiring railties without a full app) are rare and would need
|
|
81
|
+
# custom setup anyway. For standard Rails apps, this correctly delegates to Zeitwerk.
|
|
82
|
+
#
|
|
83
|
+
# IMPORTANT: The app/models/*.rb entrypoints delegate to lib/ via `load`. This means
|
|
84
|
+
# Zeitwerk watches the entrypoint files, not the lib/ files. Changes to lib/ model
|
|
85
|
+
# files during gem development won't auto-reload; restart the server in that case.
|
|
86
|
+
unless defined?(Rails::Engine)
|
|
87
|
+
autoload :Organization, "organizations/models/organization"
|
|
88
|
+
autoload :Membership, "organizations/models/membership"
|
|
89
|
+
autoload :Invitation, "organizations/models/invitation"
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
class << self
|
|
72
93
|
attr_writer :configuration
|
|
73
94
|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: organizations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-02-
|
|
10
|
+
date: 2026-02-21 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -86,7 +86,6 @@ files:
|
|
|
86
86
|
- Appraisals
|
|
87
87
|
- CHANGELOG.md
|
|
88
88
|
- CLAUDE.md
|
|
89
|
-
- LICENSE
|
|
90
89
|
- LICENSE.txt
|
|
91
90
|
- README.md
|
|
92
91
|
- Rakefile
|
|
@@ -94,8 +93,13 @@ files:
|
|
|
94
93
|
- app/controllers/organizations/invitations_controller.rb
|
|
95
94
|
- app/controllers/organizations/memberships_controller.rb
|
|
96
95
|
- app/controllers/organizations/organizations_controller.rb
|
|
96
|
+
- app/controllers/organizations/public_controller.rb
|
|
97
|
+
- app/controllers/organizations/public_invitations_controller.rb
|
|
97
98
|
- app/controllers/organizations/switch_controller.rb
|
|
98
99
|
- app/mailers/organizations/invitation_mailer.rb
|
|
100
|
+
- app/models/organizations/invitation.rb
|
|
101
|
+
- app/models/organizations/membership.rb
|
|
102
|
+
- app/models/organizations/organization.rb
|
|
99
103
|
- app/views/organizations/invitation_mailer/invitation_email.html.erb
|
|
100
104
|
- app/views/organizations/invitation_mailer/invitation_email.text.erb
|
|
101
105
|
- config/routes.rb
|
|
@@ -110,7 +114,10 @@ files:
|
|
|
110
114
|
- lib/organizations/callbacks.rb
|
|
111
115
|
- lib/organizations/configuration.rb
|
|
112
116
|
- lib/organizations/controller_helpers.rb
|
|
117
|
+
- lib/organizations/current_user_resolution.rb
|
|
113
118
|
- lib/organizations/engine.rb
|
|
119
|
+
- lib/organizations/invitation_acceptance_failure.rb
|
|
120
|
+
- lib/organizations/invitation_acceptance_result.rb
|
|
114
121
|
- lib/organizations/models/concerns/has_organizations.rb
|
|
115
122
|
- lib/organizations/models/invitation.rb
|
|
116
123
|
- lib/organizations/models/membership.rb
|
data/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Javi
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|