organizations 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a2f3a64510fcf3f08245894e49fbdd1a2aae8e76b9b9fb17fa2a6baa212c3e9
4
- data.tar.gz: 593fdc944621beb95f6bb318ade8ae88e4933a81f25bd273253704ab9f491f05
3
+ metadata.gz: 916bef6e3c027111a6da0cfa08cf015f2d872de614d55ab8978ce41e2a8ab8aa
4
+ data.tar.gz: 6d0fb70f4292c7a7635d16acde00f7b88cdbc9fdcc8d4268d76b65010bf16667
5
5
  SHA512:
6
- metadata.gz: 3c5327208fd86db81f8255bf95405736abc01e374e72a190041f31eb4ced7cff63bf2cb7b040d3a4dfc32fcdde7d11282ad6949c70a282eaed1454f318dc9908
7
- data.tar.gz: 7537c69f044a08a8e8422549b4b9272768c412ef3c13a97600808bb8b1a5d1102479c7d9c5c5d61e062ecec84be92132a687405f751033fdfc58f29fc2ca0227
6
+ metadata.gz: 78deb6016d5e62f432c1949ee9b8f700ce370e75a682451ace35a6923eb208e8f83800312cb525631fc1ee150a832f2748a67ab05b7f714873ca4b09b4e67adc
7
+ data.tar.gz: 529a7721cf5eef70b856a6b6e357e6d8881caf5b9108f31651cc2be7d83ea4258bde159b9a39d787ea78d71ed2b80d61939e1e5576c575df9416719729582c2f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.4.2] - 2026-03-19
2
+
3
+ - Added `should_create_personal_organization?` predicate as extension seam for conditional personal org creation
4
+ - DSL `create_personal_organization` setting now accepts procs for dynamic evaluation
5
+ - Added `DELETE /memberships/leave` route for users to leave organizations
6
+ - Updated owner deletion guard message to clarify transfer/delete solution
7
+ - Documentation: added "Pattern 4: Hybrid Onboarding" to README
8
+
1
9
  ## [0.4.1] - 2026-03-17
2
10
 
3
11
  - Fixed `memberships_count` counter cache writes on `Organizations::Membership.create!`
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **🎮 [Try the live demo →](https://organizations.rameerez.com)**
11
11
 
12
- [TODO: invitation / member management gif]
12
+ https://github.com/user-attachments/assets/2eddafe2-025b-4670-af9f-e0d5480508c5
13
13
 
14
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.)
15
15
 
@@ -74,13 +74,13 @@ That's the simplest setup. You can also configure per-model options:
74
74
  class User < ApplicationRecord
75
75
  has_organizations do
76
76
  max_organizations 5 # Limit how many orgs a user can own (nil = unlimited)
77
- create_personal_org true # Auto-create org on signup (default: false)
77
+ create_personal_org true # Auto-create org on signup (default: false). Can also be a proc.
78
78
  require_organization true # Require users to have at least one org (default: false)
79
79
  end
80
80
  end
81
81
  ```
82
82
 
83
- > **Note:** By default, users can exist without any organization (invite-to-join flow). Set `create_personal_org true` if you want to auto-create a personal organization when users sign up.
83
+ > **Note:** By default, users can exist without any organization (invite-to-join flow). Set `create_personal_org true` if you want to auto-create a personal organization when users sign up. For conditional creation, use a proc: `create_personal_org ->(user) { ... }` or override `should_create_personal_organization?` on your User model (see Pattern 4: Hybrid Onboarding).
84
84
 
85
85
  Mount the engine in your routes:
86
86
 
@@ -108,6 +108,8 @@ org = current_user.create_organization!("Acme Corp")
108
108
 
109
109
  ### Invite teammates
110
110
 
111
+ <img src="docs/organizations-team-members.webp" width="500" />
112
+
111
113
  ```ruby
112
114
  current_user.send_organization_invite_to!("teammate@example.com")
113
115
  # Sends invitation email: "John invited you to join Acme Corp"
