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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc99dabb60fe5f7d0c4ae8567fd62c4920f4a403b804dfe4e8dcac4d3c66fa11
4
- data.tar.gz: '024976f0f5cd19fd8530179977a34e21d7ca54968bbf62d50eeff0d5f70dc1ea'
3
+ metadata.gz: 9182741872f9485ca52d426a687201fc785e5959a79fa3e4acd6b8af6e0fa9e5
4
+ data.tar.gz: 4836859405283c87543e3470e4d3cfeaf543096c76bf2589efed04099ea5112d
5
5
  SHA512:
6
- metadata.gz: b14290ba7ecfc72544a5b4553fc5fd633ab51af9bcf1e3e1ed2bd90769ce2099a658f815191f1cf8c5e4706978d0070b80654e6b6da978b636ba6a87e60e11c9
7
- data.tar.gz: 07bb21cfe8a870b8f672069ac7cb3f9512dad17ad1ea472b4cc34db9eaaf292773f05c6eb105dc5e220dc3b4b2139bfcdee1d097201774ca92cb79356373c8a3
6
+ metadata.gz: eedcce955043e1aa3bd6c0a10ed0ecc8d61d89f004176c90df5c048badbc5e8f8db6d473eeb863bdb5e0716a0b66cd543cacd7f9f8ce588e9a2702992b7b56ff
7
+ data.tar.gz: ff8cc67de84d9e9964d1888ea8404d3d10f80c271240551c1d4b8a77d3c0e3da199abdcc5c779e793d400197fc3b2c987b19a2410146b9f07c106942846c454d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
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
+
1
13
  ## [0.2.0] - 2026-02-20
2
14
 
3
15
  - Namespaced all tables with `organizations_` prefix to prevent collisions with host apps
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|
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
@@ -1,18 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- # Controller for handling organization invitations.
5
- # Supports both viewing/accepting invitations via token (public) and
6
- # managing invitations within an organization (requires admin).
4
+ # Controller for managing organization invitations.
5
+ # Requires admin/invite_members permission for all actions.
6
+ #
7
+ # Note: Public invitation routes (show/accept) are handled by
8
+ # PublicInvitationsController to avoid host app authentication filters.
7
9
  #
8
10
  class InvitationsController < ApplicationController
9
- # Skip authentication for public invitation routes
10
- skip_before_action :authenticate_organizations_user!, only: [:show, :accept]
11
-
12
- # Require invitation permission for invitation management
13
- before_action -> { require_organization_permission_to!(:invite_members) }, only: [:index, :new, :create, :destroy, :resend]
14
- before_action :set_invitation_by_token, only: [:show, :accept]
15
- before_action :set_invitation_by_id, only: [:destroy, :resend]
11
+ before_action -> { require_organization_permission_to!(:invite_members) }
12
+ before_action :set_invitation, only: [:destroy, :resend]
16
13
 
17
14
  # GET /invitations
18
15
  # List all invitations for the current organization
@@ -62,71 +59,6 @@ module Organizations
62
59
  end
63
60
  end
64
61
 
