organizations 0.4.3 → 0.6.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/.rubocop.yml +8 -9
- data/.rubocop_todo.yml +625 -0
- data/Appraisals +2 -0
- data/CHANGELOG.md +65 -0
- data/README.md +433 -41
- data/app/controllers/organizations/application_controller.rb +4 -0
- data/app/controllers/organizations/memberships_controller.rb +3 -3
- data/app/controllers/organizations/public_controller.rb +26 -0
- data/app/mailers/organizations/invitation_mailer.rb +38 -18
- data/app/mailers/organizations/verification_mailer.rb +56 -0
- data/app/models/organizations/allowlist_entry.rb +6 -0
- data/app/models/organizations/domain.rb +6 -0
- data/app/models/organizations/join_code.rb +6 -0
- data/app/models/organizations/join_request.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
- data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
- data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
- data/config/locales/en.yml +158 -0
- data/config/locales/es.yml +126 -0
- data/config/routes.rb +46 -28
- data/gemfiles/rails_7.2.gemfile +4 -3
- data/gemfiles/rails_8.1.gemfile +3 -3
- data/lib/generators/organizations/install/install_generator.rb +3 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
- data/lib/generators/organizations/install/templates/initializer.rb +53 -0
- data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
- data/lib/generators/organizations/views/views_generator.rb +47 -0
- data/lib/organizations/callback_context.rb +9 -0
- data/lib/organizations/callbacks.rb +26 -18
- data/lib/organizations/configuration.rb +278 -3
- data/lib/organizations/controller_helpers.rb +38 -12
- data/lib/organizations/email_normalizer.rb +65 -0
- data/lib/organizations/join_flow.rb +227 -0
- data/lib/organizations/join_state.rb +117 -0
- data/lib/organizations/metadata_flags.rb +79 -0
- data/lib/organizations/models/allowlist_entry.rb +88 -0
- data/lib/organizations/models/concerns/has_organizations.rb +100 -12
- data/lib/organizations/models/domain.rb +85 -0
- data/lib/organizations/models/invitation.rb +108 -18
- data/lib/organizations/models/join_code.rb +242 -0
- data/lib/organizations/models/join_request.rb +627 -0
- data/lib/organizations/models/membership.rb +48 -18
- data/lib/organizations/models/organization.rb +317 -19
- data/lib/organizations/organization_scoped.rb +135 -0
- data/lib/organizations/roles.rb +143 -36
- data/lib/organizations/test_helpers.rb +35 -1
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +8 -12
- data/lib/organizations.rb +165 -0
- metadata +34 -2
data/README.md
CHANGED
|
@@ -60,6 +60,7 @@ Then:
|
|
|
60
60
|
bundle install
|
|
61
61
|
rails g organizations:install
|
|
62
62
|
rails db:migrate
|
|
63
|
+
rails g organizations:views # copies the reference views (see BYO-UI note below)
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
Add `has_organizations` to your User model:
|
|
@@ -91,10 +92,29 @@ Mount the engine in your routes:
|
|
|
91
92
|
mount Organizations::Engine => '/'
|
|
92
93
|
```
|
|
93
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
|
+
|
|
94
114
|
Done. Your app now has full organizations / teams support.
|
|
95
115
|
|
|
96
116
|
> [!IMPORTANT]
|
|
97
|
-
> **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.
|
|
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/).
|
|
98
118
|
|
|
99
119
|
> [!NOTE]
|
|
100
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.
|
|
@@ -181,31 +201,173 @@ plan :growth do
|
|
|
181
201
|
end
|
|
182
202
|
```
|
|
183
203
|
|
|
184
|
-
Then
|
|
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:
|
|
185
205
|
|
|
186
206
|
```ruby
|
|
187
207
|
# config/initializers/organizations.rb
|
|
188
208
|
Organizations.configure do |config|
|
|
189
|
-
config.
|
|
209
|
+
config.on_member_joining do |ctx|
|
|
190
210
|
org = ctx.organization
|
|
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
|
+
|
|
191
220
|
limit = org.current_pricing_plan.limit_for(:organization_members)
|
|
192
221
|
|
|
193
222
|
if limit && org.member_count >= limit
|
|
194
|
-
raise Organizations::
|
|
223
|
+
raise Organizations::MembershipVetoed, "Member limit reached. Please upgrade your plan."
|
|
195
224
|
end
|
|
196
225
|
end
|
|
197
226
|
end
|
|
198
227
|
```
|
|
199
228
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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")
|
|
258
|
+
|
|
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
|
|
204
349
|
|
|
205
|
-
|
|
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).
|
|
206
367
|
|
|
207
368
|
## Why this gem exists
|
|
208
369
|
|
|
370
|
+
|
|
209
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.
|
|
210
372
|
|
|
211
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`.
|
|
@@ -321,6 +483,32 @@ org.memberships.owners # Memberships with owner role
|
|
|
321
483
|
org.memberships.admins # Memberships with admin role
|
|
322
484
|
org.invitations.pending # Not yet accepted
|
|
323
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
|
|
324
512
|
```
|
|
325
513
|
|
|
326
514
|
### Membership methods
|
|
@@ -616,6 +804,34 @@ Organizations.configure do |config|
|
|
|
616
804
|
end
|
|
617
805
|
```
|
|
618
806
|
|
|
807
|
+
### Add custom roles
|
|
808
|
+
|
|
809
|
+
Need a role that is not a step on the owner > admin > member > viewer ladder — a `client` who sees and answers for their own records, an `auditor`, a `guest`? Define it in the same block. A custom role inherits from one role and sits directly above it in the hierarchy:
|
|
810
|
+
|
|
811
|
+
```ruby
|
|
812
|
+
Organizations.configure do |config|
|
|
813
|
+
config.roles do
|
|
814
|
+
role :client, inherits: :viewer do
|
|
815
|
+
can :view_own_records
|
|
816
|
+
can :answer_questions
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
Organizations::Roles.hierarchy # => [:owner, :admin, :member, :client, :viewer]
|
|
822
|
+
org.add_member!(user, role: :client)
|
|
823
|
+
user.role_in(org) # => :client
|
|
824
|
+
user.has_organization_permission_to?(:answer_questions) # => true
|
|
825
|
+
require_organization_role!(:client) # clients and everyone above them
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
- A custom role gets its own permissions plus its parent's (the parent's defaults when the block does not redefine a built-in parent).
|
|
829
|
+
- A custom role without `inherits:` sits at the bottom of the hierarchy. Two custom roles with the same parent stack in the order you define them, the first one highest.
|
|
830
|
+
- A block that only adds custom roles keeps the built-in roles' default permissions; a block that redefines a built-in role defines the built-in roles it lists, as before.
|
|
831
|
+
- A custom role ranks below admin: it inherits from `member`, `viewer` or another custom role, never from `admin` or `owner`, so it never outranks the people who can assign it. A custom role also cannot grant a permission that the owner role has and the admin role lacks (`manage_billing`, `transfer_ownership`, `delete_organization` by default), because admins can assign it. A role name is lowercase letters, digits and underscores; a parent must be a built-in role or a custom role defined above it. Both are checked when the configuration loads.
|
|
832
|
+
- Memberships, invitations, `default_invitation_role`, `add_member!`, promotions and demotions, `by_role_hierarchy` and every permission helper accept custom roles. The generated views list them in the role pickers.
|
|
833
|
+
- Removing a custom role from the configuration later leaves its memberships readable and updatable (the role is checked only when it is set or changed) and ranks them below every configured role; move them to a configured role with `org.change_role_of!(user, to: :member)`. A pending invitation for a retired role cannot be accepted; revoke it and invite again with a configured role.
|
|
834
|
+
|
|
619
835
|
### Add custom permissions
|
|
620
836
|
|
|
621
837
|
```ruby
|
|
@@ -799,6 +1015,9 @@ Users can belong to multiple organizations. The "current" organization is stored
|
|
|
799
1015
|
2. User switches org → Session updated, `current_organization` changes
|
|
800
1016
|
3. User is removed from current org → Auto-switches to next available org
|
|
801
1017
|
|
|
1018
|
+
> [!WARNING]
|
|
1019
|
+
> **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.
|
|
1020
|
+
|
|
802
1021
|
### Manual switching
|
|
803
1022
|
|
|
804
1023
|
```ruby
|
|
@@ -1106,7 +1325,10 @@ Organizations.configure do |config|
|
|
|
1106
1325
|
|
|
1107
1326
|
# === Invitation Flow Redirects ===
|
|
1108
1327
|
# Where to redirect unauthenticated users when they try to accept an invitation
|
|
1109
|
-
# Default: nil (
|
|
1328
|
+
# Default: nil = known-user promotion (since 0.5.0): if the invited address
|
|
1329
|
+
# already has an account, sign-IN (new_user_session_path); otherwise
|
|
1330
|
+
# sign-UP (new_user_registration_path), falling back to root_path.
|
|
1331
|
+
# Set this to opt out of the lookup.
|
|
1110
1332
|
config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
|
|
1111
1333
|
# Or use a Proc: ->(invitation, user) { "/signup?invite=#{invitation.token}" }
|
|
1112
1334
|
|
|
@@ -1304,10 +1526,15 @@ end
|
|
|
1304
1526
|
|----------|----------------|------|
|
|
1305
1527
|
| `on_organization_created` | `organization`, `user` | After |
|
|
1306
1528
|
| `on_member_invited` | `organization`, `invitation`, `invited_by` | **Before (strict)** |
|
|
1529
|
+
| `on_member_joining` | `organization`, `user`, `role`, `joined_via`, `invitation`/`join_request` | **Before (strict)** — THE membership gate; fires pre-persist on every join path |
|
|
1307
1530
|
| `on_member_joined` | `organization`, `membership`, `user` | After |
|
|
1308
1531
|
| `on_member_removed` | `organization`, `membership`, `user`, `removed_by` | After |
|
|
1309
1532
|
| `on_role_changed` | `organization`, `membership`, `old_role`, `new_role`, `changed_by` | After |
|
|
1310
1533
|
| `on_ownership_transferred` | `organization`, `old_owner`, `new_owner` | After |
|
|
1534
|
+
| `on_join_request_created` | `organization`, `user`, `join_request` | After |
|
|
1535
|
+
| `on_join_request_approved` | `organization`, `user`, `join_request`, `membership`, `decided_by` (nil for auto) | After |
|
|
1536
|
+
| `on_join_request_rejected` | `organization`, `user`, `join_request`, `decided_by` | After |
|
|
1537
|
+
| `on_verification_delivery_failed` | `organization`, `user`, `join_request`, `metadata` (`error_class`/`error_message`) | After |
|
|
1311
1538
|
|
|
1312
1539
|
**Callback modes:**
|
|
1313
1540
|
- **After**: Runs after the action completes. Errors are logged but don't block the operation. Use for notifications, analytics, and audit logs.
|
|
@@ -1349,6 +1576,13 @@ john_at_acme:
|
|
|
1349
1576
|
sign_in_as_organization_member(user, org, role: :admin)
|
|
1350
1577
|
set_current_organization(org)
|
|
1351
1578
|
|
|
1579
|
+
# Complete the emailed-code flow without intercepting mail: force a KNOWN
|
|
1580
|
+
# plaintext code onto the request's challenge (the DB only stores digests —
|
|
1581
|
+
# don't reverse-engineer the digest recipe in your tests)
|
|
1582
|
+
request.start_email_verification!(email: "j.doe@acme.com")
|
|
1583
|
+
code = issue_verification_code(request) # => "424242"
|
|
1584
|
+
request.verify_email_code!(code) # => Membership
|
|
1585
|
+
|
|
1352
1586
|
# Or manually
|
|
1353
1587
|
sign_in user
|
|
1354
1588
|
switch_to_organization!(org)
|
|
@@ -1366,7 +1600,7 @@ assert user.belongs_to_any_organization?
|
|
|
1366
1600
|
|
|
1367
1601
|
## Extending the Organization model
|
|
1368
1602
|
|
|
1369
|
-
The gem provides `Organizations::Organization` as the base model.
|
|
1603
|
+
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**:
|
|
1370
1604
|
|
|
1371
1605
|
```ruby
|
|
1372
1606
|
# db/migrate/xxx_add_custom_fields_to_organizations.rb
|
|
@@ -1380,43 +1614,91 @@ end
|
|
|
1380
1614
|
```
|
|
1381
1615
|
|
|
1382
1616
|
```ruby
|
|
1383
|
-
#
|
|
1384
|
-
|
|
1617
|
+
# app/models/concerns/organization_extensions.rb
|
|
1618
|
+
module OrganizationExtensions
|
|
1619
|
+
extend ActiveSupport::Concern
|
|
1385
1620
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
# Add your own validations
|
|
1392
|
-
validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
1621
|
+
included do
|
|
1622
|
+
has_many :projects
|
|
1623
|
+
has_many :documents
|
|
1624
|
+
validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
1625
|
+
end
|
|
1393
1626
|
|
|
1394
|
-
# Add your own methods
|
|
1395
1627
|
def active_projects
|
|
1396
1628
|
projects.where(archived: false)
|
|
1397
1629
|
end
|
|
1398
1630
|
end
|
|
1399
1631
|
```
|
|
1400
1632
|
|
|
1633
|
+
```ruby
|
|
1634
|
+
# config/initializers/organizations.rb (top of the file, outside the configure block)
|
|
1635
|
+
ActiveSupport.on_load(:organizations_organization) do
|
|
1636
|
+
include OrganizationExtensions # `self` is Organizations::Organization
|
|
1637
|
+
end
|
|
1638
|
+
ActiveSupport.on_load(:organizations_membership) do
|
|
1639
|
+
# typed boolean toggles over the metadata bag, with defaults:
|
|
1640
|
+
metadata_flag :show_on_profile, default: true
|
|
1641
|
+
end
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
**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.
|
|
1645
|
+
|
|
1646
|
+
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:
|
|
1647
|
+
|
|
1648
|
+
```ruby
|
|
1649
|
+
config.public_controller_helpers = ["ApplicationHelper", "PageHelper"]
|
|
1650
|
+
```
|
|
1651
|
+
|
|
1401
1652
|
Alternatively, create your own model that inherits from the gem's model:
|
|
1402
1653
|
|
|
1403
1654
|
```ruby
|
|
1404
1655
|
# app/models/organization.rb
|
|
1405
1656
|
class Organization < Organizations::Organization
|
|
1406
1657
|
has_many :projects
|
|
1407
|
-
has_many :documents
|
|
1408
|
-
|
|
1409
|
-
validates :support_email, presence: true
|
|
1410
1658
|
end
|
|
1411
1659
|
```
|
|
1412
1660
|
|
|
1413
1661
|
> **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.
|
|
1414
1662
|
|
|
1415
|
-
This is standard Rails practice — the gem provides the foundation (memberships, invitations, roles), your app extends it with domain-specific features.
|
|
1663
|
+
This is standard Rails practice — the gem provides the foundation (memberships, invitations, roles, verified joining), your app extends it with domain-specific features.
|
|
1664
|
+
|
|
1665
|
+
## URL-scoped organizations (`OrganizationScoped`)
|
|
1666
|
+
|
|
1667
|
+
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:
|
|
1668
|
+
|
|
1669
|
+
```ruby
|
|
1670
|
+
class Portal::BaseController < ApplicationController
|
|
1671
|
+
include Organizations::OrganizationScoped
|
|
1672
|
+
|
|
1673
|
+
self.organization_param = :slug
|
|
1674
|
+
self.organization_finder = ->(param) { Organizations::Organization.find_by(slug: param) }
|
|
1675
|
+
require_organization_role :admin # gate every action behind a minimum role
|
|
1676
|
+
end
|
|
1677
|
+
```
|
|
1678
|
+
|
|
1679
|
+
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.
|
|
1680
|
+
|
|
1681
|
+
## Internationalization (i18n)
|
|
1682
|
+
|
|
1683
|
+
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):
|
|
1684
|
+
|
|
1685
|
+
```yaml
|
|
1686
|
+
# config/locales/en.yml (host app) — override just what you want
|
|
1687
|
+
en:
|
|
1688
|
+
organizations:
|
|
1689
|
+
roles:
|
|
1690
|
+
admin: "Manager"
|
|
1691
|
+
errors:
|
|
1692
|
+
join_code_invalid: "Hmm, that code doesn't work."
|
|
1693
|
+
```
|
|
1694
|
+
|
|
1695
|
+
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.
|
|
1416
1696
|
|
|
1417
1697
|
## Database schema
|
|
1418
1698
|
|
|
1419
|
-
The gem creates three
|
|
1699
|
+
The gem creates **seven tables** (three core + four for verified joining, all created by `organizations:install`; existing installs get the four via `organizations:upgrade`):
|
|
1700
|
+
|
|
1701
|
+
> **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).
|
|
1420
1702
|
|
|
1421
1703
|
### organizations_organizations
|
|
1422
1704
|
|
|
@@ -1424,13 +1706,11 @@ The gem creates three tables:
|
|
|
1424
1706
|
organizations_organizations
|
|
1425
1707
|
- id (primary key, auto-detects UUID or integer from your app)
|
|
1426
1708
|
- name (string, required)
|
|
1709
|
+
- memberships_count (integer, counter cache, default: 0)
|
|
1427
1710
|
- metadata (jsonb, default: {})
|
|
1428
|
-
- created_at
|
|
1429
|
-
- updated_at
|
|
1711
|
+
- created_at / updated_at
|
|
1430
1712
|
```
|
|
1431
1713
|
|
|
1432
|
-
> **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
|
|
1433
|
-
|
|
1434
1714
|
### organizations_memberships
|
|
1435
1715
|
|
|
1436
1716
|
```sql
|
|
@@ -1440,10 +1720,17 @@ organizations_memberships
|
|
|
1440
1720
|
- organization_id (foreign key, indexed)
|
|
1441
1721
|
- role (string, default: 'member')
|
|
1442
1722
|
- invited_by_id (foreign key, nullable)
|
|
1443
|
-
-
|
|
1444
|
-
-
|
|
1723
|
+
- joined_via (string, nullable: invited|code|domain_email|allowlist|manual)
|
|
1724
|
+
- verified_email / verified_email_normalized (string, nullable)
|
|
1725
|
+
- verified_at (datetime, nullable)
|
|
1726
|
+
- metadata (jsonb, default: {})
|
|
1727
|
+
- created_at / updated_at
|
|
1445
1728
|
|
|
1446
1729
|
unique index: [user_id, organization_id]
|
|
1730
|
+
partial unique index: [organization_id] where role = 'owner' (single owner; pg/sqlite —
|
|
1731
|
+
generated-column emulation on MySQL)
|
|
1732
|
+
unique index: [organization_id, verified_email_normalized] (one proven inbox = one member;
|
|
1733
|
+
plain composite — NULLs never collide, so no WHERE clause is needed)
|
|
1447
1734
|
```
|
|
1448
1735
|
|
|
1449
1736
|
### organizations_invitations
|
|
@@ -1458,10 +1745,70 @@ organizations_invitations
|
|
|
1458
1745
|
- invited_by_id (foreign key, nullable)
|
|
1459
1746
|
- accepted_at (datetime, nullable)
|
|
1460
1747
|
- expires_at (datetime)
|
|
1461
|
-
-
|
|
1462
|
-
- updated_at
|
|
1748
|
+
- metadata / membership_metadata (jsonb, default: {})
|
|
1749
|
+
- created_at / updated_at
|
|
1463
1750
|
|
|
1464
|
-
unique index: [organization_id, email] where accepted_at is null
|
|
1751
|
+
partial unique index: [organization_id, lower(email)] where accepted_at is null
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
### organizations_domains
|
|
1755
|
+
|
|
1756
|
+
```sql
|
|
1757
|
+
organizations_domains
|
|
1758
|
+
- id, organization_id (foreign key)
|
|
1759
|
+
- domain (string, required — stored lowercased, no leading '@')
|
|
1760
|
+
- membership_metadata / metadata (jsonb, default: {})
|
|
1761
|
+
- created_at / updated_at
|
|
1762
|
+
|
|
1763
|
+
unique index: [organization_id, domain]; index: [domain] (email → candidate orgs)
|
|
1764
|
+
```
|
|
1765
|
+
|
|
1766
|
+
### organizations_join_codes
|
|
1767
|
+
|
|
1768
|
+
```sql
|
|
1769
|
+
organizations_join_codes
|
|
1770
|
+
- id, organization_id (foreign key)
|
|
1771
|
+
- code (string, globally unique)
|
|
1772
|
+
- label (string, nullable — campaign attribution)
|
|
1773
|
+
- requires_verified_domain_email (boolean, default: false)
|
|
1774
|
+
- auto_approve (boolean, default: true)
|
|
1775
|
+
- expires_at (datetime, nullable) / max_uses (integer, nullable) / uses_count (integer, default: 0)
|
|
1776
|
+
- revoked_at (datetime, nullable)
|
|
1777
|
+
- created_by_id (foreign key, nullable)
|
|
1778
|
+
- membership_metadata / metadata (jsonb, default: {})
|
|
1779
|
+
- created_at / updated_at
|
|
1780
|
+
```
|
|
1781
|
+
|
|
1782
|
+
### organizations_allowlist_entries
|
|
1783
|
+
|
|
1784
|
+
```sql
|
|
1785
|
+
organizations_allowlist_entries
|
|
1786
|
+
- id, organization_id (foreign key)
|
|
1787
|
+
- email (string, as provided) / email_normalized (string)
|
|
1788
|
+
- source (string, nullable — e.g. "csv_2026-07")
|
|
1789
|
+
- claimed_at (datetime, nullable) / claimed_by_id (foreign key, nullable)
|
|
1790
|
+
- membership_metadata / metadata (jsonb, default: {})
|
|
1791
|
+
- created_at / updated_at
|
|
1792
|
+
|
|
1793
|
+
unique index: [organization_id, email_normalized]
|
|
1794
|
+
```
|
|
1795
|
+
|
|
1796
|
+
### organizations_join_requests
|
|
1797
|
+
|
|
1798
|
+
```sql
|
|
1799
|
+
organizations_join_requests
|
|
1800
|
+
- id, organization_id (foreign key), user_id (foreign key)
|
|
1801
|
+
- status (string, default: 'pending': pending|approved|rejected|withdrawn; :expired is derived)
|
|
1802
|
+
- joined_via (string, nullable) / join_code_id (foreign key, nullable) / message (string, nullable)
|
|
1803
|
+
- verification_email / verification_email_normalized (string, nullable)
|
|
1804
|
+
- verification_code_digest (string, nullable — SHA-256 only, never plaintext)
|
|
1805
|
+
- verification_sent_at / verification_expires_at (datetime) / verified_at (datetime)
|
|
1806
|
+
- verification_attempts / verification_sends_count (integer, default: 0)
|
|
1807
|
+
- decided_by_id (foreign key, nullable) / decided_at (datetime) / expires_at (datetime)
|
|
1808
|
+
- metadata (jsonb, default: {})
|
|
1809
|
+
- created_at / updated_at
|
|
1810
|
+
|
|
1811
|
+
partial unique index: [organization_id, user_id] where status = 'pending'
|
|
1465
1812
|
```
|
|
1466
1813
|
|
|
1467
1814
|
## Ownership rules
|
|
@@ -1623,8 +1970,12 @@ These constraints prevent duplicate data at the database level:
|
|
|
1623
1970
|
| Constraint | Purpose |
|
|
1624
1971
|
|------------|---------|
|
|
1625
1972
|
| `memberships [user_id, organization_id]` | User can only have one membership per org |
|
|
1626
|
-
| `
|
|
1973
|
+
| `memberships [organization_id] WHERE role = 'owner'` | Exactly one owner per org |
|
|
1974
|
+
| `memberships [organization_id, verified_email_normalized]` | One proven inbox = one member per org |
|
|
1975
|
+
| `invitations [organization_id, LOWER(email)] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |
|
|
1627
1976
|
| `invitations [token]` | Invitation tokens are globally unique |
|
|
1977
|
+
| `join_requests [organization_id, user_id] WHERE status = 'pending'` | One open join request per user per org |
|
|
1978
|
+
| `join_codes [code]` | Join codes are globally unique |
|
|
1628
1979
|
|
|
1629
1980
|
### Row-level locking
|
|
1630
1981
|
|
|
@@ -1750,15 +2101,56 @@ CREATE INDEX index_organizations_invitations_on_email ON organizations_invitatio
|
|
|
1750
2101
|
CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
|
|
1751
2102
|
```
|
|
1752
2103
|
|
|
1753
|
-
##
|
|
2104
|
+
## Migrating from a hand-rolled Organization model
|
|
2105
|
+
|
|
2106
|
+
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**:
|
|
2107
|
+
|
|
2108
|
+
```ruby
|
|
2109
|
+
# 1. Park the legacy table (keep it around until you're confident)
|
|
2110
|
+
class BackupOrganizationsForGem < ActiveRecord::Migration[8.0]
|
|
2111
|
+
def change
|
|
2112
|
+
# Drop FKs that point INTO the legacy table first, then:
|
|
2113
|
+
rename_table :organizations, :organizations_legacy
|
|
2114
|
+
end
|
|
2115
|
+
end
|
|
2116
|
+
|
|
2117
|
+
# 2. Create the gem tables — vendor the install generator's migration:
|
|
2118
|
+
# rails g organizations:install (take the migration, skip the initializer if you have one)
|
|
2119
|
+
|
|
2120
|
+
# 3. Re-add YOUR columns onto the gem's organizations table
|
|
2121
|
+
class AddAppColumnsToOrganizations < ActiveRecord::Migration[8.0]
|
|
2122
|
+
def change
|
|
2123
|
+
add_column :organizations_organizations, :support_email, :string
|
|
2124
|
+
# ...every host column your legacy table had
|
|
2125
|
+
end
|
|
2126
|
+
end
|
|
2127
|
+
|
|
2128
|
+
# 4. Copy the data — the load-bearing details:
|
|
2129
|
+
class MigrateOrganizationData < ActiveRecord::Migration[8.0]
|
|
2130
|
+
def up
|
|
2131
|
+
# a) Copy legacy rows PRESERVING IDs (every FK in your app keeps working).
|
|
2132
|
+
# b) Create memberships: pick each org's owner deterministically
|
|
2133
|
+
# (e.g. earliest user) — the gem enforces exactly one owner.
|
|
2134
|
+
# c) Backfill memberships_count.
|
|
2135
|
+
# d) ⚠️ REWRITE POLYMORPHIC TYPE STRINGS: every table storing
|
|
2136
|
+
# "Organization" in a *_type column must now say
|
|
2137
|
+
# "Organizations::Organization" — check pay (customers, merchants),
|
|
2138
|
+
# pricing_plans (assignments, enforcement_states, usages),
|
|
2139
|
+
# api_keys, usage_credits (wallets, fulfillments), and your own
|
|
2140
|
+
# polymorphic associations. This is the step everyone forgets.
|
|
2141
|
+
# e) Re-add + validate the FKs you dropped in step 1.
|
|
2142
|
+
end
|
|
2143
|
+
end
|
|
2144
|
+
```
|
|
1754
2145
|
|
|
1755
|
-
|
|
2146
|
+
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.
|
|
1756
2147
|
|
|
1757
2148
|
## Roadmap
|
|
1758
2149
|
|
|
1759
|
-
- [
|
|
1760
|
-
- [
|
|
1761
|
-
- [
|
|
2150
|
+
- [x] Domain-based joining (users prove control of an @acme.com inbox — shipped in 0.5.0 as part of verified joining)
|
|
2151
|
+
- [x] Request-to-join workflow (shipped in 0.5.0)
|
|
2152
|
+
- [x] Roster/allowlist bulk import (shipped in 0.5.0; covers the bulk-invite use case via `import_allowlist!`)
|
|
2153
|
+
- [ ] Bulk invitations (CSV upload of token invitations)
|
|
1762
2154
|
- [ ] Organization-level audit logs
|
|
1763
2155
|
- [ ] Team hierarchies within organizations
|
|
1764
2156
|
|