organizations 0.1.1 → 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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +172 -35
  4. data/Rakefile +2 -1
  5. data/app/controllers/organizations/application_controller.rb +23 -196
  6. data/app/controllers/organizations/invitations_controller.rb +8 -145
  7. data/app/controllers/organizations/organizations_controller.rb +14 -6
  8. data/app/controllers/organizations/public_controller.rb +61 -0
  9. data/app/controllers/organizations/public_invitations_controller.rb +196 -0
  10. data/app/controllers/organizations/switch_controller.rb +15 -5
  11. data/app/models/organizations/invitation.rb +6 -0
  12. data/app/models/organizations/membership.rb +6 -0
  13. data/app/models/organizations/organization.rb +6 -0
  14. data/config/routes.rb +4 -2
  15. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +20 -32
  16. data/lib/generators/organizations/install/templates/initializer.rb +76 -0
  17. data/lib/organizations/configuration.rb +109 -0
  18. data/lib/organizations/controller_helpers.rb +512 -17
  19. data/lib/organizations/current_user_resolution.rb +89 -0
  20. data/lib/organizations/engine.rb +7 -9
  21. data/lib/organizations/invitation_acceptance_failure.rb +44 -0
  22. data/lib/organizations/invitation_acceptance_result.rb +60 -0
  23. data/lib/organizations/models/concerns/has_organizations.rb +29 -16
  24. data/lib/organizations/models/invitation.rb +1 -1
  25. data/lib/organizations/models/membership.rb +1 -1
  26. data/lib/organizations/models/organization.rb +11 -5
  27. data/lib/organizations/version.rb +1 -1
  28. data/lib/organizations/view_helpers.rb +30 -2
  29. data/lib/organizations.rb +27 -6
  30. metadata +10 -3
  31. data/LICENSE +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ca06945acfae769816fdc7c19ba44778791697325d1f59967fb4794c7b8a274
4
- data.tar.gz: 9601aa8b0835819532b1aed0a79a410ef4bf51f023b90088c463950dff5616c0
3
+ metadata.gz: 9182741872f9485ca52d426a687201fc785e5959a79fa3e4acd6b8af6e0fa9e5
4
+ data.tar.gz: 4836859405283c87543e3470e4d3cfeaf543096c76bf2589efed04099ea5112d
5
5
  SHA512:
6
- metadata.gz: 666c1b825cb64d0d822d654bf1db23c6672b993e7769369d43b0afb01480e13218511b1bd7da4c100b5761360049f9408c6c74a33a177a8e86b34ce6cab27efa
7
- data.tar.gz: 6416fb315eb949d06672665f5909bcf463976d265a05bbcdbc508978f5f20c6ccf9367c64c8a94ccbe1e8da57eeb934b3d12354dd9adb40ed51f00a9421f2883
6
+ metadata.gz: eedcce955043e1aa3bd6c0a10ed0ecc8d61d89f004176c90df5c048badbc5e8f8db6d473eeb863bdb5e0716a0b66cd543cacd7f9f8ce588e9a2702992b7b56ff
7
+ data.tar.gz: ff8cc67de84d9e9964d1888ea8404d3d10f80c271240551c1d4b8a77d3c0e3da199abdcc5c779e793d400197fc3b2c987b19a2410146b9f07c106942846c454d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.3.0] - 2026-02-20
2
+
3
+ - Invitation onboarding is now first-class and configurable, so host apps can remove most custom signup/invite glue code.
4
+ - Public invitation flows now work with Devise out of the box under the default public controller setup.
5
+ - Redirect behavior is now configurable for auth-required invitation acceptance, post-acceptance, post-switch, and no-organization flows.
6
+ - Invitation acceptance now returns structured success/failure objects, making controller handling clearer and safer.
7
+ - Switching and current-user resolution were hardened for auth-transition and stale-cache edge cases.
8
+ - Engine views now delegate host-app route helpers more cleanly, reducing `main_app.` boilerplate in host layouts/partials.
9
+ - `create_organization!` now forwards full attribute hashes, enabling custom organization validations/fields without workarounds.
10
+ - Performance improved: owner lookup avoids unnecessary SQL when memberships are preloaded (lower N+1 risk on list/admin pages).
11
+ - Test coverage expanded significantly around invitation flow, switching behavior, current-user resolution, and configuration contracts.
12
+
13
+ ## [0.2.0] - 2026-02-20
14
+
15
+ - Namespaced all tables with `organizations_` prefix to prevent collisions with host apps
16
+
1
17
  ## [0.1.1] - 2026-02-19
