organizations 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +172 -35
- 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/create_organizations_tables.rb.erb +20 -32
- 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 +29 -16
- data/lib/organizations/models/invitation.rb +1 -1
- data/lib/organizations/models/membership.rb +1 -1
- data/lib/organizations/models/organization.rb +11 -5
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +30 -2
- 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,19 @@
|
|
|
1
|
+
## [0.3.0] - 2026-02-20
|
|
2
|
+
|
|
3
|
+
- Invitation onboarding is now first-class and configurable, so host apps can remove most custom signup/invite glue code.
|
|
4
|
+
- Public invitation flows now work with Devise out of the box under the default public controller setup.
|
|
5
|
+
- Redirect behavior is now configurable for auth-required invitation acceptance, post-acceptance, post-switch, and no-organization flows.
|
|
6
|
+
- Invitation acceptance now returns structured success/failure objects, making controller handling clearer and safer.
|
|
7
|
+
- Switching and current-user resolution were hardened for auth-transition and stale-cache edge cases.
|
|
8
|
+
- Engine views now delegate host-app route helpers more cleanly, reducing `main_app.` boilerplate in host layouts/partials.
|
|
9
|
+
- `create_organization!` now forwards full attribute hashes, enabling custom organization validations/fields without workarounds.
|
|
10
|
+
- Performance improved: owner lookup avoids unnecessary SQL when memberships are preloaded (lower N+1 risk on list/admin pages).
|
|
11
|
+
- Test coverage expanded significantly around invitation flow, switching behavior, current-user resolution, and configuration contracts.
|
|
12
|
+
|
|
13
|
+
## [0.2.0] - 2026-02-20
|
|
14
|
+
|
|
15
|
+
- Namespaced all tables with `organizations_` prefix to prevent collisions with host apps
|
|
16
|
+
|
|
1
17
|
## [0.1.1] - 2026-02-19
|
|
2
18
|
|
|
3
19
|
- Removed `slugifiable` dependency (deferred to host app)
|
data/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
`organizations` adds organizations with members to any Rails app. It handles team invites, user memberships, roles, and permissions.
|
|
9
9
|
|
|
10
|
+
**🎮 [Try the live demo →](https://organizations.rameerez.com)**
|
|
11
|
+
|
|
10
12
|
[TODO: invitation / member management gif]
|
|
11
13
|
|
|
12
14
|
It's everything you need to turn a `User`-based app into a multi-tenant, `Organization`-based B2B SaaS (users belong in organizations, and organizations share resources and billing, etc.)
|
|
@@ -369,6 +371,36 @@ current_organization # Active organization (from session)
|
|
|
369
371
|
current_membership # Current user's membership in active org
|
|
370
372
|
organization_signed_in? # Is there an active organization?
|
|
371
373
|
|
|
374
|
+
# Pending invitation helpers
|
|
375
|
+
pending_organization_invitation_token # Get pending invitation token from session
|
|
376
|
+
pending_organization_invitation # Get pending invitation (clears if expired)
|
|
377
|
+
pending_organization_invitation? # Check if valid pending invitation exists
|
|
378
|
+
pending_organization_invitation_email # Get invited email (for signup prefill)
|
|
379
|
+
clear_pending_organization_invitation! # Clear invitation token and cache
|
|
380
|
+
|
|
381
|
+
# Invitation acceptance (canonical helper for post-signup flows)
|
|
382
|
+
accept_pending_organization_invitation!(user) # Accept with session token
|
|
383
|
+
accept_pending_organization_invitation!(user, token: token) # Explicit token
|
|
384
|
+
accept_pending_organization_invitation!(user, switch: false) # Don't auto-switch org
|
|
385
|
+
accept_pending_organization_invitation!(user, return_failure: true) # Returns failure object on rejection
|
|
386
|
+
pending_invitation_acceptance_redirect_path_for(user) # Accept + resolve redirect path
|
|
387
|
+
handle_pending_invitation_acceptance_for(user, redirect: true) # Accept + optionally redirect
|
|
388
|
+
# Returns InvitationAcceptanceResult or nil
|
|
389
|
+
|
|
390
|
+
# Invitation redirect helpers
|
|
391
|
+
redirect_path_when_invitation_requires_authentication(invitation) # Get auth redirect
|
|
392
|
+
redirect_path_after_invitation_accepted(invitation, user: user) # Get post-accept redirect
|
|
393
|
+
redirect_path_after_organization_switched(org, user: user) # Get post-switch redirect
|
|
394
|
+
|
|
395
|
+
# No-organization helpers
|
|
396
|
+
redirect_path_when_no_organization(user: nil) # Resolve configured no-org redirect path
|
|
397
|
+
no_organization_redirect_path # Alias
|
|
398
|
+
redirect_to_no_organization!(alert: "...", notice: "...") # Redirect and return false
|
|
399
|
+
|
|
400
|
+
# Organization creation helper
|
|
401
|
+
create_organization_and_switch!(current_user, name: "Acme") # Create and set context in one call
|
|
402
|
+
create_organization_with_context!(current_user, name: "Acme") # Backward-compatible alias
|
|
403
|
+
|
|
372
404
|
# Authorization
|
|
373
405
|
require_organization! # Redirect if no active org
|
|
374
406
|
require_organization_role!(:admin) # Require at least admin role
|
|
@@ -379,7 +411,8 @@ require_organization_owner! # Same as require_organization_role!(:owner)
|
|
|
379
411
|
require_organization_admin! # Same as require_organization_role!(:admin)
|
|
380
412
|
|
|
381
413
|
# Switching
|
|
382
|
-
switch_to_organization!(org)
|
|
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|
|
|
@@ -1064,9 +1201,9 @@ The gem provides `Organizations::Organization` as the base model. You can extend
|
|
|
1064
1201
|
# db/migrate/xxx_add_custom_fields_to_organizations.rb
|
|
1065
1202
|
class AddCustomFieldsToOrganizations < ActiveRecord::Migration[8.0]
|
|
1066
1203
|
def change
|
|
1067
|
-
add_column :
|
|
1068
|
-
add_column :
|
|
1069
|
-
add_column :
|
|
1204
|
+
add_column :organizations_organizations, :support_email, :string
|
|
1205
|
+
add_column :organizations_organizations, :billing_address, :text
|
|
1206
|
+
add_column :organizations_organizations, :settings, :jsonb, default: {}
|
|
1070
1207
|
end
|
|
1071
1208
|
end
|
|
1072
1209
|
```
|
|
@@ -1110,10 +1247,10 @@ This is standard Rails practice — the gem provides the foundation (memberships
|
|
|
1110
1247
|
|
|
1111
1248
|
The gem creates three tables:
|
|
1112
1249
|
|
|
1113
|
-
###
|
|
1250
|
+
### organizations_organizations
|
|
1114
1251
|
|
|
1115
1252
|
```sql
|
|
1116
|
-
|
|
1253
|
+
organizations_organizations
|
|
1117
1254
|
- id (primary key, auto-detects UUID or integer from your app)
|
|
1118
1255
|
- name (string, required)
|
|
1119
1256
|
- metadata (jsonb, default: {})
|
|
@@ -1123,10 +1260,10 @@ organizations
|
|
|
1123
1260
|
|
|
1124
1261
|
> **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
|
|
1125
1262
|
|
|
1126
|
-
###
|
|
1263
|
+
### organizations_memberships
|
|
1127
1264
|
|
|
1128
1265
|
```sql
|
|
1129
|
-
|
|
1266
|
+
organizations_memberships
|
|
1130
1267
|
- id (primary key)
|
|
1131
1268
|
- user_id (foreign key, indexed)
|
|
1132
1269
|
- organization_id (foreign key, indexed)
|
|
@@ -1138,10 +1275,10 @@ memberships
|
|
|
1138
1275
|
unique index: [user_id, organization_id]
|
|
1139
1276
|
```
|
|
1140
1277
|
|
|
1141
|
-
###
|
|
1278
|
+
### organizations_invitations
|
|
1142
1279
|
|
|
1143
1280
|
```sql
|
|
1144
|
-
|
|
1281
|
+
organizations_invitations
|
|
1145
1282
|
- id (primary key)
|
|
1146
1283
|
- organization_id (foreign key, indexed)
|
|
1147
1284
|
- email (string, required, indexed)
|
|
@@ -1254,7 +1391,7 @@ If you display member counts frequently (pricing pages, org listings), consider
|
|
|
1254
1391
|
|
|
1255
1392
|
```ruby
|
|
1256
1393
|
# In a migration
|
|
1257
|
-
add_column :
|
|
1394
|
+
add_column :organizations_organizations, :memberships_count, :integer, default: 0, null: false
|
|
1258
1395
|
|
|
1259
1396
|
# Reset existing counts
|
|
1260
1397
|
Organization.find_each do |org|
|
|
@@ -1275,9 +1412,9 @@ org.member_count
|
|
|
1275
1412
|
Boolean checks use efficient SQL `EXISTS` queries:
|
|
1276
1413
|
|
|
1277
1414
|
```ruby
|
|
1278
|
-
user.belongs_to_any_organization? # SELECT 1 FROM
|
|
1279
|
-
user.has_pending_organization_invitations? # SELECT 1 FROM
|
|
1280
|
-
org.has_any_members? # SELECT 1 FROM
|
|
1415
|
+
user.belongs_to_any_organization? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
|
|
1416
|
+
user.has_pending_organization_invitations? # SELECT 1 FROM organizations_invitations WHERE ... LIMIT 1
|
|
1417
|
+
org.has_any_members? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
|
|
1281
1418
|
```
|
|
1282
1419
|
|
|
1283
1420
|
### Scoped associations use JOINs
|
|
@@ -1287,13 +1424,13 @@ Methods like `org.admins` and `user.owned_organizations` use proper SQL JOINs:
|
|
|
1287
1424
|
```ruby
|
|
1288
1425
|
org.admins
|
|
1289
1426
|
# SELECT users.* FROM users
|
|
1290
|
-
# INNER JOIN
|
|
1291
|
-
# WHERE
|
|
1427
|
+
# INNER JOIN organizations_memberships ON organizations_memberships.user_id = users.id
|
|
1428
|
+
# WHERE organizations_memberships.organization_id = ? AND organizations_memberships.role IN ('admin', 'owner')
|
|
1292
1429
|
|
|
1293
1430
|
user.owned_organizations
|
|
1294
|
-
# SELECT
|
|
1295
|
-
# INNER JOIN
|
|
1296
|
-
# WHERE
|
|
1431
|
+
# SELECT organizations_organizations.* FROM organizations_organizations
|
|
1432
|
+
# INNER JOIN organizations_memberships ON organizations_memberships.organization_id = organizations_organizations.id
|
|
1433
|
+
# WHERE organizations_memberships.user_id = ? AND organizations_memberships.role = 'owner'
|
|
1297
1434
|
```
|
|
1298
1435
|
|
|
1299
1436
|
### Current organization memoization
|
|
@@ -1445,14 +1582,14 @@ The gem creates these indexes automatically:
|
|
|
1445
1582
|
|
|
1446
1583
|
```sql
|
|
1447
1584
|
-- Fast membership lookups
|
|
1448
|
-
CREATE UNIQUE INDEX
|
|
1449
|
-
CREATE INDEX
|
|
1450
|
-
CREATE INDEX
|
|
1585
|
+
CREATE UNIQUE INDEX index_organizations_memberships_on_user_and_org ON organizations_memberships (user_id, organization_id);
|
|
1586
|
+
CREATE INDEX index_organizations_memberships_on_organization_id ON organizations_memberships (organization_id);
|
|
1587
|
+
CREATE INDEX index_organizations_memberships_on_role ON organizations_memberships (role);
|
|
1451
1588
|
|
|
1452
1589
|
-- Fast invitation lookups
|
|
1453
|
-
CREATE UNIQUE INDEX
|
|
1454
|
-
CREATE INDEX
|
|
1455
|
-
CREATE UNIQUE INDEX
|
|
1590
|
+
CREATE UNIQUE INDEX index_organizations_invitations_on_token ON organizations_invitations (token);
|
|
1591
|
+
CREATE INDEX index_organizations_invitations_on_email ON organizations_invitations (email);
|
|
1592
|
+
CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
|
|
1456
1593
|
```
|
|
1457
1594
|
|
|
1458
1595
|
## Migration from 1:1 relationships
|
data/Rakefile
CHANGED
|
@@ -7,7 +7,8 @@ require "rubocop/rake_task"
|
|
|
7
7
|
Rake::TestTask.new(:test) do |t|
|
|
8
8
|
t.libs << "test"
|
|
9
9
|
t.libs << "lib"
|
|
10
|
-
|
|
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
|