organizations 0.2.0 → 0.3.1

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.
@@ -40,13 +40,10 @@ module Organizations
40
40
  # Create a new organization
41
41
  def create
42
42
  begin
43
- @organization = current_user.create_organization!(organization_params[:name])
44
-
45
- # Switch to the new organization
46
- switch_to_organization!(@organization)
43
+ @organization = create_organization_and_switch!(current_user, organization_params.to_h)
47
44
 
48
45
  respond_to do |format|
49
- format.html { redirect_to organization_path(@organization), notice: "Organization created successfully." }
46
+ format.html { redirect_to after_create_redirect_path(@organization), notice: "Organization created successfully." }
50
47
  format.json { render json: organization_json(@organization), status: :created }
51
48
  end
52
49
  rescue Organizations::Models::Concerns::HasOrganizations::OrganizationLimitReached => e
@@ -117,7 +114,18 @@ module Organizations
117
114
  end
118
115
 
119
116
  def organization_params
120
- params.require(:organization).permit(:name)
117
+ base_params = [:name]
118
+ additional_params = Organizations.configuration.additional_organization_params || []
119
+ params.require(:organization).permit(base_params + additional_params)
120
+ end
121
+
122
+ def after_create_redirect_path(organization)
123
+ custom_path = Organizations.configuration.after_organization_created_redirect_path
124
+ resolve_controller_redirect_path(
125
+ custom_path,
126
+ organization,
127
+ default: -> { organization_path(organization) }
128
+ )
121
129
  end
122
130
 