2
18
 
3
19
  - Removed `slugifiable` dependency (deferred to host app)
data/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  `organizations` adds organizations with members to any Rails app. It handles team invites, user memberships, roles, and permissions.
9
9
 
10
+ **🎮 [Try the live demo →](https://organizations.rameerez.com)**
11
+
10
12
  [TODO: invitation / member management gif]
11
13
 
12
14
  It's everything you need to turn a `User`-based app into a multi-tenant, `Organization`-based B2B SaaS (users belong in organizations, and organizations share resources and billing, etc.)
@@ -369,6 +371,36 @@ current_organization # Active organization (from session)
369
371
  current_membership # Current user's membership in active org
370
372
  organization_signed_in? # Is there an active organization?
371
373
 
374
+ # Pending invitation helpers
375
+ pending_organization_invitation_token # Get pending invitation token from session
376
+ pending_organization_invitation # Get pending invitation (clears if expired)
377
+ pending_organization_invitation? # Check if valid pending invitation exists
378
+ pending_organization_invitation_email # Get invited email (for signup prefill)
379
+ clear_pending_organization_invitation! # Clear invitation token and cache
380
+
381
+ # Invitation acceptance (canonical helper for post-signup flows)
382
+ accept_pending_organization_invitation!(user) # Accept with session token
383
+ accept_pending_organization_invitation!(user, token: token) # Explicit token
384
+ accept_pending_organization_invitation!(user, switch: false) # Don't auto-switch org
385
+ accept_pending_organization_invitation!(user, return_failure: true) # Returns failure object on rejection
386
+ pending_invitation_acceptance_redirect_path_for(user) # Accept + resolve redirect path
387
+ handle_pending_invitation_acceptance_for(user, redirect: true) # Accept + optionally redirect
388
+ # Returns InvitationAcceptanceResult or nil
389
+
390
+ # Invitation redirect helpers
391
+ redirect_path_when_invitation_requires_authentication(invitation) # Get auth redirect
392
+ redirect_path_after_invitation_accepted(invitation, user: user) # Get post-accept redirect
393
+ redirect_path_after_organization_switched(org, user: user) # Get post-switch redirect
394
+
395
+ # No-organization helpers
396
+ redirect_path_when_no_organization(user: nil) # Resolve configured no-org redirect path
397
+ no_organization_redirect_path # Alias
398
+ redirect_to_no_organization!(alert: "...", notice: "...") # Redirect and return false
399
+
400
+ # Organization creation helper
401
+ create_organization_and_switch!(current_user, name: "Acme") # Create and set context in one call
402
+ create_organization_with_context!(current_user, name: "Acme") # Backward-compatible alias
403
+
372
404
  # Authorization
373
405
  require_organization! # Redirect if no active org
374
406
  require_organization_role!(:admin) # Require at least admin role
@@ -379,7 +411,8 @@ require_organization_owner! # Same as require_organization_role!(:owner)
379
411
  require_organization_admin! # Same as require_organization_role!(:admin)
380
412
 
381
413
  # Switching
382
- switch_to_organization!(org) # Change active org in session
414
+ switch_to_organization!(org) # Change active org in session
415
+ switch_to_organization!(org, user: user) # Explicit user (for auth-transition flows)
383
416
  ```
384
417
 
385
418
  ### Protecting resources
@@ -629,21 +662,80 @@ The gem handles **both existing users and new signups** with a single invitation
629
662
  2. User clicks link → Sees invitation details + "Sign up to accept" button
630
663
  3. User registers → Token stored in session, your app calls `invitation.accept!(user)` post-signup
631
664
 
632
- The gem stores the invitation token in `session[:pending_invitation_token]` when an unauthenticated user tries to accept. In your registration callback, check for this token and accept the invitation:
665
+ The gem stores the invitation token in `session[:organizations_pending_invitation_token]` when an unauthenticated user tries to accept. Use the built-in helper to accept the invitation in your auth callbacks:
666
+
667
+ ```ruby
668
+ # In your ApplicationController (works with Devise or any auth system)
669
+ def after_sign_in_path_for(resource)
670
+ if (path = pending_invitation_acceptance_redirect_path_for(resource))
671
+ return path
672
+ end
673
+ super
674
+ end
675
+
676
+ def after_sign_up_path_for(resource)
677
+ if (path = pending_invitation_acceptance_redirect_path_for(resource))
678
+ return path
679
+ end
680
+ super
681
+ end
682
+ ```
683
+
684
+ The `accept_pending_organization_invitation!` helper handles:
685
+ - Token lookup from session
686
+ - Invitation validation (expired, already accepted, email match)
687
+ - Membership creation
688
+ - Organization context switching
689
+ - Session cleanup
690
+
691
+ It returns an `InvitationAcceptanceResult` object or `nil`:
633
692
 
634
693
  ```ruby
635
- # In your User model or registration controller
636
- after_create :accept_pending_invitation
694
+ result = accept_pending_organization_invitation!(user)
695
+ result.accepted? # => true if freshly accepted
696
+ result.already_member? # => true if user was already a member
697
+ result.switched? # => true if org context was switched
698
+ result.invitation # => the invitation record
699
+ result.membership # => the membership record
637
700
 
638
- def accept_pending_invitation
639
- token = session.delete(:pending_invitation_token)
640
- return unless token
701
+ # Default flash notice (when using pending_invitation_acceptance_redirect_path_for)
702
+ # accepted? -> "Welcome to <organization>!"
703
+ # already_member? -> "You're already a member of <organization>."
704
+ ```
705
+
706
+ If you want structured failure reasons instead of `nil`, pass `return_failure: true`:
707
+
708
+ ```ruby
709
+ result = accept_pending_organization_invitation!(user, return_failure: true)
710
+
711
+ if result.success?
712
+ # InvitationAcceptanceResult
713
+ else
714
+ # InvitationAcceptanceFailure
715
+ result.failure_reason # => :missing_token, :email_mismatch, :invitation_expired, etc.
716
+ end
717
+ ```
718
+
719
+ Configure redirects in your initializer:
641
720
 
642
- invitation = Organizations::Invitation.find_by(token: token)
643
- invitation&.accept!(self, skip_email_validation: true)
721
+ ```ruby
722
+ Organizations.configure do |config|
723
+ config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
724
+ config.redirect_path_after_invitation_accepted = "/dashboard"
725
+ config.redirect_path_after_organization_switched = "/dashboard"
726
+
727
+ # Or use procs for dynamic paths:
728
+ config.redirect_path_after_invitation_accepted = ->(inv, user) {
729
+ "/org/#{inv.organization_id}/welcome"
730
+ }
731
+ config.redirect_path_after_organization_switched = ->(org, user) {
732
+ "/orgs/#{org.id}?user=#{user.id}"
733
+ }
644
734
  end
645
735
  ```
646
736
 
737
+ > **Note:** When accepting invitations in custom auth flows (Devise overrides, `bypass_sign_in`, etc.), the gem handles stale memoization issues automatically by passing the explicit user to `switch_to_organization!`.
738
+
647
739
  ### Invitation emails
648
740
 
649
741
  The gem ships with a clean ActionMailer-based invitation email.
@@ -712,8 +804,8 @@ When you mount the engine, you get:
712
804
 
713
805
  ```
714
806
  POST /organizations/switch/:id → Organizations::SwitchController#create
715
- GET /invitations/:token → Organizations::InvitationsController#show
716
- POST /invitations/:token/accept → Organizations::InvitationsController#accept
807
+ GET /invitations/:token → Organizations::PublicInvitationsController#show
808
+ POST /invitations/:token/accept → Organizations::PublicInvitationsController#accept
717
809
  ```
718
810
 
719
811
  ## Auto-created organizations
@@ -837,6 +929,51 @@ Organizations.configure do |config|
837
929
  # Where to redirect when user has no organization
838
930
  config.redirect_path_when_no_organization = "/organizations/new"
839
931
 
932
+ # Where to redirect after organization is created (nil = default show page)
933
+ # Can be a String or Proc: ->(org) { "/orgs/#{org.id}/setup" }
934
+ config.after_organization_created_redirect_path = "/dashboard"
935
+
936
+ # === Invitation Flow Redirects ===
937
+ # Where to redirect unauthenticated users when they try to accept an invitation
938
+ # Default: nil (uses new_user_registration_path or root_path)
939
+ config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
940
+ # Or use a Proc: ->(invitation, user) { "/signup?invite=#{invitation.token}" }
941
+
942
+ # Where to redirect after an invitation is accepted
943
+ # Default: nil (uses root_path)
944
+ config.redirect_path_after_invitation_accepted = "/dashboard"
945
+ # Or use a Proc: ->(invitation, user) { "/org/#{invitation.organization_id}/welcome" }
946
+
947
+ # Where to redirect after organization switch
948
+ # Default: nil (uses root_path)
949
+ config.redirect_path_after_organization_switched = "/dashboard"
950
+ # Or use a Proc: ->(organization, user) { "/orgs/#{organization.id}" }
951
+
952
+ # Optional flash messages for built-in no-organization redirects.
953
+ # Leave nil to keep default alert behavior.
954
+ config.no_organization_alert = "Please create an organization first."
955
+ config.no_organization_notice = "Please create or join an organization to continue."
956
+
957
+ # === Organizations Controller ===
958
+ # Additional params to permit when creating/updating organizations
959
+ # Use this to add custom fields like support_email, billing_email, logo
960
+ config.additional_organization_params = [:support_email]
961
+
962
+ # === Engine Controllers ===
963
+ # Base controller for authenticated routes (default: ::ApplicationController)
964
+ config.parent_controller = "::ApplicationController"
965
+
966
+ # Base controller for public routes like invitation acceptance.
967
+ # Works with Devise out of the box - no configuration needed.
968
+ # Only override if using custom auth or needing specific inheritance.
969
+ # Default: ActionController::Base
970
+ # config.public_controller = "ActionController::Base"
971
+
972
+ # Layout overrides for engine controllers (optional)
973
+ # Resolved at request-time, so runtime config changes are respected.
974
+ config.authenticated_controller_layout = "dashboard"
975
+ config.public_controller_layout = "devise"
976
+
840
977
  # === Handlers ===
841
978
  # Called when authorization fails
842
979
  config.on_unauthorized do |context|
@@ -1064,9 +1201,9 @@ The gem provides `Organizations::Organization` as the base model. You can extend
1064
1201
  # db/migrate/xxx_add_custom_fields_to_organizations.rb
1065
1202
  class AddCustomFieldsToOrganizations < ActiveRecord::Migration[8.0]
1066
1203
  def change
1067
- add_column :organizations, :support_email, :string
1068
- add_column :organizations, :billing_address, :text
1069
- add_column :organizations, :settings, :jsonb, default: {}
1204
+ add_column :organizations_organizations, :support_email, :string
1205
+ add_column :organizations_organizations, :billing_address, :text
1206
+ add_column :organizations_organizations, :settings, :jsonb, default: {}
1070
1207
  end
1071
1208
  end
1072
1209
  ```
@@ -1110,10 +1247,10 @@ This is standard Rails practice — the gem provides the foundation (memberships
1110
1247
 
1111
1248
  The gem creates three tables:
1112
1249
 
1113
- ### organizations
1250
+ ### organizations_organizations
1114
1251
 
1115
1252
  ```sql
1116
- organizations
1253
+ organizations_organizations
1117
1254
  - id (primary key, auto-detects UUID or integer from your app)
1118
1255
  - name (string, required)
1119
1256
  - metadata (jsonb, default: {})
@@ -1123,10 +1260,10 @@ organizations
1123
1260
 
1124
1261
  > **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
1125
1262
 
1126
- ### memberships
1263
+ ### organizations_memberships
1127
1264
 
1128
1265
  ```sql
1129
- memberships
1266
+ organizations_memberships
1130
1267
  - id (primary key)
1131
1268
  - user_id (foreign key, indexed)
1132
1269
  - organization_id (foreign key, indexed)
@@ -1138,10 +1275,10 @@ memberships
1138
1275
  unique index: [user_id, organization_id]
1139
1276
  ```
1140
1277
 
1141
- ### organization_invitations
1278
+ ### organizations_invitations
1142
1279
 
1143
1280
  ```sql
1144
- organization_invitations
1281
+ organizations_invitations
1145
1282
  - id (primary key)
1146
1283
  - organization_id (foreign key, indexed)
1147
1284
  - email (string, required, indexed)
@@ -1254,7 +1391,7 @@ If you display member counts frequently (pricing pages, org listings), consider
1254
1391
 
1255
1392
  ```ruby
1256
1393
  # In a migration
1257
- add_column :organizations, :memberships_count, :integer, default: 0, null: false
1394
+ add_column :organizations_organizations, :memberships_count, :integer, default: 0, null: false
1258
1395
 
1259
1396
  # Reset existing counts
1260
1397
  Organization.find_each do |org|
@@ -1275,9 +1412,9 @@ org.member_count
1275
1412
  Boolean checks use efficient SQL `EXISTS` queries:
1276
1413
 
1277
1414
  ```ruby
1278
- user.belongs_to_any_organization? # SELECT 1 FROM memberships WHERE ... LIMIT 1
1279
- user.has_pending_organization_invitations? # SELECT 1 FROM organization_invitations WHERE ... LIMIT 1
1280
- org.has_any_members? # SELECT 1 FROM memberships WHERE ... LIMIT 1
1415
+ user.belongs_to_any_organization? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
1416
+ user.has_pending_organization_invitations? # SELECT 1 FROM organizations_invitations WHERE ... LIMIT 1
1417
+ org.has_any_members? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
1281
1418
  ```
1282
1419
 
1283
1420
  ### Scoped associations use JOINs
@@ -1287,13 +1424,13 @@ Methods like `org.admins` and `user.owned_organizations` use proper SQL JOINs:
1287
1424
  ```ruby
1288
1425
  org.admins
1289
1426
  # SELECT users.* FROM users
1290
- # INNER JOIN memberships ON memberships.user_id = users.id
1291
- # WHERE memberships.organization_id = ? AND memberships.role IN ('admin', 'owner')
1427
+ # INNER JOIN organizations_memberships ON organizations_memberships.user_id = users.id
1428
+ # WHERE organizations_memberships.organization_id = ? AND organizations_memberships.role IN ('admin', 'owner')
1292
1429
 
1293
1430
  user.owned_organizations
1294
- # SELECT organizations.* FROM organizations
1295
- # INNER JOIN memberships ON memberships.organization_id = organizations.id
1296
- # WHERE memberships.user_id = ? AND memberships.role = 'owner'
1431
+ # SELECT organizations_organizations.* FROM organizations_organizations
1432
+ # INNER JOIN organizations_memberships ON organizations_memberships.organization_id = organizations_organizations.id
1433
+ # WHERE organizations_memberships.user_id = ? AND organizations_memberships.role = 'owner'
1297
1434
  ```
1298
1435
 
1299
1436
  ### Current organization memoization
@@ -1445,14 +1582,14 @@ The gem creates these indexes automatically:
1445
1582
 
1446
1583
  ```sql
1447
1584
  -- Fast membership lookups
1448
- CREATE UNIQUE INDEX index_memberships_on_user_and_org ON memberships (user_id, organization_id);
1449
- CREATE INDEX index_memberships_on_organization_id ON memberships (organization_id);
1450
- CREATE INDEX index_memberships_on_role ON memberships (role);
1585
+ CREATE UNIQUE INDEX index_organizations_memberships_on_user_and_org ON organizations_memberships (user_id, organization_id);
1586
+ CREATE INDEX index_organizations_memberships_on_organization_id ON organizations_memberships (organization_id);
1587
+ CREATE INDEX index_organizations_memberships_on_role ON organizations_memberships (role);
1451
1588
 
1452
1589
  -- Fast invitation lookups
1453
- CREATE UNIQUE INDEX index_invitations_on_token ON organization_invitations (token);
1454
- CREATE INDEX index_invitations_on_email ON organization_invitations (email);
1455
- CREATE UNIQUE INDEX index_invitations_pending ON organization_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
1590
+ CREATE UNIQUE INDEX index_organizations_invitations_on_token ON organizations_invitations (token);
1591
+ CREATE INDEX index_organizations_invitations_on_email ON organizations_invitations (email);
1592
+ CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
1456
1593
  ```
1457
1594
 
1458
1595
  ## Migration from 1:1 relationships
data/Rakefile CHANGED
@@ -7,7 +7,8 @@ require "rubocop/rake_task"
7
7
  Rake::TestTask.new(:test) do |t|
8
8
  t.libs << "test"
9
9
  t.libs << "lib"
10
- t.test_files = FileList["test/**/*_test.rb"]
10
+ # Exclude dummy app tests - they require Rails and are run separately
11
+ t.test_files = FileList["test/**/*_test.rb"].exclude("test/dummy/**/*")
11
12
  end
12
13
 
13
14
  RuboCop::RakeTask.new
@@ -7,21 +7,28 @@ module Organizations
7
7
  #
8
8
  # All engine controllers inherit from this class and get:
9
9
  # - Authentication via configured method
10
- # - Organization context helpers
11
- # - Permission guards
10
+ # - Organization context helpers (via ControllerHelpers)
11
+ # - Permission guards (via ControllerHelpers)
12
12
  #
13
13
  class ApplicationController < (Organizations.configuration.parent_controller.constantize rescue ::ApplicationController)
14
+ # Include ControllerHelpers for organization context and permission guards
15
+ # This provides: current_organization, current_membership, organization_signed_in?,
16
+ # switch_to_organization!, require_organization!, require_organization_admin!, etc.
17
+ include Organizations::ControllerHelpers
18
+
14
19
  # Protect from forgery if the parent controller does
15
20
  protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
16
21
 
22
+ if respond_to?(:layout)
23
+ # Resolve layout at request-time so runtime config changes are respected.
24
+ layout :organizations_authenticated_layout
25
+ end
26
+
17
27
  # Ensure user is authenticated for all actions
18
28
  before_action :authenticate_organizations_user!
19
29
 
20
- # Expose helpers to views
30
+ # Expose current_user to views (ControllerHelpers exposes org-related helpers)
21
31
  helper_method :current_user
22
- helper_method :current_organization
23
- helper_method :current_membership
24
- helper_method :organization_signed_in?
25
32
 
26
33
  private
27
34
 
@@ -50,202 +57,22 @@ module Organizations
50
57
  # Uses the configured method name (defaults to :current_user)
51
58
  # NOTE: We call the PARENT class method to avoid infinite recursion
52
59
  def current_user
53
- return @_current_user if defined?(@_current_user)
54
-
55
- user_method = Organizations.configuration.current_user_method
56
-
57
- # Avoid infinite recursion: if configured method is :current_user,
58
- # call the parent implementation, not this method
59
- @_current_user = if user_method == :current_user
60
- super rescue nil
61
- elsif user_method && respond_to?(user_method, true)
62
- send(user_method)
63
- end
60
+ resolve_organizations_current_user(
61
+ cache_ivar: :@_current_user,
62
+ cache_nil: true,
63
+ prefer_super_for_current_user: true
64
+ )
64
65
  end
65
66
 
66
67
  # Alias for compatibility
67
68
  alias_method :current_organizations_user, :current_user
68
69
 
69
- # === Organization Context ===
70
-
71
- # Returns the current organization from the session
72
- # Validates membership - if user was removed, auto-switches to next available org
73
- # Falls back to most recently joined org if no session set
74
- def current_organization
75
- return @_current_organization if defined?(@_current_organization)
76
- return @_current_organization = nil unless current_user
77
-
78
- session_key = Organizations.configuration.session_key
79
- org_id = session[session_key]
80
-
81
- org = org_id ? Organization.find_by(id: org_id) : nil
82
-
83
- if org && current_user.is_member_of?(org)
84
- # Valid membership - use this org
85
- current_user._current_organization_id = org.id
86
- @_current_organization = org
87
- else
88
- # User was removed from this org OR no session set
89
- # Auto-switch to next available org (most recently joined)
90
- clear_organization_session!
91
-
92
- fallback_org = fallback_organization_for(current_user)
93
- if fallback_org
94
- session[session_key] = fallback_org.id
95
- current_user._current_organization_id = fallback_org.id
96
- @_current_organization = fallback_org
97
- else
98
- @_current_organization = nil
99
- end
100
- end
101
- end
102
-
103
- # Returns the current user's membership in the current organization
104
- def current_membership
105
- return @_current_membership if defined?(@_current_membership)
106
- return @_current_membership = nil unless current_user && current_organization
107
-
108
- @_current_membership = current_user.memberships.find_by(organization_id: current_organization.id)
109
- end
110
-
111
- # Check if there's an active organization
112
- def organization_signed_in?
113
- current_organization.present?
114
- end
115
-
116
- # Sets the current organization in session
117
- def current_organization=(org)
118
- session_key = Organizations.configuration.session_key
119
-
120
- if org
121
- session[session_key] = org.id
122
- @_current_organization = org
123
- @_current_membership = nil
124
- current_user&._current_organization_id = org.id
125
- else
126
- clear_organization_session!
127
- end
128
- end
129
-
130
- # Switches to a different organization
131
- def switch_to_organization!(org)
132
- unless current_user&.is_member_of?(org)
133
- raise Organizations::NotAMember.new(
134
- "You are not a member of this organization",
135
- organization: org,
136
- user: current_user
137
- )
138
- end
139
-
140
- self.current_organization = org
141
- mark_membership_as_recent!(current_user, org)
142
- end
143
-
144
- # Clear organization session and cached values
145
- def clear_organization_session!
146
- session_key = Organizations.configuration.session_key
147
- session.delete(session_key)
148
- @_current_organization = nil
149
- @_current_membership = nil
150
- current_user&.clear_organization_cache!
151
- end
152
-
153
- # === Permission Guards ===
154
-
155
- # Requires a current organization to be set
156
- def require_organization!
157
- return if current_organization
70
+ def organizations_authenticated_layout
71
+ configured_layout = Organizations.configuration.authenticated_controller_layout
72
+ return nil if configured_layout.nil?
73
+ return configured_layout unless configured_layout.is_a?(Symbol)
158
74
 
159
- config = Organizations.configuration
160
-
161
- if config.no_organization_handler
162
- context = CallbackContext.new(event: :no_organization, user: current_user)
163
- instance_exec(context, &config.no_organization_handler)
164
- else
165
- respond_to do |format|
166
- format.html { redirect_to config.redirect_path_when_no_organization, alert: "Please select or create an organization." }
167
- format.json { render json: { error: "Organization required" }, status: :forbidden }
168
- end
169
- end
170
- end
171
-
172
- # Requires the user to have at least the specified role
173
- def require_organization_role!(role)
174
- require_organization!
175
- return unless current_organization
176
-
177
- return if current_user&.is_at_least?(role, in: current_organization)
178
-
179
- handle_unauthorized(required_role: role)
180
- end
181
-
182
- # Requires the user to have a specific permission
183
- def require_organization_permission_to!(permission)
184
- require_organization!
185
- return unless current_organization
186
-
187
- return if current_user&.has_organization_permission_to?(permission)
188
-
189
- handle_unauthorized(permission: permission)
190
- end
191
-
192
- # Requires the user to be an admin of the current organization
193
- def require_organization_admin!
194
- require_organization_role!(:admin)
195
- end
196
-
197
- # Requires the user to be the owner of the current organization
198
- def require_organization_owner!
199
- require_organization_role!(:owner)
200
- end
201
-
202
- # Handle unauthorized access
203
- def handle_unauthorized(permission: nil, required_role: nil)
204
- config = Organizations.configuration
205
-
206
- if config.unauthorized_handler
207
- context = CallbackContext.new(
208
- event: :unauthorized,
209
- user: current_user,
210
- organization: current_organization,
211
- permission: permission,
212
- required_role: required_role
213
- )
214
- instance_exec(context, &config.unauthorized_handler)
215
- else
216
- error = Organizations::NotAuthorized.new(
217
- build_unauthorized_message(permission, required_role),
218
- permission: permission || required_role,
219
- organization: current_organization,
220
- user: current_user
221
- )
222
-
223
- respond_to do |format|
224
- format.html { redirect_back fallback_location: main_app.root_path, alert: error.message }
225
- format.json { render json: { error: error.message }, status: :forbidden }
226
- end
227
- end
228
- end
229
-
230
- def fallback_organization_for(user)
231
- membership = user.memberships.includes(:organization).order(updated_at: :desc, created_at: :desc).first
232
- membership&.organization
233
- end
234
-
235
- def mark_membership_as_recent!(user, org)
236
- return unless user && org
237
-
238
- user.memberships.where(organization_id: org.id).update_all(updated_at: Time.current)
239
- end
240
-
241
- def build_unauthorized_message(permission, required_role)
242
- if required_role
243
- "You need #{required_role} access to perform this action"
244
- elsif permission
245
- "You don't have permission to #{permission.to_s.humanize.downcase}"
246
- else
247
- "You are not authorized to perform this action"
248
- end
75
+ send(configured_layout)
249
76
  end
250
77
  end
251
78
  end