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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -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 +38 -5
- data/lib/organizations.rb +27 -6
- metadata +10 -3
- data/LICENSE +0 -21
|
@@ -40,13 +40,10 @@ module Organizations
|
|
|
40
40
|
# Create a new organization
|
|
41
41
|
def create
|
|
42
42
|
begin
|
|
43
|
-
@organization =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
34
|
-
post "invitations/:token/accept", to: "
|
|
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
|