@@ -122,6 +124,8 @@ current_user.send_organization_invite_to!("teammate@example.com", organization:
122
124
 
123
125
  ### Check roles and permissions
124
126
 
127
+ <img src="docs/organizations-member-permissions.webp" width="500" />
128
+
125
129
  ```ruby
126
130
  # Quick role checks (in current organization)
127
131
  current_user.is_organization_owner? # => true/false
@@ -141,6 +145,8 @@ current_user.is_member_of?(@org)
141
145
 
142
146
  ### Switch between organizations
143
147
 
148
+ <img src="docs/organizations-switcher.webp" width="400" />
149
+
144
150
  ```ruby
145
151
  # User belongs to multiple organizations? No problem.
146
152
  current_user.organizations # => [acme, startup_co, personal]
@@ -652,6 +658,8 @@ org.send_invite_to!("teammate@example.com", invited_by: current_user)
652
658
 
653
659
  The gem handles **both existing users and new signups** with a single invitation link:
654
660
 
661
+ <img src="docs/organizations-invitation-accept-create-account.webp" width="500" />
662
+
655
663
  **For existing users:**
656
664
  1. Invitation created → Email sent with unique link
657
665
  2. User clicks link → Sees invitation details (org name, inviter, role)
@@ -888,6 +896,165 @@ Organizations.configure do |config|
888
896
  end
889
897
  ```
890
898
 
899
+ ## User Onboarding Patterns
900
+
901
+ The `always_create_personal_organization_for_each_user` setting controls how new users get their first organization. This is one of the most important decisions when integrating the gem.
902
+
903
+ ### Pattern 1: Instant Access (auto-create)
904
+
905
+ **Think:** Notion, Slack, Trello — "Sign up and start using it in 10 seconds"
906
+
907
+ ```ruby
908
+ config.always_create_personal_organization_for_each_user = true
909
+ config.default_organization_name = "My Workspace"
910
+ ```
911
+
912
+ ```
913
+ ┌─────────────────────────────────────────────────────────────┐
914
+ │ User signs up → "My Workspace" created → Dashboard 🎉 │
915
+ └─────────────────────────────────────────────────────────────┘
916
+ ```
917
+
918
+ Users land in the app immediately. Zero friction. They can invite teammates later.
919
+
920
+ ```ruby
921
+ # config/initializers/organizations.rb
922
+ Organizations.configure do |config|
923
+ config.always_create_personal_organization_for_each_user = true
924
+ config.default_organization_name = "My Workspace"
925
+ # Or personalize it:
926
+ # config.default_organization_name = ->(user) { "#{user.email.split('@').first}'s Workspace" }
927
+ end
928
+ ```
929
+
930
+ Best for: productivity tools, note apps, personal SaaS, anything where "just let me in" matters.
931
+
932
+ ### Pattern 2: Guided Onboarding (manual create)
933
+
934
+ **Think:** Stripe, HubSpot, Salesforce — "Tell us about your company first"
935
+
936
+ ```ruby
937
+ config.always_create_personal_organization_for_each_user = false
938
+ config.redirect_path_when_no_organization = "/onboarding/create_organization"
939
+ ```
940
+
941
+ ```
942
+ ┌──────────────────────────────────────────────────────────────────────────┐
943
+ │ User signs up → Onboarding wizard → "Create your company" → App │
944
+ │ (collect company name, billing email, etc.) │
945
+ └──────────────────────────────────────────────────────────────────────────┘
946
+ ```
947
+
948
+ You control the experience. Collect whatever info you need before they enter.
949
+
950
+ ```ruby
951
+ # config/initializers/organizations.rb
952
+ Organizations.configure do |config|
953
+ config.always_create_personal_organization_for_each_user = false
954
+ config.redirect_path_when_no_organization = "/onboarding/create_organization"
955
+ config.no_organization_notice = "Let's set up your company first."
956
+ config.additional_organization_params = [:billing_email, :company_size, :industry]
957
+ end
958
+ ```
959
+
960
+ ```ruby
961
+ # app/controllers/onboarding_controller.rb
962
+ def create_organization
963
+ @organization = current_user.create_organization!(
964
+ name: params[:company_name],
965
+ billing_email: params[:billing_email]
966
+ )
967
+ redirect_to dashboard_path
968
+ end
969
+ ```
970
+
971
+ Best for: B2B SaaS, enterprise tools, apps that need company details for billing/compliance.
972
+
973
+ ### Pattern 3: Invitation-Only
974
+
975
+ **Think:** Internal company tools, private beta, enterprise deployments
976
+
977
+ ```ruby
978
+ config.always_create_personal_organization_for_each_user = false
979
+ config.redirect_path_when_no_organization = "/waiting_room"
980
+ ```
981
+
982
+ ```
983
+ ┌───────────────────────────────────────────────────────────────────────┐
984
+ │ User signs up → "Waiting for invitation" page → (nothing yet) │
985
+ │ │
986
+ │ Admin invites user → User accepts → Joins org → Dashboard 🎉 │
987
+ └───────────────────────────────────────────────────────────────────────┘
988
+ ```
989
+
990
+ Users can't do anything until an admin invites them. Full control over who gets in.
991
+
992
+ Best for: internal tools, private beta programs, enterprise B2B where orgs are pre-provisioned.
993
+
994
+ ### Pattern 4: Hybrid Onboarding
995
+
996
+ **Think:** Best of both worlds — instant access for direct signups, no clutter for invited users
997
+
998
+ Direct signups get a personal workspace immediately. Invited users skip the personal workspace and join the organization that invited them directly. This avoids creating unnecessary "My Workspace" organizations for users who are joining an existing team.
999
+
1000
+ ```
1001
+ ┌──────────────────────────────────────────────────────────────────────────────┐
1002
+ │ Direct signup → "My Workspace" auto-created → Dashboard 🎉 │
1003
+ │ │
1004
+ │ Invited signup → No personal org → Joins invited org → Dashboard 🎉 │
1005
+ └──────────────────────────────────────────────────────────────────────────────┘
1006
+ ```
1007
+
1008
+ Implementation requires overriding the `should_create_personal_organization?` method on your User model:
1009
+
1010
+ ```ruby
1011
+ # app/models/user.rb
1012
+ class User < ApplicationRecord
1013
+ has_organizations
1014
+
1015
+ # Skip personal org creation for users signing up via invitation
1016
+ attr_accessor :skip_personal_organization
1017
+
1018
+ def should_create_personal_organization?
1019
+ return false if skip_personal_organization
1020
+ super
1021
+ end
1022
+ end
1023
+ ```
1024
+
1025
+ Then set the flag before the user is persisted. With Devise, override `build_resource`:
1026
+
1027
+ ```ruby
1028
+ # app/controllers/users/registrations_controller.rb
1029
+ class Users::RegistrationsController < Devise::RegistrationsController
1030
+ protected
1031
+
1032
+ def build_resource(hash = {})
1033
+ super
1034
+ # Skip personal org for users signing up via invitation
1035
+ resource.skip_personal_organization = true if pending_organization_invitation?
1036
+ end
1037
+ end
1038
+ ```
1039
+
1040
+ The `should_create_personal_organization?` method is the official extension seam. It evaluates:
1041
+ 1. Your method override (if defined)
1042
+ 2. The `create_personal_org` setting (boolean or proc)
1043
+ 3. The global `always_create_personal_organization_for_each_user` config
1044
+
1045
+ You can also use a proc for the setting itself:
1046
+
1047
+ ```ruby
1048
+ # In has_organizations block
1049
+ has_organizations do
1050
+ create_personal_org ->(user) { !user.skip_personal_organization }
1051
+ end
1052
+ ```
1053
+
1054
+ Best for: SaaS products that want instant onboarding for individual users but clean team onboarding for invited members.
1055
+
1056
+ ---
1057
+
891
1058
  ## Configuration
892
1059
 
893
1060
  Full configuration options:
@@ -79,6 +79,31 @@ module Organizations
79
79
  end
80
80
  end
81
81
 
82
+ # DELETE /memberships/leave
83
+ # Leave the current organization (current user removes themselves)
84
+ # Note: No permission guard needed — users can only leave themselves, and
85
+ # require_organization! ensures valid org context. Domain rules (can't leave
86
+ # as last owner, etc.) are enforced by leave_organization!.
87
+ def leave
88
+ org_name = current_organization.name
89
+
90
+ begin
91
+ current_user.leave_organization!(current_organization)
92
+
93
+ respond_to do |format|
94
+ format.html { redirect_to organizations_path, notice: "You have left #{org_name}." }
95
+ format.json { head :no_content }
96
+ end
97
+ rescue Models::Concerns::HasOrganizations::CannotLeaveLastOrganization,
98
+ Models::Concerns::HasOrganizations::CannotLeaveAsLastOwner,
99
+ Error => e
100
+ respond_to do |format|
101
+ format.html { redirect_back fallback_location: organizations_path, alert: e.message }
102
+ format.json { render json: { error: e.message }, status: :unprocessable_entity }
103
+ end
104
+ end
105
+ end
106
+
82
107
  # POST /memberships/:id/transfer_ownership
83
108
  # Transfer organization ownership to another member
84
109
  def transfer_ownership
data/config/routes.rb CHANGED
@@ -15,6 +15,9 @@ Organizations::Engine.routes.draw do
15
15
  member do
16
16
  post :transfer_ownership
17
17
  end
18
+ collection do
19
+ delete :leave
20
+ end
18
21
  end
19
22
 
20
23
  # Invitation management (scoped to current_organization)
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/organizations",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
Binary file
@@ -6,11 +6,33 @@ Organizations.configure do |config|
6
6
  # ============================================================================
7
7
 
8
8
  # Automatically create a personal organization when a user signs up.
9
- # The organization will be named after the user (e.g., "John's Organization").
9
+ # The organization will be named using default_organization_name (below).
10
10
  # Set to true if you want every user to have their own organization on signup.
11
11
  # Default: false (invite-to-join flow)
12
12
  # config.always_create_personal_organization_for_each_user = false
13
13
 
14
+ # NOTE: This is a coarse, global default. For conditional creation (e.g., skip
15
+ # personal org for invited signups), override should_create_personal_organization?
16
+ # on your User model instead:
17
+ #
18
+ # class User < ApplicationRecord
19
+ # has_organizations
20
+ # attr_accessor :skip_personal_organization
21
+ #
22
+ # def should_create_personal_organization?
23
+ # return false if skip_personal_organization
24
+ # super
25
+ # end
26
+ # end
27
+ #
28
+ # See "Pattern 4: Hybrid Onboarding" in the README for the full pattern.
29
+
30
+ # Name for auto-created organizations.
31
+ # Can be a string or a proc that receives the user.
32
+ # Default: "Personal"
33
+ # config.default_organization_name = "My Workspace"
34
+ # config.default_organization_name = ->(user) { "#{user.name}'s Workspace" }
35
+
14
36
  # ============================================================================
15
37
  # ORGANIZATION REQUIREMENTS
16
38
  # ============================================================================
@@ -33,10 +33,41 @@ module Organizations
33
33
  attr_accessor :authenticate_user_method
34
34
 
35
35
  # === Auto-creation ===
36
- # Create personal organization on user signup
36
+ #
37
+ # Create personal organization automatically on user signup.
38
+ #
39
+ # This setting controls the user's first-time experience:
40
+ #
41
+ # ┌─────────────────────────────────────────────────────────────────────────┐
42
+ # │ true → "Instant access" pattern │
43
+ # │ │
44
+ # │ User signs up → auto-created workspace → lands in app immediately │
45
+ # │ │
46
+ # │ Think: Notion, Slack, Trello │
47
+ # │ "Sign up and start using it in seconds" │
48
+ # │ │
49
+ # │ Best for: productivity tools, note apps, simple SaaS │
50
+ # └─────────────────────────────────────────────────────────────────────────┘
51
+ #
52
+ # ┌─────────────────────────────────────────────────────────────────────────┐
53
+ # │ false → "Guided onboarding" pattern │
54
+ # │ │
55
+ # │ User signs up → onboarding wizard → enters company info → dashboard │
56
+ # │ │
57
+ # │ Think: Stripe, HubSpot, enterprise B2B tools │
58
+ # │ "Tell us about your company before you start" │
59
+ # │ │
60
+ # │ Best for: B2B SaaS needing company details, billing info, etc. │
61
+ # └─────────────────────────────────────────────────────────────────────────┘
62
+ #
63
+ # Related settings:
64
+ # - default_organization_name: Name for auto-created orgs (only when true)
65
+ # - redirect_path_when_no_organization: Where to send users without an org
66
+ # - always_require_users_to_belong_to_one_organization: Prevent leaving last org
67
+ #
37
68
  attr_accessor :always_create_personal_organization_for_each_user
38
69
 
39
- # Name for auto-created organizations
70
+ # Name for auto-created organizations (only used when always_create_personal_organization_for_each_user = true)
40
71
  # Can be a String or a Proc/Lambda: ->(user) { "#{user.name}'s Workspace" }
41
72
  attr_accessor :default_organization_name
42
73
 
@@ -37,7 +37,8 @@ module Organizations
37
37
  # Enable organization support on this model
38
38
  # @param options [Hash] Configuration options
39
39
  # @option options [Integer, nil] :max_organizations Maximum orgs user can own
40
- # @option options [Boolean] :create_personal_org Create personal org on signup
40
+ # @option options [Boolean, Proc] :create_personal_org Create personal org on signup.
41
+ # Can be a boolean or a proc that receives the user instance for conditional creation.
41
42
  # @option options [Boolean] :require_organization Require user to have an org
42
43
  # @yield Configuration block using DSL
43
44
  def has_organizations(**options, &block)
@@ -111,9 +112,10 @@ module Organizations
111
112
  end
112
113
 
113
114
  def setup_personal_org_callback
114
- after_create :create_personal_organization_if_configured, if: -> {
115
- self.class.organization_settings[:create_personal_org]
116
- }
115
+ # Callback that creates personal organization.
116
+ # The condition is checked inside the callback to ensure it's evaluated
117
+ # at the right time with the correct instance state.
118
+ after_create :create_personal_organization_if_configured
117
119
  end
118
120
 
119
121
  def setup_owner_deletion_guard
@@ -136,7 +138,14 @@ module Organizations
136
138
  end
137
139
 
138
140
  # Enable/disable personal organization creation on signup
139
- # @param value [Boolean]
141
+ # @param value [Boolean, Proc] Boolean or proc that receives the user instance
142
+ #
143
+ # @example Boolean (backward-compatible)
144
+ # create_personal_org true
145
+ #
146
+ # @example Proc for conditional creation
147
+ # create_personal_org ->(user) { !user.skip_personal_organization }
148
+ #
140
149
  def create_personal_org(value)
141
150
  @settings[:create_personal_org] = value
142
151
  end
@@ -499,9 +508,41 @@ module Organizations
499
508
  org.send_invite_to!(email, invited_by: self, role: role)
500
509
  end
501
510
 
511
+ # Extension seam for personal organization creation.
512
+ # Override this method in your User model to implement custom logic.
513
+ #
514
+ # @return [Boolean] true if a personal organization should be created
515
+ #
516
+ # @example Skip personal org for invited signups
517
+ # class User < ApplicationRecord
518
+ # has_organizations
519
+ # attr_accessor :skip_personal_organization
520
+ #
521
+ # def should_create_personal_organization?
522
+ # return false if skip_personal_organization
523
+ # super
524
+ # end
525
+ # end
526
+ #
527
+ def should_create_personal_organization?
528
+ setting = self.class.organization_settings[:create_personal_org]
529
+
530
+ case setting
531
+ when Proc
532
+ setting.call(self)
533
+ else
534
+ !!setting
535
+ end
536
+ end
537
+
502
538
  private
503
539
 
504
540
  def create_personal_organization_if_configured
541
+ # Check condition inside callback (not via `if:` option) to ensure
542
+ # proper evaluation of instance state, including singleton methods
543
+ # and dynamically-set attributes.
544
+ return unless should_create_personal_organization?
545
+
505
546
  org_name = Organizations.configuration.resolve_default_organization_name(self)
506
547
  create_organization!(org_name)
507
548
  rescue StandardError => e
@@ -512,7 +553,7 @@ module Organizations
512
553
  def prevent_deletion_while_owning_organizations
513
554
  return unless memberships.where(role: "owner").exists?
514
555
 
515
- errors.add(:base, "Cannot delete a user who still owns organizations. Transfer ownership first.")
556
+ errors.add(:base, "Cannot delete a user who still owns organizations. Transfer ownership or delete those organizations first.")
516
557
  throw(:abort)
517
558
  end
518
559
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-17 00:00:00.000000000 Z
10
+ date: 2026-03-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -103,6 +103,11 @@ files:
103
103
  - app/views/organizations/invitation_mailer/invitation_email.html.erb
104
104
  - app/views/organizations/invitation_mailer/invitation_email.text.erb
105
105
  - config/routes.rb
106
+ - context7.json
107
+ - docs/organizations-invitation-accept-create-account.webp
108
+ - docs/organizations-member-permissions.webp
109
+ - docs/organizations-switcher.webp
110
+ - docs/organizations-team-members.webp
106
111
  - gemfiles/rails_7.2.gemfile
107
112
  - gemfiles/rails_8.1.gemfile
108
113
  - lib/generators/organizations/install/install_generator.rb