123
131
  def authorize_manage_settings!
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Base controller for public routes that don't require authentication.
5
+ # Inherits from the configured public_controller (defaults to ActionController::Base)
6
+ # to avoid inheriting host app filters that might enforce authentication.
7
+ #
8
+ # Use cases:
9
+ # - Invitation acceptance pages (users clicking email links)
10
+ # - Any other routes that should work for unauthenticated users
11
+ #
12
+ class PublicController < (Organizations.configuration.public_controller.constantize rescue ActionController::Base)
13
+ include Organizations::CurrentUserResolution
14
+
15
+ # Protect from forgery if available
16
+ protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
17
+
18
+ if respond_to?(:layout)
19
+ # Resolve layout at request-time so runtime config changes are respected.
20
+ layout :organizations_public_layout
21
+ end
22
+
23
+ # Include main app route helpers so host app layouts work correctly
24
+ # (e.g., root_path, pricing_path in navbar partials)
25
+ include Rails.application.routes.url_helpers if defined?(Rails.application.routes.url_helpers)
26
+ helper Rails.application.routes.url_helpers if respond_to?(:helper)
27
+
28
+ # Minimal helpers needed for public routes
29
+ helper_method :current_user if respond_to?(:helper_method)
30
+
31
+ private
32
+
33
+ # Returns the current user from the host application (if any).
34
+ # Uses the configured method name (defaults to :current_user)
35
+ #
36
+ # NOTE: Nil values are intentionally not cached to handle auth-transition flows
37
+ # where user state changes mid-request (e.g., sign_in during invitation acceptance).
38
+ def current_user
39
+ resolve_organizations_current_user(
40
+ cache_ivar: :@_current_user,
41
+ cache_nil: false,
42
+ prefer_super_for_current_user: true,
43
+ prefer_warden_for_current_user: true
44
+ )
45
+ end
46
+
47
+ def organizations_public_layout
48
+ configured_layout = Organizations.configuration.public_controller_layout
49
+ return nil if configured_layout.nil?
50
+ return configured_layout unless configured_layout.is_a?(Symbol)
51
+
52
+ send(configured_layout)
53
+ end
54
+
55
+ # Access main_app routes from engine views
56
+ def main_app
57
+ Rails.application.routes.url_helpers
58
+ end
59
+ helper_method :main_app if respond_to?(:helper_method)
60
+ end
61
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Controller for public invitation routes (show and accept).
5
+ # Inherits from PublicController to avoid host app authentication filters.
6
+ #
7
+ # Routes:
8
+ # GET /invitations/:token → View invitation details
9
+ # POST /invitations/:token/accept → Accept the invitation
10
+ #
11
+ class PublicInvitationsController < PublicController
12
+ include Organizations::Controller if defined?(Organizations::Controller)
13
+
14
+ before_action :set_invitation
15
+
16
+ # Use the same view path as InvitationsController so host apps
17
+ # don't need to duplicate views for public routes
18
+ def self.controller_path
19
+ "organizations/invitations"
20
+ end
21
+
22
+ # GET /invitations/:token
23
+ # View invitation details (public route)
24
+ def show
25
+ @user_exists = user_exists_for_invitation?
26
+ @user_is_logged_in = current_user.present?
27
+ @user_email_matches = @user_is_logged_in && current_user.email.downcase == @invitation.email.downcase
28
+
29
+ respond_to do |format|
30
+ format.html
31
+ format.json { render json: invitation_show_json }
32
+ end
33
+ end
34
+
35
+ # POST /invitations/:token/accept
36
+ # Accept an invitation (public route)
37
+ def accept
38
+ return respond_invitation_authentication_required unless current_user
39
+
40
+ result = accept_pending_organization_invitation!(
41
+ current_user,
42
+ token: @invitation.token,
43
+ switch: true,
44
+ skip_email_validation: false,
45
+ return_failure: true
46
+ )
47
+
48
+ return respond_invitation_acceptance_failure(result) if result.failure?
49
+
50
+ respond_invitation_acceptance_success(result)
51
+ end
52
+
53
+ private
54
+
55
+ def respond_invitation_authentication_required
56
+ session[pending_invitation_session_key] = @invitation.token
57
+
58
+ respond_to do |format|
59
+ format.html do
60
+ redirect_to redirect_path_when_invitation_requires_authentication(@invitation),
61
+ alert: "Please sign in or create an account to accept this invitation."
62
+ end
63
+ format.json { render json: { error: "Authentication required" }, status: :unauthorized }
64
+ end
65
+ end
66
+
67
+ def respond_invitation_acceptance_failure(failure)
68
+ case failure.failure_reason
69
+ when :email_mismatch
70
+ return respond_invitation_email_mismatch
71
+ when :invitation_expired
72
+ return respond_invitation_expired
73
+ end
74
+
75
+ respond_invitation_error(
76
+ html_path: main_app.root_path,
77
+ alert: "Unable to accept this invitation.",
78
+ json_error: "Acceptance failed",
79
+ status: :unprocessable_entity
80
+ )
81
+ end
82
+
83
+ def respond_invitation_email_mismatch
84
+ respond_invitation_error(
85
+ html_path: invitation_path(@invitation.token),
86
+ alert: "This invitation was sent to a different email address.",
87
+ json_error: "Email mismatch",
88
+ status: :forbidden
89
+ )
90
+ end
91
+
92
+ def respond_invitation_expired
93
+ respond_invitation_error(
94
+ html_path: main_app.root_path,
95
+ alert: "This invitation has expired. Please request a new one.",
96
+ json_error: "Invitation expired",
97
+ status: :gone
98
+ )
99
+ end
100
+
101
+ def respond_invitation_acceptance_success(result)
102
+ after_path = redirect_path_after_invitation_accepted(result.invitation, user: current_user)
103
+ payload, status = invitation_acceptance_json_response(result)
104
+
105
+ respond_to do |format|
106
+ format.html { redirect_to after_path, notice: invitation_acceptance_notice(result) }
107
+ format.json { render json: payload, status: status }
108
+ end
109
+ end
110
+
111
+ def set_invitation
112
+ @invitation = ::Organizations::Invitation.find_by!(token: params[:token])
113
+ rescue ActiveRecord::RecordNotFound
114
+ respond_to do |format|
115
+ format.html { redirect_to main_app.root_path, alert: "Invitation not found or has been revoked." }
116
+ format.json { render json: { error: "Invitation not found" }, status: :not_found }
117
+ end
118
+ end
119
+
120
+ def user_exists_for_invitation?
121
+ if defined?(User) && User.respond_to?(:exists?)
122
+ User.exists?(email: @invitation.email.downcase)
123
+ else
124
+ false
125
+ end
126
+ end
127
+
128
+ # JSON serialization helpers
129
+
130
+ def invitation_show_json
131
+ inviter = @invitation.invited_by
132
+ inviter_name = if inviter
133
+ inviter.respond_to?(:name) && inviter.name.present? ? inviter.name : inviter.email
134
+ else
135
+ "Someone"
136
+ end
137
+ {
138
+ invitation: {
139
+ organization_name: @invitation.organization.name,
140
+ role: @invitation.role,
141
+ invited_by_name: inviter_name,
142
+ status: @invitation.status,
143
+ expires_at: @invitation.expires_at
144
+ },
145
+ user_exists: @user_exists,
146
+ user_is_logged_in: @user_is_logged_in,
147
+ user_email_matches: @user_email_matches
148
+ }
149
+ end
150
+
151
+ def membership_json(membership)
152
+ {
153
+ id: membership.id,
154
+ organization_id: membership.organization_id,
155
+ role: membership.role,
156
+ created_at: membership.created_at
157
+ }
158
+ end
159
+
160
+ def respond_invitation_error(html_path:, alert:, json_error:, status:)
161
+ respond_to do |format|
162
+ format.html { redirect_to html_path, alert: alert }
163
+ format.json { render json: { error: json_error }, status: status }
164
+ end
165
+ end
166
+
167
+ def invitation_acceptance_notice(result)
168
+ org_name = result.invitation.organization.name
169
+
170
+ if result.already_member?
171
+ "You're already a member of #{org_name}."
172
+ elsif result.switched?
173
+ "Welcome to #{org_name}!"
174
+ else
175
+ # Rare edge case: membership was created but context switch failed
176
+ "You've joined #{org_name}! Navigate to the organization to get started."
177
+ end
178
+ end
179
+
180
+ def invitation_acceptance_json_response(result)
181
+ if result.already_member?
182
+ [{ message: "Already accepted" }, :ok]
183
+ elsif result.switched?
184
+ [{ membership: membership_json(result.membership) }, :created]
185
+ else
186
+ # Rare edge case: membership was created but context switch failed
187
+ [{ membership: membership_json(result.membership), warning: "Could not switch context automatically" }, :created]
188
+ end
189
+ end
190
+
191
+ # Route helpers for engine routes
192
+ def invitation_path(token)
193
+ Organizations::Engine.routes.url_helpers.invitation_path(token)
194
+ end
195
+ end
196
+ end
@@ -12,13 +12,16 @@ module Organizations
12
12
  # Switch to a different organization
