organizations 0.4.2 → 0.5.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +53 -0
  5. data/README.md +414 -46
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +27 -19
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
data/README.md CHANGED
@@ -7,9 +7,9 @@
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)**
10
+ <img src="docs/organizations-invitation-accept-create-account.webp" width="500" />
11
11
 
12
- https://github.com/user-attachments/assets/2eddafe2-025b-4670-af9f-e0d5480508c5
12
+ **🎮 [Try the live demo →](https://organizations.rameerez.com)**
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
 
@@ -41,6 +41,8 @@ current_user.is_organization_owner? # => true
41
41
  current_user.is_organization_admin? # => true (owners inherit admin permissions)
42
42
  ```
43
43
 
44
+ https://github.com/user-attachments/assets/2eddafe2-025b-4670-af9f-e0d5480508c5
45
+
44
46
  ## Installation
45
47
 
46
48
  Add to your Gemfile:
@@ -58,6 +60,7 @@ Then:
58
60
  bundle install
59
61
  rails g organizations:install
60
62
  rails db:migrate
63
+ rails g organizations:views # copies the reference views (see BYO-UI note below)
61
64
  ```
62
65
 
63
66
  Add `has_organizations` to your User model:
@@ -89,10 +92,29 @@ Mount the engine in your routes:
89
92
  mount Organizations::Engine => '/'
90
93
  ```
91
94
 
95
+ Don't want the whole engine UI? Declare which route groups to draw (devise_for-style) instead of shadowing routes:
96
+
97
+ ```ruby
98
+ # config/initializers/organizations.rb
99
+ config.engine_routes = { except: [:organizations] } # keep switching/memberships/invitations, no org CRUD
100
+ # or: config.engine_routes = { only: [:switching, :public_invitations] }
101
+ # Groups: :switching, :organizations, :memberships, :invitations, :public_invitations
102
+ ```
103
+
104
+ > [!WARNING]
105
+ > Excluding a group also removes its **route helpers** — audit anything that references them: a custom `on_no_organization` handler calling `new_organization_path` raises `NameError` once `:organizations` is excluded (the default `redirect_path_when_no_organization = "/organizations/new"` would 404 too — point it somewhere real). The reference views also link across groups (the memberships page links to `organization_path`), so hosts that keep some engine pages while excluding others should retheme the copied views accordingly.
106
+
107
+ If your account model isn't named `User`:
108
+
109
+ ```ruby
110
+ config.user_class = "Account" # set BEFORE the models are first referenced (initializers are fine)
111
+ # The install migrations reference `to_table: :users` — adjust them to your user table.
112
+ ```
113
+
92
114
  Done. Your app now has full organizations / teams support.
93
115
 
94
116
  > [!IMPORTANT]
95
- > **Bring Your Own UI (BYOU):** This gem provides all the building blocks — models, controllers, routes, helpers, and mailers — but intentionally **does not ship with views**. Views are too context-dependent (Tailwind vs Bootstrap, dark mode, your app's design system) to be one-size-fits-all. You'll need to create your own views in `app/views/organizations/`. For a complete working example, check out the demo app in [`test/dummy`](test/dummy/app/views/organizations/).
117
+ > **Bring Your Own UI (BYOU):** This gem provides all the building blocks — models, controllers, routes, helpers, and mailers — but intentionally **does not ship with views**. Views are too context-dependent (Tailwind vs Bootstrap, dark mode, your app's design system) to be one-size-fits-all. Run `rails g organizations:views` to copy the Tailwind reference views into `app/views/organizations/` and retheme them — they're yours. The copies use the `heroicon` helper (add `gem "heroicons"`, or swap the icon calls for your own set), and the organization page's owner-only Danger Zone renders only if you provide `app/views/shared/_danger_zone.html.erb` (copy the full-featured one from the dummy). For a complete working example (including the verified-joining screens the generator deliberately doesn't cover), check out the demo app in [`test/dummy`](test/dummy/).
96
118
 
97
119
  > [!NOTE]
98
120
  > This gem uses the term "organization", but the concept is the same as "team", "workspace", or "account". It's essentially just an umbrella under which users / members are organized. This gem works for all those use cases, in the same way. Just use whichever term fits your product best in your UI.
@@ -166,7 +188,7 @@ end
166
188
 
167
189
  > **Note:** This is an integration pattern, not built-in functionality. You implement the limit checks in your callbacks.
168
190
 
169
- If you're using [`pricing_plans`](https://github.com/rameerez/pricing_plans), you can limit how many members an organization can have based on their subscription using callbacks:
191
+ If you're using [`pricing_plans`](https://github.com/rameerez/pricing_plans), you can limit how many members an organization can have based on their effective pricing plan using callbacks:
170
192
 
171
193
  ```ruby
172
194
  # config/initializers/pricing_plans.rb
@@ -179,31 +201,173 @@ plan :growth do
179
201
  end
180
202
  ```
181
203
 
182
- Then hook into the `on_member_invited` callback to enforce limits. **This callback runs BEFORE the invitation is persisted**, so raising an error will block the invitation:
204
+ Then enforce the limit in **`on_member_joining` the membership gate**. It runs strictly, inside the creating transaction, immediately before a membership row is inserted, on EVERY join path: `add_member!`, invitation acceptance, join-request approval (which covers join codes, email domains, allowlists, and the account-email shortcut). Raising vetoes the join and rolls back cleanly:
183
205
 
184
206
  ```ruby
185
207
  # config/initializers/organizations.rb
186
208
  Organizations.configure do |config|
187
- config.on_member_invited do |ctx|
209
+ config.on_member_joining do |ctx|
188
210
  org = ctx.organization
189
- limit = org.current_plan.limit_for(:organization_members)
211
+
212
+ # The gate runs INSIDE the membership-creating transaction, so this row
213
+ # lock serializes concurrent joins to the same org (and refreshes
214
+ # member_count under the lock). Without it, two simultaneous joins can
215
+ # both read "one seat left" and overshoot the cap by one — take the lock
216
+ # when you sell hard seat counts; skip it for advisory/anti-abuse caps
217
+ # where an off-by-one under a race is acceptable.
218
+ org.lock!
219
+
220
+ limit = org.current_pricing_plan.limit_for(:organization_members)
190
221
 
191
222
  if limit && org.member_count >= limit
192
- raise Organizations::InvitationError, "Member limit reached. Please upgrade your plan."
223
+ raise Organizations::MembershipVetoed, "Member limit reached. Please upgrade your plan."
193
224
  end
194
225
  end
195
226
  end
196
227
  ```
197
228
 
198
- The `on_member_invited` callback is special — it runs in **strict mode**, meaning:
199
- - It executes **before** the invitation is saved to the database
200
- - Raising any error will **veto** the invitation (it won't be created)
201
- - The error message is returned to the user
229
+ > [!WARNING]
230
+ > **Do not enforce seat limits only in `on_member_invited`.** That hook guards the *invitation* path exclusively — the moment your app enables any verified-joining instrument (a join code, an email domain, an allowlist), invite-only enforcement is silently bypassable. `on_member_joining` is the one gate that covers every path. (Keeping an `on_member_invited` check *too* is nice UX — it rejects at invite time instead of accept time — but it's an optimization, not the enforcement.)
231
+
232
+ Details worth knowing:
233
+ - A vetoed join-request approval leaves the request **pending** (resumable — approve again after the upgrade); a vetoed invitation acceptance leaves the invitation pending too.
234
+ - A vetoed join-code redemption still **consumes a use**: uses are counted at redemption, before approval reaches the gate. `max_uses` is an anti-abuse cap, not a seat counter — seats live here, in the gate.
235
+ - The gate deliberately does NOT fire when an owner membership is created together with its organization (creating your own org is not "joining"), for idempotent already-a-member paths, or for role changes.
236
+ - `ctx` carries `organization`, `user`, `role`, `joined_via`, and the instrument (`invitation`/`join_request`) when one applies.
237
+ - Raising bare `Organizations::MembershipVetoed` uses a localized default message.
238
+
239
+ ## Verified joining: let users prove they belong
240
+
241
+ Invitations cover org→user. Since v0.5.0 the gem also covers **user→org**: people who claim to belong to an organization and need to prove it. Four mechanisms, all converging on a regular `Membership` with provenance stamped on it:
242
+
243
+ | Mechanism | Proof | Typical use |
244
+ |---|---|---|
245
+ | Request-to-join | a human approves | small teams, communities |
246
+ | Email domain | 6-digit code emailed to `anything@yourdomain.com` | companies, universities |
247
+ | Join code ("PIN") | possession of the code (± domain email) | posters, newsletters, classrooms |
248
+ | Allowlist / roster | 6-digit code emailed to a rostered address | clubs with no own domain |
249
+
250
+ **The key idea: the proven email is decoupled from the account email.** A user signed up with a personal Gmail can still prove they control `j.doe@acme.com` and join Acme as a verified member. The proven address is recorded on the membership (`verified_email`) and is **unique per organization** — one inbox, one member.
251
+
252
+ ```ruby
253
+ # ── Request-to-join (manual approval) ──────────────────────────────────────
254
+ request = user.request_to_join!(org, message: "I'm in the Madrid office")
255
+ org.pending_join_requests # approval queue
256
+ org.approve_join_request!(request, approved_by: admin) # => Membership
257
+ org.reject_join_request!(request, rejected_by: admin, reason: "unknown")
202
258
 
203
- This pattern gives you full control over how and when limits are enforced.
259
+ # ── Email domain ───────────────────────────────────────────────────────────
260
+ org.add_domain!("acme.com")
261
+ request = user.request_to_join!(org)
262
+ request.start_email_verification!(email: "j.doe@acme.com") # emails a 6-digit code
263
+ request.verify_email_code!("492817") # => Membership (auto-approved)
264
+
265
+ # Zero-friction shortcut when the ACCOUNT email is already confirmed
266
+ # (e.g. Devise :confirmable) and its domain is enrolled:
267
+ org.join_with_account_email!(user) # => Membership, no code needed
268
+
269
+ # ── Join code (PIN) ────────────────────────────────────────────────────────
270
+ code = org.generate_join_code!(label: "office poster", max_uses: 500,
271
+ expires_at: 3.months.from_now)
272
+ Organizations::JoinCode.redeem("7FHK-2MPX", user: user) # => Membership
273
+ code.revoke! # rotation = revoke + generate
274
+
275
+ # Reinforced mode: PIN + corporate email required (per-code!)
276
+ strict = org.generate_join_code!(requires_verified_domain_email: true)
277
+ request = Organizations::JoinCode.redeem(strict.code, user: user) # => JoinRequest
278
+ request.start_email_verification!(email: "j.doe@acme.com")
279
+ request.verify_email_code!("492817") # => Membership
280
+
281
+ # ── Allowlist / roster ─────────────────────────────────────────────────────
282
+ org.import_allowlist!(["ana@gmail.com", "luis@yahoo.es"], source: "csv_2026-07")
283
+ # Rostered users still complete the email challenge — a leaked roster grants
284
+ # nothing without inbox access. The entry is claimed on join.
285
+ ```
286
+
287
+ ### Cohorts without the gem knowing (`membership_metadata`)
288
+
289
+ Every join instrument (domain, code, allowlist entry, invitation) carries a `membership_metadata` hash that's merged onto memberships it creates. The gem never interprets it — it's your cohort/segmentation channel:
290
+
291
+ ```ruby
292
+ org.add_domain!("acme.com", membership_metadata: { member_kind: "employee" })
293
+ org.add_domain!("students.acme.edu", membership_metadata: { member_kind: "student" })
294
+ # later…
295
+ membership.metadata["member_kind"] # => "student"
296
+ membership.joined_via # => "domain_email"
297
+ membership.verified_email # => "x@students.acme.edu"
298
+ membership.verified? # => true
299
+ ```
300
+
301
+ ### Building the join UI: `JoinFlow` + `JoinState`
302
+
303
+ The models above are the exception-raising programmatic layer. For controllers and views, use the headless join kit — one call in, one state out, no rescue ladders:
304
+
305
+ ```ruby
306
+ # The controller: every join input goes through ONE facade
307
+ result = Organizations::JoinFlow.attempt(
308
+ user: current_user, organization: @org,
309
+ code: params[:code], # a join code (PIN), if typed
310
+ email: params[:email], # start the emailed challenge
311
+ verification_code: params[:verification_code], # the typed 6-digit code
312
+ message: params[:message] # note for manual approval
313
+ )
314
+ result.outcome # :member | :challenge_sent | :pending | :vetoed | :error
315
+ result.reason # stable symbol from JoinFlow::REASONS (map your copy off this)
316
+ result.message # localized human message (rides the i18n catalog)
317
+ result.membership / result.join_request
318
+
319
+ # The view: one object decides which screen state renders
320
+ @state = Organizations::JoinState.for(user: current_user, organization: @org, result: result)
321
+ @state.status # :member | :verifying | :pending | :entry
322
+ @state.resend_seconds # cooldown for the "resend code" button, derived from config
323
+ @state.error_message # localized message of a just-failed action
324
+
325
+ # Which entry forms to show — the org's actual instruments decide:
326
+ org.accepts_domain_joining? # email form (domains or unclaimed allowlist entries)
327
+ org.accepts_code_joining? # code form (at least one actually-redeemable code)
328
+ org.accepts_join_requests? # anything at all
329
+ ```
330
+
331
+ **Reference UI to copy:** the demo app ships a complete verified-joining implementation — the four-state join screen ([`test/dummy/app/controllers/joins_controller.rb`](test/dummy/app/controllers/joins_controller.rb) + [view](test/dummy/app/views/joins/show.html.erb)) and the org-admin Access surface (domains/codes/allowlist + request queue, [`access_controller.rb`](test/dummy/app/controllers/access_controller.rb)). Copy them into your app and retheme; their headers carry the production checklist.
332
+
333
+ ### Rate limiting your join endpoints
334
+
335
+ The gem deliberately ships no join routes/controllers, so it cannot rate-limit them for you — and **code redemption and code verification are enumeration surfaces**. With Rails' built-in [`rate_limit`](https://api.rubyonrails.org/classes/ActionController/RateLimiting.html):
336
+
337
+ ```ruby
338
+ class JoinsController < ApplicationController
339
+ # Joining/redeeming: per-user, generous but bounded
340
+ rate_limit to: 10, within: 1.hour, by: -> { current_user.id }, only: :create
341
+ # Public landings (if any are unauthenticated): per-IP against slug/code enumeration
342
+ # rate_limit to: 60, within: 1.minute, by: -> { request.remote_ip }, only: :show
343
+ end
344
+ ```
345
+
346
+ Layer them per surface: the gem already throttles **per request** (60s resend interval, 5 sends, 5 attempts per challenge); your controller limits cap **per user/IP across requests** — mail-bombing and brute-force across fresh challenges. And keep the generic error copy for unknown codes: `JoinFlow` already collapses unknown/revoked/expired/foreign codes into one reason — don't "improve" it into an oracle. (One narrow residual the rate limits also cover: an expired org-scoped code does slightly more work than the fast-reject paths before raising - identical response shape, but a measurable timing side-channel without endpoint limits.)
347
+
348
+ ### Security posture
349
+
350
+ - Codes are stored as **SHA-256 digests only** (peppered by row id), compared in constant time, single-use, expire after `verification_code_ttl` (15 min default), capped at `verification_max_attempts` (5) with resend throttles.
351
+ - Emails are normalized before the uniqueness check (case, whitespace, and `+tag` plus-addressing collapse — override via `config.verification_email_normalizer`).
352
+ - Domain matching is **exact** and evasion-hardened: `acme.com.evil.com`, `evilacme.com`, multi-`@` shapes never match; subdomains are separate domains on purpose (they often mean different cohorts).
353
+ - BYO-UI as always: the gem ships models/APIs/mailers and state; **you must rate-limit your join/redemption/verification endpoints** (see above).
354
+ - Hard member caps belong in **`on_member_joining`** (the strict membership gate) — the `on_join_request_*` after-callbacks are error-isolated and cannot veto. Take `ctx.organization.lock!` inside the gate when the cap must hold under concurrent joins (see "Limit seats per plan").
355
+ - **`max_uses` is an anti-abuse cap, not a seat counter**: join-code uses are counted at redemption, so a redemption later vetoed by the gate still consumed a use.
356
+ - One deliberate exception to the one-generic-reason rule: an **exhausted** code surfaces its own reason (`:join_code_exhausted`), so legitimate holders learn the code ran out instead of retyping it forever. This confirms such a code exists — if that trade-off bothers you, render it with the same copy as `:join_code_invalid`.
357
+ - Verification-email delivery failures are handled: the gem rolls the throttle bookkeeping back (immediate retry, the failed send doesn't count) and fires `on_verification_delivery_failed` — wire it to your error tracker so mail outages are visible:
358
+
359
+ ```ruby
360
+ config.on_verification_delivery_failed do |ctx|
361
+ Rails.error.report(RuntimeError.new("Verification email failed: #{ctx.metadata["error_message"]}"),
362
+ context: { join_request_id: ctx.join_request.id })
363
+ end
364
+ ```
365
+
366
+ Upgrading an existing install: `rails g organizations:upgrade && rails db:migrate` (additive only).
204
367
 
205
368
  ## Why this gem exists
206
369
 
370
+
207
371
  Organizations / teams are tough to do alone. Wiring up accounts, roles, and invites by hand is a pain you only want to go through once. If you don't implement organizations / teams on day one, adding them later becomes a major refactor — the kind that touches every model, controller, and permission in your app. Even experienced Rails developers have built accounts / teams poorly multiple times before getting it right.
208
372
 
209
373
  No more asking yourself "should I just roll my own?" No more stitching together `acts_as_tenant` + `rolify` + `devise_invitable` + `pundit` and writing 500 lines of glue code. No more paying $250/year for a boilerplate template just because it has organizations / teams built in. The `organizations` gem gives you everything in a single `bundle add`.
@@ -319,6 +483,32 @@ org.memberships.owners # Memberships with owner role
319
483
  org.memberships.admins # Memberships with admin role
320
484
  org.invitations.pending # Not yet accepted
321
485
  org.invitations.expired # Past expiration date
486
+
487
+ # Creation (ops/provisioning primitive: no session switching, no per-user
488
+ # cap, fires on_organization_created — for consoles, seeds, admin panels)
489
+ Organizations::Organization.create_with_owner!(owner: admin, name: "Acme Corp")
490
+
491
+ # Verified joining
492
+ org.domains / org.join_codes / org.allowlist_entries / org.join_requests
493
+ org.add_domain!("acme.com", membership_metadata: {})
494
+ org.generate_join_code!(label:, requires_verified_domain_email:, auto_approve:, expires_at:, max_uses:)
495
+ org.import_allowlist!(emails, source:, membership_metadata: {})
496
+ org.approve_join_request!(req, approved_by:) / org.reject_join_request!(req, rejected_by:, reason:)
497
+ org.join_with_account_email!(user)
498
+ org.accepts_domain_joining? # email form? (domains or unclaimed roster entries)
499
+ org.accepts_code_joining? # code form? (at least one actually-redeemable code)
500
+ org.accepts_join_requests? # any self-serve mechanism at all
501
+ org.join_codes.active # SQL twin of code.active?
502
+
503
+ # The join kit (controller/view layer — see "Verified joining")
504
+ Organizations::JoinFlow.attempt(user:, organization:, code:, email:, verification_code:, message:)
505
+ Organizations::JoinState.for(user:, organization:, result:)
506
+
507
+ # Housekeeping
508
+ Organizations::JoinRequest.purge_stale!(older_than: 12.months) # GDPR sweep: old decided/expired requests
509
+
510
+ # Typed boolean toggles over the metadata bag (also on Membership)
511
+ Organizations::Organization.metadata_flag :beta_features, default: false
322
512
  ```
323
513
 
324
514
  ### Membership methods
@@ -434,6 +624,8 @@ class SettingsController < ApplicationController
434
624
  end
435
625
  ```
436
626
 
627
+ `manage_billing` and `view_billing` are authorization checks only. They control who in the organization can access your billing UI, but they do not imply an active Stripe subscription or determine the effective pricing plan.
628
+
437
629
  ### Handling unauthorized access
438
630
 
439
631
  Configure how unauthorized access is handled:
@@ -795,6 +987,9 @@ Users can belong to multiple organizations. The "current" organization is stored
795
987
  2. User switches org → Session updated, `current_organization` changes
796
988
  3. User is removed from current org → Auto-switches to next available org
797
989
 
990
+ > [!WARNING]
991
+ > **The session is the source of truth — always read `current_organization` through the CONTROLLER helper.** The model-level `user.current_organization` reads a per-request attribute that is only seeded *by* that controller helper; called anywhere else (a background job, a view that bypassed the helper, a console) it silently falls back to the user's first organization no matter what they switched to. This burned a production host's org switcher on first render: reading the model in the view showed the first org forever. In jobs/services, pass the organization explicitly.
992
+
798
993
  ### Manual switching
799
994
 
800
995
  ```ruby
@@ -1102,7 +1297,10 @@ Organizations.configure do |config|
1102
1297
 
1103
1298
  # === Invitation Flow Redirects ===
1104
1299
  # Where to redirect unauthenticated users when they try to accept an invitation
1105
- # Default: nil (uses new_user_registration_path or root_path)
1300
+ # Default: nil = known-user promotion (since 0.5.0): if the invited address
1301
+ # already has an account, sign-IN (new_user_session_path); otherwise
1302
+ # sign-UP (new_user_registration_path), falling back to root_path.
1303
+ # Set this to opt out of the lookup.
1106
1304
  config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
1107
1305
  # Or use a Proc: ->(invitation, user) { "/signup?invite=#{invitation.token}" }
1108
1306
 
@@ -1215,7 +1413,7 @@ end
1215
1413
 
1216
1414
  ### Integrates with pricing_plans
1217
1415
 
1218
- Enforce member limits based on pricing plans using callbacks:
1416
+ Enforce member limits based on the effective pricing plan using callbacks:
1219
1417
 
1220
1418
  ```ruby
1221
1419
  # In your Organization model
@@ -1300,10 +1498,15 @@ end
1300
1498
  |----------|----------------|------|
1301
1499
  | `on_organization_created` | `organization`, `user` | After |
1302
1500
  | `on_member_invited` | `organization`, `invitation`, `invited_by` | **Before (strict)** |
1501
+ | `on_member_joining` | `organization`, `user`, `role`, `joined_via`, `invitation`/`join_request` | **Before (strict)** — THE membership gate; fires pre-persist on every join path |
1303
1502
  | `on_member_joined` | `organization`, `membership`, `user` | After |
1304
1503
  | `on_member_removed` | `organization`, `membership`, `user`, `removed_by` | After |
1305
1504
  | `on_role_changed` | `organization`, `membership`, `old_role`, `new_role`, `changed_by` | After |
1306
1505
  | `on_ownership_transferred` | `organization`, `old_owner`, `new_owner` | After |
1506
+ | `on_join_request_created` | `organization`, `user`, `join_request` | After |
1507
+ | `on_join_request_approved` | `organization`, `user`, `join_request`, `membership`, `decided_by` (nil for auto) | After |
1508
+ | `on_join_request_rejected` | `organization`, `user`, `join_request`, `decided_by` | After |
1509
+ | `on_verification_delivery_failed` | `organization`, `user`, `join_request`, `metadata` (`error_class`/`error_message`) | After |
1307
1510
 
1308
1511
  **Callback modes:**
1309
1512
  - **After**: Runs after the action completes. Errors are logged but don't block the operation. Use for notifications, analytics, and audit logs.
@@ -1345,6 +1548,13 @@ john_at_acme:
1345
1548
  sign_in_as_organization_member(user, org, role: :admin)
1346
1549
  set_current_organization(org)
1347
1550
 
1551
+ # Complete the emailed-code flow without intercepting mail: force a KNOWN
1552
+ # plaintext code onto the request's challenge (the DB only stores digests —
1553
+ # don't reverse-engineer the digest recipe in your tests)
1554
+ request.start_email_verification!(email: "j.doe@acme.com")
1555
+ code = issue_verification_code(request) # => "424242"
1556
+ request.verify_email_code!(code) # => Membership
1557
+
1348
1558
  # Or manually
1349
1559
  sign_in user
1350
1560
  switch_to_organization!(org)
@@ -1362,7 +1572,7 @@ assert user.belongs_to_any_organization?
1362
1572
 
1363
1573
  ## Extending the Organization model
1364
1574
 
1365
- The gem provides `Organizations::Organization` as the base model. You can extend it with your app's specific fields by adding migrations and reopening the class:
1575
+ The gem provides `Organizations::Organization` as the base model. Extend it with your app's specific fields by adding migrations (host columns on gem tables are the sanctioned path) and registering an extension via **load hooks**:
1366
1576
 
1367
1577
  ```ruby
1368
1578
  # db/migrate/xxx_add_custom_fields_to_organizations.rb
@@ -1376,43 +1586,91 @@ end
1376
1586
  ```
1377
1587
 
1378
1588
  ```ruby
1379
- # config/initializers/organization_extensions.rb
1380
- # Or: app/models/concerns/organization_extensions.rb (then include in initializer)
1589
+ # app/models/concerns/organization_extensions.rb
1590
+ module OrganizationExtensions
1591
+ extend ActiveSupport::Concern
1381
1592
 
1382
- Organizations::Organization.class_eval do
1383
- # Add your own associations
1384
- has_many :projects
1385
- has_many :documents
1386
-
1387
- # Add your own validations
1388
- validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
1593
+ included do
1594
+ has_many :projects
1595
+ has_many :documents
1596
+ validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
1597
+ end
1389
1598
 
1390
- # Add your own methods
1391
1599
  def active_projects
1392
1600
  projects.where(archived: false)
1393
1601
  end
1394
1602
  end
1395
1603
  ```
1396
1604
 
1605
+ ```ruby
1606
+ # config/initializers/organizations.rb (top of the file, outside the configure block)
1607
+ ActiveSupport.on_load(:organizations_organization) do
1608
+ include OrganizationExtensions # `self` is Organizations::Organization
1609
+ end
1610
+ ActiveSupport.on_load(:organizations_membership) do
1611
+ # typed boolean toggles over the metadata bag, with defaults:
1612
+ metadata_flag :show_on_profile, default: true
1613
+ end
1614
+ ```
1615
+
1616
+ **Why load hooks and not `class_eval` in an initializer:** the gem's model classes are reload-safe under Zeitwerk (they reload in development), and an initializer runs ONCE — a bare `class_eval` patch evaporates on the first code reload, and a hand-rolled `to_prepare` block is one shared basket where a single bad constant silently kills every extension after it. Load hooks re-fire per model on every reload ([ActiveSupport::LazyLoadHooks](https://api.rubyonrails.org/classes/ActiveSupport/LazyLoadHooks.html)). One hook per model: `:organizations_organization`, `:organizations_membership`, `:organizations_invitation`, `:organizations_domain`, `:organizations_join_code`, `:organizations_allowlist_entry`, `:organizations_join_request`, plus `:organizations_application_controller` and `:organizations_public_controller` for the engine controllers.
1617
+
1618
+ For the common public-controller case — host layouts on the invitation-acceptance pages need your app's helper modules (the public controller inherits `ActionController::Base`, not your `ApplicationController`) — use the declarative option instead:
1619
+
1620
+ ```ruby
1621
+ config.public_controller_helpers = ["ApplicationHelper", "PageHelper"]
1622
+ ```
1623
+
1397
1624
  Alternatively, create your own model that inherits from the gem's model:
1398
1625
 
1399
1626
  ```ruby
1400
1627
  # app/models/organization.rb
1401
1628
  class Organization < Organizations::Organization
1402
1629
  has_many :projects
1403
- has_many :documents
1404
-
1405
- validates :support_email, presence: true
1406
1630
  end
1407
1631
  ```
1408
1632
 
1409
1633
  > **Note:** If you create your own `Organization` class, be aware that internal gem code uses `Organizations::Organization`. Your subclass will work for your app code, but associations from `User#organizations` will return `Organizations::Organization` instances.
1410
1634
 
1411
- This is standard Rails practice — the gem provides the foundation (memberships, invitations, roles), your app extends it with domain-specific features.
1635
+ This is standard Rails practice — the gem provides the foundation (memberships, invitations, roles, verified joining), your app extends it with domain-specific features.
1636
+
1637
+ ## URL-scoped organizations (`OrganizationScoped`)
1638
+
1639
+ The engine's controllers are **session-scoped**: one "current" organization per user, workspace-style. Overlay-style apps (communities, marketplaces — users *belong to* orgs but don't "work inside" one) address organizations **by URL** instead: `/org/:slug/admin`. For those surfaces, include the concern:
1640
+
1641
+ ```ruby
1642
+ class Portal::BaseController < ApplicationController
1643
+ include Organizations::OrganizationScoped
1644
+
1645
+ self.organization_param = :slug
1646
+ self.organization_finder = ->(param) { Organizations::Organization.find_by(slug: param) }
1647
+ require_organization_role :admin # gate every action behind a minimum role
1648
+ end
1649
+ ```
1650
+
1651
+ You get `current_scoped_organization` / `current_scoped_membership` (also as view helpers), and a configurable not-found posture: the default raises `ActionController::RoutingError`, so **unknown orgs, strangers, and under-role members are indistinguishable** — no existence oracle, the right stance for customer-facing portals. Set `self.organization_not_found_behavior = :forbidden` for internal tools where a 403 is fine. Both addressing modes coexist in one app — the concern never touches the session context.
1652
+
1653
+ ## Internationalization (i18n)
1654
+
1655
+ Every user-facing string the gem produces — error messages, validation messages, role/status labels, invitation-flow notices, the stock mailer copy — resolves through locale files under the `organizations.` namespace. **English and Spanish ship with the gem**; add or override any key in your app's locale files (app locale files load after engine ones, so yours win):
1656
+
1657
+ ```yaml
1658
+ # config/locales/en.yml (host app) — override just what you want
1659
+ en:
1660
+ organizations:
1661
+ roles:
1662
+ admin: "Manager"
1663
+ errors:
1664
+ join_code_invalid: "Hmm, that code doesn't work."
1665
+ ```
1666
+
1667
+ Two deliberate boundaries: developer-facing errors (`ArgumentError`, `ConfigurationError`) stay English, and custom roles defined via `config.roles` fall back to `humanize` unless you add a matching `organizations.roles.<name>` key.
1412
1668
 
