organizations 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +148 -11
- data/Rakefile +2 -1
- data/app/controllers/organizations/application_controller.rb +23 -196
- data/app/controllers/organizations/invitations_controller.rb +8 -145
- data/app/controllers/organizations/organizations_controller.rb +14 -6
- data/app/controllers/organizations/public_controller.rb +61 -0
- data/app/controllers/organizations/public_invitations_controller.rb +196 -0
- data/app/controllers/organizations/switch_controller.rb +15 -5
- data/app/models/organizations/invitation.rb +6 -0
- data/app/models/organizations/membership.rb +6 -0
- data/app/models/organizations/organization.rb +6 -0
- data/config/routes.rb +4 -2
- data/lib/generators/organizations/install/templates/initializer.rb +76 -0
- data/lib/organizations/configuration.rb +109 -0
- data/lib/organizations/controller_helpers.rb +512 -17
- data/lib/organizations/current_user_resolution.rb +89 -0
- data/lib/organizations/engine.rb +7 -9
- data/lib/organizations/invitation_acceptance_failure.rb +44 -0
- data/lib/organizations/invitation_acceptance_result.rb +60 -0
- data/lib/organizations/models/concerns/has_organizations.rb +28 -15
- data/lib/organizations/models/organization.rb +8 -2
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +28 -0
- data/lib/organizations.rb +27 -6
- metadata +10 -3
- data/LICENSE +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9182741872f9485ca52d426a687201fc785e5959a79fa3e4acd6b8af6e0fa9e5
|
|
4
|
+
data.tar.gz: 4836859405283c87543e3470e4d3cfeaf543096c76bf2589efed04099ea5112d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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[:
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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::
|
|
716
|
-
POST /invitations/:token/accept → Organizations::
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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
|