13
13
  # POST /organizations/switch/:id
14
14
  def create
15
- org = current_user.organizations.find_by(id: params[:id])
15
+ user = organizations_current_user(refresh: true)
16
+ return respond_unauthorized unless user
17
+
18
+ org = user.organizations.find_by(id: params[:id])
16
19
 
17
20
  if org
18
- switch_to_organization!(org)
21
+ switch_to_organization!(org, user: user)
19
22
 
20
23
  respond_to do |format|
21
- format.html { redirect_to after_switch_path, notice: "Switched to #{org.name}" }
24
+ format.html { redirect_to after_switch_path(org, user: user), notice: "Switched to #{org.name}" }
22
25
  format.json { render json: { organization: { id: org.id, name: org.name } } }
23
26
  end
24
27
  else
@@ -31,8 +34,15 @@ module Organizations
31
34
 
32
35
  private
33
36
 
34
- def after_switch_path
35
- main_app.respond_to?(:root_path) ? main_app.root_path : "/"
37
+ def after_switch_path(organization, user:)
38
+ redirect_path_after_organization_switched(organization, user: user)
39
+ end
40
+
41
+ def respond_unauthorized
42
+ respond_to do |format|
43
+ format.html { redirect_to main_app.root_path, alert: "You need to sign in to switch organizations." }
44
+ format.json { render json: { error: "Unauthorized" }, status: :unauthorized }
45
+ end
36
46
  end