1413
1669
  ## Database schema
1414
1670
 
1415
- The gem creates three tables:
1671
+ The gem creates **seven tables** (three core + four for verified joining, all created by `organizations:install`; existing installs get the four via `organizations:upgrade`):
1672
+
1673
+ > **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables, and emits adapter-aware DDL (partial unique indexes on PostgreSQL/SQLite, generated-column emulation on MySQL).
1416
1674
 
1417
1675
  ### organizations_organizations
1418
1676
 
@@ -1420,13 +1678,11 @@ The gem creates three tables:
1420
1678
  organizations_organizations
1421
1679
  - id (primary key, auto-detects UUID or integer from your app)
1422
1680
  - name (string, required)
1681
+ - memberships_count (integer, counter cache, default: 0)
1423
1682
  - metadata (jsonb, default: {})
1424
- - created_at
1425
- - updated_at
1683
+ - created_at / updated_at
1426
1684
  ```
1427
1685
 
1428
- > **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
1429
-
1430
1686
  ### organizations_memberships
1431
1687
 
1432
1688
  ```sql
@@ -1436,10 +1692,17 @@ organizations_memberships
1436
1692
  - organization_id (foreign key, indexed)
1437
1693
  - role (string, default: 'member')
1438
1694
  - invited_by_id (foreign key, nullable)