65
- # GET /invitations/:token
66
- # View invitation details (public route)
67
- def show
68
- @user_exists = user_exists_for_invitation?
69
- @user_is_logged_in = current_user.present?
70
- @user_email_matches = @user_is_logged_in && current_user.email.downcase == @invitation.email.downcase
71
-
72
- respond_to do |format|
73
- format.html
74
- format.json { render json: invitation_show_json }
75
- end
76
- end
77
-
78
- # POST /invitations/:token/accept
79
- # Accept an invitation (public route)
80
- def accept
81
- # Require authentication to accept
82
- unless current_user
83
- # Store invitation token in session for post-signup acceptance
84
- session[:pending_invitation_token] = @invitation.token
85
-
86
- respond_to do |format|
87
- format.html do
88
- redirect_to main_app.respond_to?(:new_user_registration_path) ?
89
- main_app.new_user_registration_path :
90
- main_app.root_path,
91
- alert: "Please sign in or create an account to accept this invitation."
92
- end
93
- format.json { render json: { error: "Authentication required" }, status: :unauthorized }
94
- end
95
- return
96
- end
97
-
98
- # Verify email matches (for security)
99
- unless current_user.email.downcase == @invitation.email.downcase
100
- respond_to do |format|
101
- format.html { redirect_to invitation_path(@invitation.token), alert: "This invitation was sent to a different email address." }
102
- format.json { render json: { error: "Email mismatch" }, status: :forbidden }
103
- end
104
- return
105
- end
106
-
107
- begin
108
- membership = @invitation.accept!(current_user)
109
-
110
- # Switch to the new organization
111
- switch_to_organization!(@invitation.organization)
112
-
113
- respond_to do |format|
114
- format.html { redirect_to after_accept_path, notice: "Welcome to #{@invitation.organization.name}!" }
115
- format.json { render json: { membership: membership_json(membership) }, status: :created }
116
- end
117
- rescue ::Organizations::InvitationExpired
118
- respond_to do |format|
119
- format.html { redirect_to main_app.root_path, alert: "This invitation has expired. Please request a new one." }
120
- format.json { render json: { error: "Invitation expired" }, status: :gone }
121
- end
122
- rescue ::Organizations::InvitationAlreadyAccepted
123
- respond_to do |format|
124
- format.html { redirect_to after_accept_path, notice: "You're already a member of #{@invitation.organization.name}." }
125
- format.json { render json: { message: "Already accepted" }, status: :ok }
126
- end
127
- end
128
- end
129
-
130
62
  # DELETE /invitations/:id
131
63
  # Revoke/delete an invitation
132
64
  def destroy
@@ -162,49 +94,10 @@ module Organizations
162
94
  params.require(:invitation).permit(:email, :role)
163
95
  end
164
96
 
165
- def set_invitation_by_token
166
- @invitation = ::Organizations::Invitation.find_by!(token: params[:token])
167
- rescue ActiveRecord::RecordNotFound
168
- respond_to do |format|
169
- format.html { redirect_to main_app.root_path, alert: "Invitation not found or has been revoked." }
170
- format.json { render json: { error: "Invitation not found" }, status: :not_found }
171
- end
172
- end
173
-
174
- def set_invitation_by_id
97
+ def set_invitation
175
98
  @invitation = current_organization.invitations.find(params[:id])
176
99
  end
177
100
 
178
- # Override to handle public routes where authentication is skipped
179
- # Avoids infinite recursion when method_name == :current_user
180
- def current_user
181
- return @_current_user if defined?(@_current_user)
182
-
183
- method_name = ::Organizations.configuration.current_user_method
184
-
185
- # Avoid infinite recursion: if configured method is :current_user,
186
- # call the parent implementation, not this method
187
- @_current_user = if method_name == :current_user
188
- super rescue nil
189
- elsif respond_to?(method_name, true)
190
- send(method_name)
191
- end
192
- end
193
-
194
- def user_exists_for_invitation?
195
- # Check if a user exists with this email
196
- # This requires knowledge of the User model
197
- if defined?(User) && User.respond_to?(:exists?)
198
- User.exists?(email: @invitation.email.downcase)
199
- else
200
- false
201
- end
202
- end
203
-
204
- def after_accept_path
205
- main_app.respond_to?(:root_path) ? main_app.root_path : "/"
206
- end
207
-
208
101
  # JSON serialization helpers
209
102
 
210
103
  def invitations_json(invitations)
@@ -228,35 +121,5 @@ module Organizations
228
121
  created_at: invitation.created_at
229
122
  }
230
123
  end
231
-
232
- def invitation_show_json
233
- inviter = @invitation.invited_by
234
- inviter_name = if inviter
235
- inviter.respond_to?(:name) && inviter.name.present? ? inviter.name : inviter.email
236
- else
237
- "Someone"
238
- end
239
- {
240
- invitation: {
241
- organization_name: @invitation.organization.name,
242
- role: @invitation.role,
243
- invited_by_name: inviter_name,
244
- status: @invitation.status,
245
- expires_at: @invitation.expires_at
246
- },
247
- user_exists: @user_exists,
248
- user_is_logged_in: @user_is_logged_in,
249
- user_email_matches: @user_email_matches
250
- }
251
- end
252
-
253
- def membership_json(membership)
254
- {
255
- id: membership.id,
256
- organization_id: membership.organization_id,
257
- role: membership.role,
258
- created_at: membership.created_at
259
- }
260
- end
261
124
  end
262
125
  end