37
47
  end
38
48
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::Invitation in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/invitation.rb", __dir__)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::Membership in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/membership.rb", __dir__)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::Organization in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/organization.rb", __dir__)
data/config/routes.rb CHANGED
@@ -27,9 +27,11 @@ Organizations::Engine.routes.draw do
27
27
  end
28
28
 
29
29
  # Invitation acceptance (public routes with token)
30
+ # These use PublicInvitationsController which inherits from a minimal base controller
31
+ # to avoid host app filters that might enforce authentication.
30
32
  # GET /invitations/:token → View invitation details
31
33
  # POST /invitations/:token/accept → Accept the invitation
32
34
  # NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
33
- get "invitations/:token", to: "invitations#show", as: :invitation
34
- post "invitations/:token/accept", to: "invitations#accept", as: :accept_invitation
35
+ get "invitations/:token", to: "public_invitations#show", as: :invitation
36
+ post "invitations/:token/accept", to: "public_invitations#accept", as: :accept_invitation
35
37
  end
@@ -71,4 +71,80 @@ Organizations.configure do |config|
71
71
  # Default: :current_organization_id
72
72
  # config.session_key = :current_organization_id
73
73
 
74
+ # ============================================================================
75
+ # ORGANIZATIONS CONTROLLER
76
+ # ============================================================================
77
+
78
+ # Additional params to permit when creating/updating organizations.
79
+ # Use this to add custom fields to organizations (e.g., support_email, logo).
80
+ # Default: [] (only :name is permitted)
81
+ # config.additional_organization_params = [:support_email, :billing_email]
82
+
83
+ # Where to redirect after organization is created.
84
+ # Can be a String path or a Proc that receives the organization.
85
+ # Default: nil (redirects to organization show page)
86
+ # config.after_organization_created_redirect_path = "/dashboard"
87
+ # config.after_organization_created_redirect_path = ->(org) { "/orgs/#{org.id}/setup" }
88
+
89
+ # ============================================================================
90
+ # REDIRECTS
91
+ # ============================================================================
92
+
93
+ # Where to redirect when user has no organization.
94
+ # Default: "/organizations/new"
95
+ # config.redirect_path_when_no_organization = "/onboarding"
96
+
97
+ # Optional flash messages used by the built-in no-organization handler.
98
+ # Leave both nil to keep the default alert:
99
+ # "Please select or create an organization."
100
+ # config.no_organization_alert = "Please create an organization first."
101
+ # config.no_organization_notice = "Please create or join an organization to continue."
102
+
103
+ # ============================================================================
104
+ # INVITATION FLOW REDIRECTS
105
+ # ============================================================================
106
+
107
+ # Where to redirect unauthenticated users when they try to accept an invitation.
108
+ # Use this to customize the signup/login page for invited users.
109
+ # Can be a String path or a Proc receiving (invitation, user).
110
+ # Default: nil (uses new_user_registration_path or root_path)
111
+ # config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
112
+ # config.redirect_path_when_invitation_requires_authentication = ->(inv, _user) { "/signup?invite=#{inv.token}" }
113
+
114
+ # Where to redirect after an invitation is accepted.
115
+ # Can be a String path or a Proc receiving (invitation, user).
116
+ # Default: nil (uses root_path)
117
+ # config.redirect_path_after_invitation_accepted = "/dashboard"
118
+ # config.redirect_path_after_invitation_accepted = ->(inv, user) { "/org/#{inv.organization_id}/welcome" }
119
+
120
+ # Where to redirect after switching organizations.
121
+ # Can be a String path or a Proc receiving (organization, user).
122
+ # Default: nil (uses root_path)
123
+ # config.redirect_path_after_organization_switched = "/dashboard"
124
+ # config.redirect_path_after_organization_switched = ->(org, user) { "/orgs/#{org.id}?user=#{user.id}" }
125
+
126
+ # ============================================================================
127
+ # ENGINE CONTROLLERS
128
+ # ============================================================================
129
+
130
+ # Base controller for authenticated routes (default: ::ApplicationController)
131
+ # All organization management controllers inherit from this.
132
+ # config.parent_controller = "::ApplicationController"
133
+
134
+ # Base controller for public routes like invitation acceptance.
135
+ # Uses a minimal base to avoid host app filters that enforce authentication.
136
+ # Works with Devise out of the box - no configuration needed.
137
+ # Only override if using custom auth or needing specific inheritance.
138
+ # Default: ActionController::Base
139
+ # config.public_controller = "ActionController::Base"
140
+
141
+ # Layout override for authenticated engine controllers.
142
+ # Use this to avoid host-side layout monkey patches.
143
+ # Default: nil (inherits host controller defaults)
144
+ # config.authenticated_controller_layout = "dashboard"
145
+
146
+ # Layout override for public engine controllers (invitation pages).
147
+ # Default: nil (inherits host controller defaults)
148
+ # config.public_controller_layout = "devise"
149
+
74
150
  end