1439
- - created_at
1440
- - updated_at
1695
+ - joined_via (string, nullable: invited|code|domain_email|allowlist|manual)
1696
+ - verified_email / verified_email_normalized (string, nullable)
1697
+ - verified_at (datetime, nullable)
1698
+ - metadata (jsonb, default: {})
1699
+ - created_at / updated_at
1441
1700
 
1442
1701
  unique index: [user_id, organization_id]
1702
+ partial unique index: [organization_id] where role = 'owner' (single owner; pg/sqlite —
1703
+ generated-column emulation on MySQL)
1704
+ unique index: [organization_id, verified_email_normalized] (one proven inbox = one member;
1705
+ plain composite — NULLs never collide, so no WHERE clause is needed)
1443
1706
  ```
1444
1707
 
1445
1708
  ### organizations_invitations
@@ -1454,10 +1717,70 @@ organizations_invitations
1454
1717
  - invited_by_id (foreign key, nullable)
1455
1718
  - accepted_at (datetime, nullable)
1456
1719
  - expires_at (datetime)
1457
- - created_at
1458
- - updated_at
1720
+ - metadata / membership_metadata (jsonb, default: {})
1721
+ - created_at / updated_at
1459
1722
 
1460
- unique index: [organization_id, email] where accepted_at is null
1723
+ partial unique index: [organization_id, lower(email)] where accepted_at is null
1724
+ ```
1725
+
1726
+ ### organizations_domains
1727
+
1728
+ ```sql
1729
+ organizations_domains
1730
+ - id, organization_id (foreign key)
1731
+ - domain (string, required — stored lowercased, no leading '@')
1732
+ - membership_metadata / metadata (jsonb, default: {})
1733
+ - created_at / updated_at
1734
+
1735
+ unique index: [organization_id, domain]; index: [domain] (email → candidate orgs)
1736
+ ```
1737
+
1738
+ ### organizations_join_codes
1739
+
1740
+ ```sql
1741
+ organizations_join_codes
1742
+ - id, organization_id (foreign key)
1743
+ - code (string, globally unique)
1744
+ - label (string, nullable — campaign attribution)
1745
+ - requires_verified_domain_email (boolean, default: false)
1746
+ - auto_approve (boolean, default: true)
1747
+ - expires_at (datetime, nullable) / max_uses (integer, nullable) / uses_count (integer, default: 0)
1748
+ - revoked_at (datetime, nullable)
1749
+ - created_by_id (foreign key, nullable)
1750
+ - membership_metadata / metadata (jsonb, default: {})
1751
+ - created_at / updated_at
1752
+ ```
1753
+
1754
+ ### organizations_allowlist_entries
1755
+
1756
+ ```sql
1757
+ organizations_allowlist_entries
1758
+ - id, organization_id (foreign key)
1759
+ - email (string, as provided) / email_normalized (string)
1760
+ - source (string, nullable — e.g. "csv_2026-07")
1761
+ - claimed_at (datetime, nullable) / claimed_by_id (foreign key, nullable)
1762
+ - membership_metadata / metadata (jsonb, default: {})
1763
+ - created_at / updated_at
1764
+
1765
+ unique index: [organization_id, email_normalized]
1766
+ ```
1767
+
1768
+ ### organizations_join_requests
1769
+
1770
+ ```sql
1771
+ organizations_join_requests
1772
+ - id, organization_id (foreign key), user_id (foreign key)
1773
+ - status (string, default: 'pending': pending|approved|rejected|withdrawn; :expired is derived)
1774
+ - joined_via (string, nullable) / join_code_id (foreign key, nullable) / message (string, nullable)
1775
+ - verification_email / verification_email_normalized (string, nullable)
1776
+ - verification_code_digest (string, nullable — SHA-256 only, never plaintext)
1777
+ - verification_sent_at / verification_expires_at (datetime) / verified_at (datetime)
1778
+ - verification_attempts / verification_sends_count (integer, default: 0)
1779
+ - decided_by_id (foreign key, nullable) / decided_at (datetime) / expires_at (datetime)
1780
+ - metadata (jsonb, default: {})
1781
+ - created_at / updated_at
1782
+
1783
+ partial unique index: [organization_id, user_id] where status = 'pending'
1461
1784
  ```