@@ -67,9 +67,55 @@ module Organizations
67
67
  # Where to redirect when user has no organization
68
68
  attr_accessor :redirect_path_when_no_organization
69
69
 
70
+ # Where to redirect after organization is created (nil = default show page)
71
+ # Can be a String ("/dashboard") or Proc (->(org) { "/orgs/#{org.id}" })
72
+ attr_accessor :after_organization_created_redirect_path
73
+
74
+ # Default flash alert for no-organization redirects when using the built-in handler
75
+ # nil keeps backward-compatible default alert
76
+ attr_accessor :no_organization_alert
77
+
78
+ # Default flash notice for no-organization redirects when using the built-in handler
79
+ # nil means no notice
80
+ attr_accessor :no_organization_notice
81
+
82
+ # === Invitation Flow Redirects ===
83
+ # Where to redirect unauthenticated users when they try to accept an invitation
84
+ # Can be nil (use default: new_user_registration_path or root_path),
85
+ # a String ("/users/sign_up"), or a Proc receiving (invitation, user)
86
+ attr_accessor :redirect_path_when_invitation_requires_authentication
87
+
88
+ # Where to redirect after invitation is accepted
89
+ # Can be nil (use default: root_path), a String ("/dashboard"),
90
+ # or a Proc receiving (invitation, user)
91
+ attr_accessor :redirect_path_after_invitation_accepted
92
+
93
+ # Where to redirect after organization switch
94
+ # Can be nil (use default: root_path), a String ("/dashboard"),
95
+ # or a Proc receiving (organization, user)
96
+ attr_accessor :redirect_path_after_organization_switched
97
+
98
+ # === Organizations Controller ===
99
+ # Additional params to permit when creating/updating organizations
100
+ # @example [:support_email, :billing_email, :logo]
101
+ attr_accessor :additional_organization_params
102
+
70
103
  # === Engine configuration ===
104
+ # Base controller for authenticated routes (default: ::ApplicationController)
71
105
  attr_accessor :parent_controller
72
106
 