1462
1785
 
1463
1786
  ## Ownership rules
@@ -1619,8 +1942,12 @@ These constraints prevent duplicate data at the database level:
1619
1942
  | Constraint | Purpose |
1620
1943
  |------------|---------|
1621
1944
  | `memberships [user_id, organization_id]` | User can only have one membership per org |
1622
- | `invitations [organization_id, email] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |
1945
+ | `memberships [organization_id] WHERE role = 'owner'` | Exactly one owner per org |
1946
+ | `memberships [organization_id, verified_email_normalized]` | One proven inbox = one member per org |
1947
+ | `invitations [organization_id, LOWER(email)] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |
1623
1948
  | `invitations [token]` | Invitation tokens are globally unique |
1949
+ | `join_requests [organization_id, user_id] WHERE status = 'pending'` | One open join request per user per org |
1950
+ | `join_codes [code]` | Join codes are globally unique |
1624
1951
 
1625
1952
  ### Row-level locking
1626
1953
 
@@ -1746,15 +2073,56 @@ CREATE INDEX index_organizations_invitations_on_email ON organizations_invitatio
1746
2073
  CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
1747
2074
  ```
1748
2075
 
1749
- ## Migration from 1:1 relationships
2076
+ ## Migrating from a hand-rolled Organization model
2077
+
2078
+ Adopting the gem in an app that already has its own `Organization` model is a well-trodden path — one production host migrated a live SaaS (billing, API keys, usage credits, all tenant-scoped) onto the gem with zero downtime. The distilled playbook, as **four ordered migrations**:
2079
+
2080
+ ```ruby
2081
+ # 1. Park the legacy table (keep it around until you're confident)
2082
+ class BackupOrganizationsForGem < ActiveRecord::Migration[8.0]
2083
+ def change
2084
+ # Drop FKs that point INTO the legacy table first, then:
2085
+ rename_table :organizations, :organizations_legacy
2086
+ end
2087
+ end
2088
+
2089
+ # 2. Create the gem tables — vendor the install generator's migration:
2090
+ # rails g organizations:install (take the migration, skip the initializer if you have one)
2091
+
2092
+ # 3. Re-add YOUR columns onto the gem's organizations table
2093
+ class AddAppColumnsToOrganizations < ActiveRecord::Migration[8.0]
2094
+ def change
2095
+ add_column :organizations_organizations, :support_email, :string
2096
+ # ...every host column your legacy table had
2097
+ end
2098
+ end
2099
+
2100
+ # 4. Copy the data — the load-bearing details:
2101
+ class MigrateOrganizationData < ActiveRecord::Migration[8.0]
2102
+ def up
2103
+ # a) Copy legacy rows PRESERVING IDs (every FK in your app keeps working).
2104
+ # b) Create memberships: pick each org's owner deterministically
2105
+ # (e.g. earliest user) — the gem enforces exactly one owner.
2106
+ # c) Backfill memberships_count.
2107
+ # d) ⚠️ REWRITE POLYMORPHIC TYPE STRINGS: every table storing
2108
+ # "Organization" in a *_type column must now say
2109
+ # "Organizations::Organization" — check pay (customers, merchants),
2110
+ # pricing_plans (assignments, enforcement_states, usages),
2111
+ # api_keys, usage_credits (wallets, fulfillments), and your own
2112
+ # polymorphic associations. This is the step everyone forgets.
2113
+ # e) Re-add + validate the FKs you dropped in step 1.
2114
+ end
2115
+ end
2116
+ ```
1750
2117
 
1751
- If your app currently has `User belongs_to :organization` (1:1), migrate to `User has_many :organizations, through: :memberships` by backfilling memberships and removing direct `organization_id` dependencies incrementally.
2118
+ Afterwards: `has_organizations` on `User`, move your model code into an extension concern (see "Extending the Organization model"), and keep a `users.organization_id`-style pointer only if you need a "last active org" fallback. If you previously had `User belongs_to :organization` (1:1), the same playbook applies the memberships you create in step 4b simply all have one member.
1752
2119
 
1753
2120
  ## Roadmap
1754
2121
 
1755
- - [ ] Domain-based auto-join (users with @acme.com auto-join Acme org)
1756
- - [ ] Bulk invitations (CSV upload)
1757
- - [ ] Request-to-join workflow
2122
+ - [x] Domain-based joining (users prove control of an @acme.com inbox shipped in 0.5.0 as part of verified joining)
2123
+ - [x] Request-to-join workflow (shipped in 0.5.0)
2124
+ - [x] Roster/allowlist bulk import (shipped in 0.5.0; covers the bulk-invite use case via `import_allowlist!`)
2125
+ - [ ] Bulk invitations (CSV upload of token invitations)
1758
2126
  - [ ] Organization-level audit logs
1759
2127
  - [ ] Team hierarchies within organizations
1760
2128