107
+ # Base controller for public routes like invitation acceptance (default: ActionController::Base)
108
+ # Use this to avoid inheriting host app filters that enforce authentication
109
+ attr_accessor :public_controller
110
+
111
+ # Layout for authenticated engine controllers (OrganizationsController, etc.)
112
+ # Can be nil (use controller default), String, or Symbol
113
+ attr_accessor :authenticated_controller_layout
114
+
115
+ # Layout for public engine controllers (PublicInvitationsController, etc.)
116
+ # Can be nil (use controller default), String, or Symbol
117
+ attr_accessor :public_controller_layout
118
+
73
119
  # === Handlers (blocks) ===
74
120
  # @private - stored handler blocks
75
121
  attr_reader :unauthorized_handler, :no_organization_handler
@@ -112,9 +158,23 @@ module Organizations
112
158
 
113
159
  # Redirects
114
160
  @redirect_path_when_no_organization = "/organizations/new"
161
+ @after_organization_created_redirect_path = nil
162
+ @no_organization_alert = nil
163
+ @no_organization_notice = nil
164
+
165
+ # Invitation flow redirects
166
+ @redirect_path_when_invitation_requires_authentication = nil
167
+ @redirect_path_after_invitation_accepted = nil
168
+ @redirect_path_after_organization_switched = nil
169
+
170
+ # Organizations controller
171
+ @additional_organization_params = []
115
172
 
116
173
  # Engine
117
174
  @parent_controller = "::ApplicationController"
175
+ @public_controller = "ActionController::Base"
176
+ @authenticated_controller_layout = nil
177
+ @public_controller_layout = nil
118
178
 
119
179
  # Handlers (nil by default - use default behavior)
120
180
  @unauthorized_handler = nil
@@ -248,6 +308,9 @@ module Organizations
248
308
  validate_authentication_methods!
249
309
  validate_invitation_settings!
250
310
  validate_limits!
311
+ validate_invitation_redirects!
312
+ validate_no_organization_messages!
313
+ validate_controller_layouts!
251
314
  true
252
315
  end
253
316
 
@@ -282,5 +345,51 @@ module Organizations
282
345
  raise ConfigurationError, "max_organizations_per_user must be at least 1"
283
346
  end
284
347
  end
348
+
349
+ def validate_invitation_redirects!
350
+ validate_redirect_option!(
351
+ @redirect_path_when_invitation_requires_authentication,
352
+ "redirect_path_when_invitation_requires_authentication"
353
+ )
354
+ validate_redirect_option!(
355
+ @redirect_path_after_invitation_accepted,
356
+ "redirect_path_after_invitation_accepted"
357
+ )
358
+ validate_redirect_option!(
359
+ @redirect_path_after_organization_switched,
360
+ "redirect_path_after_organization_switched"
361
+ )
362
+ end
363
+
364
+ def validate_controller_layouts!
365
+ validate_layout_option!(@authenticated_controller_layout, "authenticated_controller_layout")
366
+ validate_layout_option!(@public_controller_layout, "public_controller_layout")
367
+ end
368
+
369
+ def validate_no_organization_messages!
370
+ validate_string_option!(@no_organization_alert, "no_organization_alert")
371
+ validate_string_option!(@no_organization_notice, "no_organization_notice")
372
+ end
373
+
374
+ def validate_redirect_option!(value, option_name)
375
+ return if value.nil? || value.is_a?(String) || value.is_a?(Proc)
376
+
377
+ raise ConfigurationError,
378
+ "#{option_name} must be nil, a String, or a Proc"
379
+ end
380
+
381
+ def validate_layout_option!(value, option_name)
382
+ return if value.nil? || value.is_a?(String) || value.is_a?(Symbol)
383
+
384
+ raise ConfigurationError,
385
+ "#{option_name} must be nil, a String, or a Symbol"
386
+ end
387
+
388
+ def validate_string_option!(value, option_name)
389
+ return if value.nil? || value.is_a?(String)
390
+
391
+ raise ConfigurationError,
392
+ "#{option_name} must be nil or a String"
393
+ end
285
394
  end
286
395
  end