organizations 0.6.0 → 0.7.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/CHANGELOG.md +102 -0
  6. data/README.md +704 -33
  7. data/app/controllers/organizations/invitations_controller.rb +27 -22
  8. data/app/controllers/organizations/memberships_controller.rb +37 -30
  9. data/app/controllers/organizations/organizations_controller.rb +8 -8
  10. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  11. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  12. data/app/mailers/organizations/verification_mailer.rb +1 -1
  13. data/app/models/organizations/grant.rb +6 -0
  14. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  15. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  16. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  17. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  18. data/config/locales/en.yml +22 -0
  19. data/config/locales/es.yml +22 -0
  20. data/config/routes.rb +1 -2
  21. data/gemfiles/rails_7.2.gemfile +5 -1
  22. data/gemfiles/rails_8.1.gemfile +5 -1
  23. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  24. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  25. data/lib/generators/organizations/install/install_generator.rb +1 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  27. data/lib/generators/organizations/join/join_generator.rb +113 -0
  28. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  30. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  33. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  34. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  35. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  36. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  37. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  38. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  39. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  40. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  41. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  42. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  43. data/lib/generators/organizations/views/views_generator.rb +4 -6
  44. data/lib/organizations/account_deletion_plan.rb +118 -0
  45. data/lib/organizations/configuration.rb +152 -0
  46. data/lib/organizations/controller_helpers.rb +55 -15
  47. data/lib/organizations/grant_access.rb +75 -0
  48. data/lib/organizations/grant_holder.rb +152 -0
  49. data/lib/organizations/grantable.rb +28 -0
  50. data/lib/organizations/join_flow.rb +16 -12
  51. data/lib/organizations/models/concerns/has_organizations.rb +224 -9
  52. data/lib/organizations/models/domain.rb +283 -1
  53. data/lib/organizations/models/grant.rb +152 -0
  54. data/lib/organizations/models/invitation.rb +76 -7
  55. data/lib/organizations/models/join_request.rb +102 -43
  56. data/lib/organizations/models/membership.rb +87 -5
  57. data/lib/organizations/models/organization.rb +219 -48
  58. data/lib/organizations/organization_scoped.rb +16 -0
  59. data/lib/organizations/personal_owner_backfill.rb +122 -0
  60. data/lib/organizations/public_email_domains.rb +49 -0
  61. data/lib/organizations/resource.rb +11 -0
  62. data/lib/organizations/roles.rb +55 -0
  63. data/lib/organizations/version.rb +1 -1
  64. data/lib/organizations/view_helpers.rb +40 -9
  65. data/lib/organizations.rb +99 -4
  66. metadata +22 -2
data/README.md CHANGED
@@ -114,7 +114,7 @@ config.user_class = "Account" # set BEFORE the models are first referenced (in
114
114
  Done. Your app now has full organizations / teams support.
115
115
 
116
116
  > [!IMPORTANT]
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/).
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 the verified-joining screens, run `rails g organizations:join` (see "Generate the join and access screens"). For a complete working example, check out the demo app in [`test/dummy`](test/dummy/).
118
118
 
119
119
  > [!NOTE]
120
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.
@@ -284,6 +284,96 @@ org.import_allowlist!(["ana@gmail.com", "luis@yahoo.es"], source: "csv_2026-07")
284
284
  # nothing without inbox access. The entry is claimed on join.
285
285
  ```
286
286
 
287
+ ### Domain authority: prove that the organization controls the domain
288
+
289
+ An emailed code proves that one person controls one inbox. It does not prove that the organization may claim the whole domain: a student with an `@university.edu` inbox is not the university. Before you let untrusted organizers enroll domains themselves, require a proof of control per organization and turn on `require_verified_domains`:
290
+
291
+ ```ruby
292
+ config.require_verified_domains = true # default: false (unverified domains work as before)
293
+ ```
294
+
295
+ The proof is a DNS TXT record with an unpredictable token. The token belongs to one claim: one organization and one exact domain.
296
+
297
+ ```ruby
298
+ domain = org.add_domain!("acme.com")
299
+ domain.start_verification! # new token; the challenge stays open 7 days
300
+ domain.verification_record_name # => "_organizations-verification.acme.com"
301
+ domain.verification_record_value # => "organizations-verification=Qm9…"
302
+ # The organization's admin publishes that TXT record, then:
303
+ domain.verify! # => domain
304
+ # On failure it raises Organizations::DomainVerificationFailed, and
305
+ # error.reason is :not_started, :expired, :changed, :record_not_found or :lookup_failed.
306
+ ```
307
+
308
+ Or record an approval by a platform operator who checked the claim another way. A member of the claiming organization cannot approve its own claim (`NotAuthorized`); deciding who is platform staff is your app's job:
309
+
310
+ ```ruby
311
+ domain.verify_by_operator!(operator: staff_user, note: "Registrar record checked, ticket 4411")
312
+ domain.verification_method # => "operator"; domain.verified_by => staff_user
313
+ ```
314
+
315
+ Later:
316
+
317
+ ```ruby
318
+ domain.verified? # proven and not revoked
319
+ domain.revoke_verification!(reason: "domain sold") # stops new domain-backed admissions; members stay
320
+ domain.recheck_verification! # re-reads the TXT record and revokes when it is gone (schedule it if you like)
321
+ domain.start_verification! # a new challenge: rotates the token and clears an earlier verification
322
+ Organizations::Domain.verified # scope
323
+ ```
324
+
325
+ The rules:
326
+
327
+ - **A verification does not expire on its own.** The 7-day TTL is for the open challenge only. Domains change hands: schedule `recheck_verification!` (for DNS proofs) or review operator approvals, and revoke what no longer holds.
328
+ - **Authority columns are privileged.** Only the methods above write `verified_at`, `verification_method`, `verified_by_id` and `verification_revoked_at`. Never let request params reach them: permit only `domain` and `join_policy` in your admin forms, and authorize who may call `verify!` and `verify_by_operator!`.
329
+ - **A proof belongs to one claim.** Moving a domain row to another organization, changing its domain, or copying it (`dup`) clears the proof. A revocation stays until a new proof succeeds, also after `start_verification!`.
330
+ - **Exact domains only.** No subdomain or suffix authority, and no global exclusivity: two departments can each prove their own claim of the same domain.
331
+ - **Changing `domain.domain` clears the verification and the token.** Start again.
332
+ - **Every domain path checks the same rule** (`Organizations::Domain.joinable`): the emailed challenge, the confirmed-account-email shortcut, domain-restricted join codes and `JoinFlow`. It checks again at admission: a domain (or allowlist entry) removed, revoked or blocked while a code was in flight admits nobody. An admin can still approve such a pending request by hand.
333
+ - **A revoked domain never admits**, also with `require_verified_domains` off. Existing members stay.
334
+ - **Existing domains start unverified.** Nothing is grandfathered. Verify them before you turn on `require_verified_domains`.
335
+ - **Invitations, plain codes and allowlists stay separate grants.** They do not depend on domain authority.
336
+ - Rate-limit the endpoint that calls `verify!`, and tell joiners which organization will see their address before they submit it.
337
+
338
+ Configuration:
339
+
340
+ ```ruby
341
+ config.domain_verification_record_prefix = "_myapp-verification" # your brand in the DNS label
342
+ config.domain_verification_ttl = 7.days # how long a challenge stays open
343
+ config.domain_verification_timeout = 5 # seconds for the built-in Resolv lookup
344
+ config.domain_txt_resolver = ->(name) { MyDns.txt(name) } # custom lookup, returns [String]
345
+ ```
346
+
347
+ **Blocking public email providers (opt-in).** A preset ships with the gem:
348
+
349
+ ```ruby
350
+ config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS # default: []
351
+ ```
352
+
353
+ A blocked domain cannot be enrolled, and an existing row for it stops admitting. People with those addresses can still join by invitation, code or allowlist. The list is defence in depth, not proof of authority and not an authorization boundary: an unlisted provider is not trusted because it is unlisted.
354
+
355
+ ### Sign-up domain policies: none, suggest, auto-join
356
+
357
+ Each domain has a `join_policy` for people who sign up with a confirmed account email at that domain. The policy acts only while the domain is verified, and only when the account email is confirmed (Devise `:confirmable` or similar) and `trust_confirmed_account_email` is on:
358
+
359
+ | `join_policy` | What happens |
360
+ |---|---|
361
+ | `"none"` (default) | Nothing. The explicit join flows work as before. |
362
+ | `"suggest"` | The organization appears in `user.suggested_organizations`. The user still chooses to join. |
363
+ | `"auto_join"` | The user joins after sign-up, or right after confirming the email, through the membership gate (`on_member_joining` can veto). |
364
+
365
+ ```ruby
366
+ org.add_domain!("acme.com", join_policy: :suggest)
367
+ domain.update!(join_policy: :auto_join)
368
+ domain.effective_join_policy # => :none until the domain is verified
369
+
370
+ current_user.suggested_organizations # verified "suggest" or "auto_join" domains, minus orgs they are in
371
+ org.join_with_account_email!(current_user) # the "Join" button for a suggestion
372
+ current_user.join_organizations_by_domain! # what auto-join runs; idempotent, returns memberships
373
+ ```
374
+
375
+ Auto-join runs in an `after_commit` on the user when `confirmed_at` changes, and never breaks a sign-up: a veto or an already-claimed email skips that organization and logs it. Changing the email of an account that stays confirmed does not trigger it, because nothing proved the new address. The trigger reads the changes of the last save in the transaction, so set `confirmed_at` in that save (Devise does). With Devise `:reconfirmable`, confirming the new address updates `confirmed_at` and triggers it.
376
+
287
377
  ### Cohorts without the gem knowing (`membership_metadata`)
288
378
 
289
379
  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:
@@ -321,6 +411,8 @@ result.membership / result.join_request
321
411
  @state.status # :member | :verifying | :pending | :entry
322
412
  @state.resend_seconds # cooldown for the "resend code" button, derived from config
323
413
  @state.error_message # localized message of a just-failed action
414
+ @state.member? / verifying? / pending? / entry? # the same states as predicates
415
+ @state.challenge # the join request whose emailed code is in flight
324
416
 
325
417
  # Which entry forms to show — the org's actual instruments decide:
326
418
  org.accepts_domain_joining? # email form (domains or unclaimed allowlist entries)
@@ -328,14 +420,41 @@ org.accepts_code_joining? # code form (at least one actually-redeemable code)
328
420
  org.accepts_join_requests? # anything at all
329
421
  ```
330
422
 
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.
423
+ ### Generate the join and access screens
424
+
425
+ ```bash
426
+ rails g organizations:join # join screen + access screen + routes
427
+ rails g organizations:join --skip-access # the join screen only
428
+ rails g organizations:join --skip-routes # add the routes yourself
429
+ ```
430
+
431
+ The generator copies host-owned controllers and views (Tailwind, like `organizations:views`) and adds one routes block. Run it again later without `--skip-access` and it adds only the access routes. The engine itself adds no join route, so nothing changes in your app until you run it.
432
+
433
+ | Screen | URL | What it does |
434
+ |---|---|---|
435
+ | `OrganizationJoinsController` | `GET/POST/DELETE /orgs/:organization_id/join` (`organization_join_path`) | The four `JoinState` states at one URL: a code, a work email and its 6-digit code, or a plain request; withdraw a pending request |
436
+ | `OrganizationAccessController` | `/orgs/:organization_id/access` (`organization_access_path`) | Pending requests (approve, reject), email domains (DNS verification, sign-up policy), join codes (create, revoke), the allowlist (import, remove) |
437
+
438
+ The generated code already follows the safety rules. Keep them when you edit it:
439
+
440
+ - **A signed-in user is required** (`config.authenticate_user_method`). The join screen takes the organization from the URL and never changes the session's current organization.
441
+ - **Only POST and DELETE change data**, under Rails' CSRF protection. A `?code=…` link fills in the form; it never joins and never spends a use, also after a sign-in redirect.
442
+ - **Inputs are cut to a fixed length**, and allowlist imports to 1,000 addresses; invalid addresses are skipped and counted.
443
+ - **Unknown, revoked, expired and foreign codes share one message** (`JoinFlow` does that).
444
+ - **The access screen is URL-scoped** with `OrganizationScoped`: it needs `invite_members`, domain changes also need `manage_settings`, and every lookup is scoped to the URL's organization. Unknown organizations, strangers and members without the permission all get the same 404.
445
+ - **Rate limits** on joining, on the screen, and on DNS checks. They need Rails 7.2+ and a cache store that every server process shares (Redis, Solid Cache): the null and memory stores do not enforce them across processes. On Rails 7.1, use Rack::Attack.
446
+ - **A sent code or a pending request redirects** (303), as Turbo expects. Failures render with 422.
447
+ - **The signed-in user comes from `config.current_user_method`**, the same as the rest of the gem.
448
+ - **A disclosure partial** (`organization_joins/_disclosure.html.erb`) tells joiners who will see their name and email before they submit. Edit its text to match your privacy policy.
449
+
450
+ The templates are the gem's demo app ([`test/dummy`](test/dummy/)), byte for byte, and its HTTP test suite covers them. Two things to know: the join screen shows an organization's name to any signed-in user who has its id (use UUID primary keys or a handle finder for private tenants), and the generator does not check for controllers with the same names in your app (Thor asks before it overwrites a file). When your `routes.rb` has no `routes.draw do` line to insert after, the generator prints the routes for you to add.
332
451
 
333
452
  ### Rate limiting your join endpoints
334
453
 
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):
454
+ The engine adds no join routes, so the rate limits live in your controllers — and **code redemption and code verification are enumeration surfaces**. `rails g organizations:join` adds these limits; for your own controllers, use Rails' built-in [`rate_limit`](https://api.rubyonrails.org/classes/ActionController/RateLimiting.html):
336
455
 
337
456
  ```ruby
338
- class JoinsController < ApplicationController
457
+ class OrganizationJoinsController < ApplicationController
339
458
  # Joining/redeeming: per-user, generous but bounded
340
459
  rate_limit to: 10, within: 1.hour, by: -> { current_user.id }, only: :create
341
460
  # Public landings (if any are unauthenticated): per-IP against slug/code enumeration
@@ -349,6 +468,7 @@ Layer them per surface: the gem already throttles **per request** (60s resend in
349
468
 
350
469
  - 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
470
  - Emails are normalized before the uniqueness check (case, whitespace, and `+tag` plus-addressing collapse — override via `config.verification_email_normalizer`).
471
+ - An emailed code proves one inbox, never the whole domain. Domain authority (DNS TXT or operator approval) is a separate proof per organization; turn on `require_verified_domains` before untrusted self-service enrollment (see "Domain authority").
352
472
  - 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
473
  - BYO-UI as always: the gem ships models/APIs/mailers and state; **you must rate-limit your join/redemption/verification endpoints** (see above).
354
474
  - 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").
@@ -363,7 +483,7 @@ config.on_verification_delivery_failed do |ctx|
363
483
  end
364
484
  ```
365
485
 
366
- Upgrading an existing install: `rails g organizations:upgrade && rails db:migrate` (additive only).
486
+ Upgrading an existing install: `rails g organizations:upgrade && rails db:migrate` (additive only). The generator adds only the migrations your app does not have yet (it asks the database when it can, and reads `db/migrate` otherwise), so run it after every upgrade of the gem. For a squashed migration history, name the steps: `rails g organizations:upgrade --steps add_personal_organizations`.
367
487
 
368
488
  ## Why this gem exists
369
489
 
@@ -395,8 +515,8 @@ Laravel has Jetstream with a `--teams` flag. Django has `django-organizations`.
395
515
 
396
516
  `organizations` gives you the complete `User → Membership → Organization` pattern with scoped invitations, hierarchical roles, and the ability to switch between organizations – all in a single, well-tested gem that works with your existing Devise setup. What previously took 1.5 months now takes 3 days.
397
517
 
398
- > ![NOTE]
399
- > This gem handles organization membership and org-level permissions (who can invite members, who can manage billing). For per-record authorization ("can user X edit document Y"), use [Pundit](https://github.com/varvet/pundit) or [CanCanCan](https://github.com/CanCanCommunity/cancancan) alongside this gem.
518
+ > [!NOTE]
519
+ > This gem handles organization membership and org-level permissions (who can invite members, who can manage billing), plus optional per-record grants for members who should see only some records (see "Scoped resource grants"). For general per-record policies ("users can edit their own posts"), use [Pundit](https://github.com/varvet/pundit) or [CanCanCan](https://github.com/CanCanCommunity/cancancan) alongside this gem.
400
520
 
401
521
  ## The complete API
402
522
 
@@ -409,6 +529,7 @@ When you add `has_organizations` to your User model, you get:
409
529
  user.organizations # All organizations user belongs to
410
530
  user.memberships # All memberships (with roles)
411
531
  user.owned_organizations # Organizations where user is owner
532
+ user.personal_organization # Their personal organization, or nil (never creates one)
412
533
  user.pending_organization_invitations # Invitations waiting to be accepted
413
534
 
414
535
  # Current organization context
@@ -439,6 +560,10 @@ user.is_viewer_of?(org) # "Is user a viewer of this org?"
439
560
  user.is_at_least?(:admin, in: org) # "Is user at least an admin in this org?"
440
561
  user.role_in(org) # => :admin
441
562
 
563
+ # Domain policies (see "Sign-up domain policies")
564
+ user.suggested_organizations # Organizations to offer, from verified domains
565
+ user.join_organizations_by_domain! # Join every verified auto_join domain (runs on its own)
566
+
442
567
  # Actions
443
568
  user.create_organization!("Acme") # Positional arg
444
569
  user.create_organization!(name: "Acme") # Keyword arg (both work)
@@ -446,6 +571,10 @@ user.leave_organization!(org)
446
571
  user.leave_current_organization! # Leave the active org
447
572
  user.send_organization_invite_to!(email) # Invite to current org
448
573
  user.send_organization_invite_to!(email, organization: org) # Invite to specific org
574
+ user.create_personal_organization! # Create it, or return the existing one
575
+
576
+ # Account deletion
577
+ user.account_deletion_plan # What user.destroy would do (changes nothing)
449
578
  ```
450
579
 
451
580
  ### Organization methods
@@ -460,6 +589,7 @@ org.pending_invitations # Invitations not yet accepted
460
589
 
461
590
  # Queries
462
591
  org.owner # User who owns this org
592
+ org.owner_membership # The owner's Membership
463
593
  org.admins # Users with admin role or higher
464
594
  org.has_member?(user) # "Does org have this user as a member?"
465
595
  org.has_any_members? # "Does org have any members?"
@@ -467,16 +597,28 @@ org.member_count # Number of members
467
597
 
468
598
  # Class methods / Scopes
469
599
  Organizations::Organization.with_member(user) # Find all orgs where user is a member
600
+ Organizations::Organization.personal # Personal organizations
601
+ Organizations::Organization.not_personal # Every other organization
602
+ Organizations::Organization.backfill_personal_owners!(mapping, dry_run: true)
603
+
604
+ # Personal organizations
605
+ org.personal? # "Is this someone's personal organization?"
606
+ org.personal_owner # The user it belongs to
607
+ org.convert_to_shared! # Stop being personal (needed before a transfer)
470
608
 
471
609
  # Actions
472
- org.add_member!(user, role: :member)
473
- org.remove_member!(user)
474
- org.change_role_of!(user, to: :admin)
610
+ # Pass the acting user (removed_by:, changed_by:) so the role rule is checked;
611
+ # without one, or with a user who is not a member, nothing is checked.
612
+ org.add_member!(user, role: :member) # no actor: never checked, authorize it yourself
613
+ org.remove_member!(user, removed_by: current_user)
614
+ org.change_role_of!(user, to: :admin, changed_by: current_user)
475
615
  org.transfer_ownership_to!(other_user)
476
616
 
477
617
  # Invitations (inviter must be a member with :invite_members permission)
478
618
  org.send_invite_to!(email) # Auto-infers invited_by from Current.user
479
619
  org.send_invite_to!(email, invited_by: user) # Explicit inviter
620
+ org.send_invite_to!(email, invited_by: user, grants: { record => %i[read] }) # With scoped grants
621
+ org.grants # Every grant of every membership
480
622
 
481
623
  # Scopes
482
624
  org.memberships.owners # Memberships with owner role
@@ -490,7 +632,7 @@ Organizations::Organization.create_with_owner!(owner: admin, name: "Acme Corp")
490
632
 
491
633
  # Verified joining
492
634
  org.domains / org.join_codes / org.allowlist_entries / org.join_requests
493
- org.add_domain!("acme.com", membership_metadata: {})
635
+ org.add_domain!("acme.com", membership_metadata: {}, join_policy: :none)
494
636
  org.generate_join_code!(label:, requires_verified_domain_email:, auto_approve:, expires_at:, max_uses:)
495
637
  org.import_allowlist!(emails, source:, membership_metadata: {})
496
638
  org.approve_join_request!(req, approved_by:) / org.reject_join_request!(req, rejected_by:, reason:)
@@ -503,6 +645,14 @@ org.join_codes.active # SQL twin of code.active?
503
645
  # The join kit (controller/view layer — see "Verified joining")
504
646
  Organizations::JoinFlow.attempt(user:, organization:, code:, email:, verification_code:, message:)
505
647
  Organizations::JoinState.for(user:, organization:, result:)
648
+ # rails g organizations:join → OrganizationJoinsController + OrganizationAccessController + routes
649
+
650
+ # Domain authority (see "Domain authority")
651
+ domain.start_verification! / domain.verify! / domain.verify_by_operator!(operator:, note:)
652
+ domain.verification_record_name / domain.verification_record_value
653
+ domain.verified? / domain.revoked? / domain.joinable? / domain.effective_join_policy
654
+ domain.revoke_verification!(reason:) / domain.recheck_verification!
655
+ Organizations::Domain.verified / Organizations::Domain.joinable / Organizations::Domain.with_join_policy(:suggest)
506
656
 
507
657
  # Housekeeping
508
658
  Organizations::JoinRequest.purge_stale!(older_than: 12.months) # GDPR sweep: old decided/expired requests
@@ -522,13 +672,29 @@ membership.invited_by # User who invited them (if any)
522
672
  # Permission checks
523
673
  membership.has_permission_to?(:invite_members) # => true/false
524
674
  membership.permissions # => [:view_members, :invite_members, ...]
675
+ membership.role_sym # => :admin
676
+ membership.owner? / admin? / member? / viewer? # Exact role
677
+ membership.verified? # Joined with a proven email (verified joining)
525
678
 
526
679
  # Role hierarchy checks
527
680
  membership.is_at_least?(:member) # => true (if member, admin, or owner)
681
+ membership.can_assign_role?(:admin) # => true (if admin or owner: roles at or below their own)
682
+ membership.compare_role(other) # => -1 (higher), 0, 1 (lower)
683
+ membership.authorize_role_change!(:admin, changed_by: current_user) # Raises NotAuthorized when a member actor outranks or lacks the roles' permissions (does not check :edit_member_roles; nil or non-member actors pass: authorize them yourself)
528
684
 
529
685
  # Role changes
530
- membership.promote_to!(:admin) # Change role to admin
531
- membership.demote_to!(:member) # Change role to member
686
+ membership.promote_to!(:admin, changed_by: current_user) # Change role to admin
687
+ membership.demote_to!(:member, changed_by: current_user) # Change role to member
688
+
689
+ # Scoped resource grants (see "Scoped resource grants")
690
+ membership.grants # Its grants
691
+ membership.grant!(record, capabilities: %i[read], granted_by: current_user)
692
+ membership.revoke_grant!(record, revoked_by: current_user)
693
+ membership.can?(:read, record) / membership.capabilities_for(record)
694
+ membership.granted?(record) / membership.grant_for(record)
695
+ membership.limited_by_grants? # Role below config.full_resource_access_role
696
+ membership.visible(relation, capability: nil) / membership.granted_ids(Model, capability: nil)
697
+ Organizations::Membership.with_grant_on(record) # Memberships granted this record
532
698
  ```
533
699
 
534
700
  ### Invitation methods
@@ -542,11 +708,75 @@ invitation.from # Alias for invited_by
542
708
  invitation.pending? # => true (not yet accepted)
543
709
  invitation.accepted? # => true (has accepted_at)
544
710
  invitation.expired? # => true (past expires_at)
711
+ invitation.status # => :pending, :accepted or :expired
712
+ invitation.for_email?(email) # Case-insensitive match
713
+ invitation.acceptance_url # Link for your own mailer (honors the engine mount path)
714
+ invitation.acceptance_url(base_url: "https://app.example.com") # In jobs/mailers without URL defaults
715
+
716
+ # Grants the membership gets on acceptance
717
+ invitation.grants
718
+ invitation.grant!(record, capabilities: %i[read])
545
719
 
546
720
  # Actions
547
721
  invitation.accept! # Accept (auto-infers Current.user)
548
722
  invitation.accept!(user) # Accept with explicit user
549
- invitation.resend! # Send invitation email again
723
+ invitation.resend!(resent_by: current_user) # Send invitation email again
724
+ invitation.revoke!(revoked_by: current_user) # Delete it, so the link stops working
725
+ ```
726
+
727
+ ### Verified-joining records
728
+
729
+ ```ruby
730
+ # Organizations::JoinRequest
731
+ request.pending? / approved? / rejected? / withdrawn? / expired? / decided?
732
+ request.effective_status # => :pending, :approved, :rejected, :withdrawn or :expired
733
+ request.email_verified?
734
+ request.start_email_verification!(email: "j.doe@acme.com")
735
+ request.verify_email_code!("492817") # => Membership (or the request, when approval is manual)
736
+ request.approve!(decided_by: admin) # => Membership. Records the actor; checking that it may decide is your job
737
+ request.reject!(rejected_by: admin, reason: "unknown")
738
+ request.withdraw! # The user cancels it. Check that the caller owns the request
739
+ user.pending_join_request_for(org) # The user's open request, or nil
740
+
741
+ # Organizations::JoinCode
742
+ code.code / code.display_code # "7FHK2MPX" / "7FHK-2MPX"
743
+ code.active? / revoked? / expired? / exhausted? / code.status
744
+ code.redeem!(user: user) # Same as JoinCode.redeem(code.code, user:)
745
+ code.revoke!
746
+
747
+ # Organizations::Domain
748
+ domain.matches_email?("j.doe@acme.com") # Exact domain match
749
+ domain.blocked? # Listed in config.blocked_email_domains
750
+ domain.verification_expired? # The open DNS challenge passed its TTL
751
+ # (verification methods: see "Domain authority")
752
+
753
+ # Organizations::AllowlistEntry
754
+ entry.claimed? / entry.claim!(user)
755
+
756
+ # Organizations::Grant (see "Scoped resource grants")
757
+ grant.capabilities / grant.allows?(:read) / grant.resource / grant.grantable
758
+ # grant.allows? reads the stored list only. To authorize, ask membership.can?(capability, record):
759
+ # it also applies the role ceiling and the organization check.
760
+ ```
761
+
762
+ ### Roles
763
+
764
+ ```ruby
765
+ Organizations::Roles.hierarchy # => [:owner, :admin, :member, :viewer] (custom roles included)
766
+ Organizations::Roles.valid_role?(:admin) # => true
767
+ Organizations::Roles.permissions_for(:member) # => [:view_organization, ...]
768
+ Organizations::Roles.has_permission?(:admin, :invite_members) # => true
769
+ Organizations::Roles.at_least?(:owner, :admin) # => true
770
+ Organizations::Roles.compare(:admin, :member) # => -1 (higher), 0, 1 (lower)
771
+ Organizations::Roles.higher_role(:member) / lower_role(:member) # => :admin / :viewer
772
+ Organizations::Roles.can_assign?(:admin, :member) # the role rule (see "Customize roles")
773
+ Organizations::Roles.assignment_refusal(:member, :admin) # => :outranks, :lacks_permissions or nil
774
+ ```
775
+
776
+ ### User housekeeping
777
+
778
+ ```ruby
779
+ user.clear_organization_cache! # Forget the memoized current organization and membership
550
780
  ```
551
781
 
552
782
  ## Controller helpers
@@ -596,6 +826,7 @@ redirect_to_no_organization!(alert: "...", notice: "...") # Redirect and
596
826
  # Organization creation helper
597
827
  create_organization_and_switch!(current_user, name: "Acme") # Create and set context in one call
598
828
  create_organization_with_context!(current_user, name: "Acme") # Backward-compatible alias
829
+ self.current_organization = org # Set the session's organization (no membership check; prefer switch_to_organization!)
599
830
 
600
831
  # Authorization
601
832
  require_organization! # Redirect if no active org
@@ -698,6 +929,32 @@ The helper returns:
698
929
  }
699
930
  ```
700
931
 
932
+ ### All view helpers
933
+
934
+ ```ruby
935
+ organization_switcher_data # See "Organization switcher"
936
+ organization_invitation_badge(user) # See "Invitation badge"
937
+
938
+ organization_role_label(:admin, organization: org) # => "Admin" (I18n; the organization selects its vocabulary)
939
+ organization_role_info(:admin, organization: org) # => { role: :admin, label: "Admin", color: :blue }
940
+ organization_invitation_status(invitation) # => :pending, :accepted or :expired
941
+ organization_invitation_status_label(invitation) # => "Pending"
942
+ organization_invitation_status_info(invitation) # => { status:, label:, color: }
943
+ organization_members_data(org) # => [{ id:, membership_id:, name:, email:, role:, role_label:, role_info:, joined_at:, is_owner: }]
944
+ organization_invitations_data(org) # => [{ id:, email:, role:, role_label:, invited_by:, invited_by_name:, status:, status_info:, expires_at:, created_at: }]
945
+
946
+ can_manage_organization?(user, org) # manage_settings
947
+ can_invite_members?(user, org) # invite_members
948
+ can_view_billing?(user, org) # view_billing
949
+ can_manage_billing?(user, org) # manage_billing
950
+ can_remove_member?(user, membership) # remove_members, and the role rule
951
+ can_change_member_role?(user, membership) # edit_member_roles, and the role rule
952
+ can_transfer_ownership?(user, org) # transfer_ownership
953
+ can_delete_organization?(user, org) # delete_organization
954
+ ```
955
+
956
+ Inside engine views, the host app's route helpers work without `main_app.` (the engine delegates unknown `*_path` and `*_url` helpers to the host).
957
+
701
958
  ### Invitation badge
702
959
 
703
960
  ```ruby
@@ -739,7 +996,124 @@ authorize! :destroy, @project # "Can they delete THIS specific project?"
739
996
  | **Based on** | Role in Membership | Policy classes / Ability rules |
740
997
  | **Example** | "Admins can invite members" | "Users can edit their own posts" |
741
998
 
742
- **Most B2B apps need both.** Use `organizations` for org membership and capabilities. Use Pundit/CanCanCan for fine-grained resource authorization. They're complementary, not competing.
999
+ **Most B2B apps need both.** Use `organizations` for org membership and capabilities. Use Pundit/CanCanCan for fine-grained resource authorization. They're complementary, not competing. Between the two sits one common case the gem covers: **some members see only some records** (an accountant who handles three of a firm's clients, a contractor on one project). See "Scoped resource grants".
1000
+
1001
+ ### Scoped resource grants
1002
+
1003
+ A grant gives one membership access to one record of your app, with a list of capabilities. Members whose role ranks below `config.full_resource_access_role` (`:admin` by default) see only the records granted to them. Admins and the owner see everything through their role. **The role stays the ceiling:** a member can use a granted capability only when the role also has it as a permission. A grant narrows; it never widens.
1004
+
1005
+ It is optional. Add the table, then mark the models you grant:
1006
+
1007
+ ```bash
1008
+ rails g organizations:grants # check the type of grantable_id, then:
1009
+ rails db:migrate
1010
+ ```
1011
+
1012
+ Capabilities are checked against the role's permissions, so give your roles the capabilities they may use on granted records (custom roles work too):
1013
+
1014
+ ```ruby
1015
+ # A block that redefines a built-in role defines only the built-in roles it
1016
+ # lists, so list all four: these are the defaults plus the capabilities.
1017
+ config.roles do
1018
+ role :viewer do
1019
+ can :view_organization
1020
+ can :view_members
1021
+ can :read
1022
+ end
1023
+ role :client, inherits: :viewer do # a narrow custom role
1024
+ can :comment
1025
+ end
1026
+ role :member, inherits: :viewer do
1027
+ can :create_resources
1028
+ can :edit_own_resources
1029
+ can :delete_own_resources
1030
+ can :comment
1031
+ can :edit
1032
+ end
1033
+ role :admin, inherits: :member do
1034
+ can :invite_members
1035
+ can :remove_members
1036
+ can :edit_member_roles
1037
+ can :manage_settings
1038
+ can :view_billing
1039
+ end
1040
+ role :owner, inherits: :admin do
1041
+ can :manage_billing
1042
+ can :transfer_ownership
1043
+ can :delete_organization
1044
+ end
1045
+ end
1046
+ ```
1047
+
1048
+ ```ruby
1049
+ class Project < ApplicationRecord
1050
+ include Organizations::Grantable # project.organization_grants, Project.visible_to(membership)
1051
+ end
1052
+ ```
1053
+
1054
+ ```ruby
1055
+ membership = org.memberships.find_by!(user: contractor)
1056
+
1057
+ membership.grant!(project, capabilities: %i[read edit], granted_by: current_user)
1058
+ membership.grant!(project, capabilities: %i[read]) # again: replaces the capabilities
1059
+ membership.grant!(project, capabilities: []) # no capabilities: removes the grant
1060
+ membership.revoke_grant!(project, revoked_by: current_user)
1061
+
1062
+ membership.can?(:edit, project) # grant lists it AND the role has the :edit permission
1063
+ membership.capabilities_for(project) # => #<Set: {:read}>
1064
+ membership.granted?(project)
1065
+ membership.grant_for(project) # => #<Organizations::Grant capabilities: ["read"]>
1066
+ membership.limited_by_grants? # => true for a member, false for an admin
1067
+
1068
+ Project.visible_to(membership) # what they may see
1069
+ Project.visible_to(membership, capability: :edit) # …and edit
1070
+ membership.visible(Project.active) # the same, for any relation
1071
+ membership.granted_ids(Project, capability: :edit) # just the ids
1072
+
1073
+ org.memberships.with_grant_on(project) # who was granted this project
1074
+ org.grants # every grant in the organization
1075
+ Organizations::Grant.for_resource(project)
1076
+ ```
1077
+
1078
+ Records that are not ActiveRecord models (rows read through another library, ids from an API) work by type and id:
1079
+
1080
+ ```ruby
1081
+ entity = Organizations::Resource.new("Ledger::Entity", entity_id)
1082
+ membership.grant!(entity, capabilities: %w[read draft])
1083
+ membership.granted_ids("Ledger::Entity") # => [entity_id, ...]
1084
+ ```
1085
+
1086
+ Any object with an `id` also works; its class name is the type.
1087
+
1088
+ **Invitations carry grants.** The grants move to the membership when the invitation is accepted (also when the invitee was already a member):
1089
+
1090
+ ```ruby
1091
+ org.send_invite_to!("client@example.com", invited_by: current_user, role: :client,
1092
+ grants: { project => %i[read comment], other_project => %i[read] })
1093
+ current_user.send_organization_invite_to!(email, role: :client, grants: { project => %i[read] })
1094
+ invitation.grant!(project, capabilities: %i[read]) # add one later
1095
+ ```
1096
+
1097
+ The rules:
1098
+
1099
+ - **Who may grant.** `granted_by:` (or `revoked_by:`, or the inviter) must be a member: it needs `config.grant_management_permission` (`:edit_member_roles` by default), it may act only on members whose role ranks at or below its own, and, when grants limit it too, it may give only access it has on that record. An account that is not a member is refused. A nil actor (a console, a job, your own authorization) is not checked.
1100
+ - **The role is the ceiling everywhere.** `can?`, `capabilities_for`, `granted_ids` and `visible_to` all count only capabilities that the grant lists and the role has. A grant that lists none of the role's capabilities gives nothing.
1101
+ - **Organizations stay apart.** For a model with `organization_id`, `visible_to` returns only the membership's organization's records (admins included), `can?` is false for another organization's record (also through `Organizations::Resource`, which reads its `organization_id`), and `grant!` raises `ArgumentError`. A record moved to another organization loses its access.
1102
+ - **Invitation grants are checked twice:** when they are attached, and again at acceptance against the inviter as it is then and the membership that receives them (an existing member may hold a higher role). A grant that fails the second check is dropped and logged. Grants cannot be added to an accepted invitation, and a reissued expired invitation carries only the new grants.
1103
+ - **Ids must fit the column.** `grantable_id` has your primary key type by default. An id it would change (`"42-a"` in an integer column) raises `ArgumentError`. For resources with other key types, make the column `:string` (or `:uuid`) in the generated migration before you migrate.
1104
+ - **Capability names.** Any name by default. To catch typos, list them: `config.grant_capabilities = %i[read edit comment]`.
1105
+ - **Removing a member removes their grants.** Deleting a `Grantable` record removes its grants. Changing a role keeps the grants: a member promoted to admin sees everything, and sees only the granted records again after a demotion.
1106
+ - **Concurrent grants converge** on one row per membership and record (unique index).
1107
+ - **Deleting an account** keeps the grants it gave to members (they were valid then) and deletes the grants it attached to pending invitations. A grant whose capabilities `config.grant_capabilities` no longer allows is dropped at acceptance, not fatal.
1108
+ - **Performance.** `can?` runs one query per record unless the grants are loaded: `memberships.includes(:grants)` or `membership.grants.load` before you check a list. `granted_ids` and `visible_to` load the membership's grants of that type and filter them in Ruby (the capability lists are JSON), then query with `WHERE id IN (...)`. That suits hundreds of grants per member; for far more, keep your own index of granted ids.
1109
+
1110
+ Configuration:
1111
+
1112
+ ```ruby
1113
+ config.full_resource_access_role = :admin # roles at or above see every record; nil = grants limit every role
1114
+ config.grant_capabilities = nil # or a list of allowed names
1115
+ config.grant_management_permission = :edit_member_roles
1116
+ ```
743
1117
 
744
1118
  ### Built-in roles
745
1119
 
@@ -751,6 +1125,10 @@ owner > admin > member > viewer
751
1125
 
752
1126
  Each role inherits all permissions from roles below it.
753
1127
 
1128
+ A role acts only on roles at or below its own in `Roles.hierarchy`. That covers invitations (the invited role), role changes (both the old and the new role), removals (the removed member's role) and invitation management (revoking or resending an invitation for a role). Below admin, a role must also hold every permission of the role it acts on, because two custom roles with the same parent are ranked only by definition order. So a custom role with `invite_members`, `edit_member_roles` and `remove_members` cannot invite, promote, demote or remove an admin, cannot hand out a sibling role with powers it lacks, and cannot touch an admin's invitation. Admins and the owner keep the rank rule alone, and the owner keeps its own rules.
1129
+
1130
+ The check applies to the member passed as `invited_by:`, `changed_by:`, `removed_by:`, `revoked_by:` or `resent_by:`. The engine controllers always pass the current user. A nil actor, or one who is not a member of the organization, is not checked, so pass the acting user whenever you call these methods from your own code. A few paths take no actor and are never checked: `add_member!`, `approve_join_request!`, `Invitation#accept!` (the invitation was checked when it was sent) and direct `Membership` writes. A refusal raises `Organizations::NotAuthorized`, which the engine controllers answer through `config.unauthorized_handler` like any failed permission guard (an alert, or a 403 over JSON, by default). `membership.can_assign_role?(role)` tells you which roles to offer in a role picker, as the generated views do.
1131
+
754
1132
  ### Default permissions
755
1133
 
756
1134
  | Permission | viewer | member | admin | owner |
@@ -826,7 +1204,8 @@ require_organization_role!(:client) # clients and everyone above them
826
1204
  ```
827
1205
 
828
1206
  - 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.
1207
+ - A custom role without `inherits:` sits at the bottom of the hierarchy, below viewer. Two custom roles with the same parent stack in the order you define them, the first one highest.
1208
+ - Because a role acts only on roles at or below its own, a custom role without `inherits:` that can `invite_members` can invite only to its own role: inviting with the default `member` role raises `NotAuthorized`. Give such a role the parent it should invite to, for example `role :recruiter, inherits: :member do can :invite_members end`.
830
1209
  - 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
1210
  - 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
1211
  - 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.
@@ -992,7 +1371,7 @@ Expired invitations can be resent:
992
1371
 
993
1372
  ```ruby
994
1373
  invitation.expired? # => true
995
- invitation.resend! # Generates new token, resets expiry, sends email
1374
+ invitation.resend!(resent_by: current_user) # Generates new token, resets expiry, sends email
996
1375
  ```
997
1376
 
998
1377
  ### Accepted invitations
@@ -1119,6 +1498,93 @@ Organizations.configure do |config|
1119
1498
  end
1120
1499
  ```
1121
1500
 
1501
+ ### Personal organizations
1502
+
1503
+ An auto-created organization is an ordinary organization: nothing in the database says that it is the user's own workspace. `user.organizations.first` is not a personal-workspace lookup. Turn on `record_personal_organizations` to keep a persistent link from the user to the one organization that is theirs:
1504
+
1505
+ ```ruby
1506
+ Organizations.configure do |config|
1507
+ config.always_create_personal_organization_for_each_user = true
1508
+ config.record_personal_organizations = true # default: false
1509
+ end
1510
+ ```
1511
+
1512
+ ```ruby
1513
+ user.personal_organization # => the organization, or nil. Never creates anything.
1514
+ user.create_personal_organization! # creates it, or returns the existing one
1515
+ user.create_personal_organization!("Ana's space")
1516
+
1517
+ org.personal? # => true
1518
+ org.personal_owner # => the user
1519
+ Organizations::Organization.personal # every personal organization
1520
+ Organizations::Organization.not_personal # every other organization
1521
+ ```
1522
+
1523
+ The rules:
1524
+
1525
+ - The link is `organizations.personal_owner_id`, with a unique index. A user has at most one personal organization.
1526
+ - The personal owner is also the owner membership. The gem writes both together, and `create_with_owner!` and `create_organization!` refuse a `personal_owner` who is not the new owner. `org.transfer_ownership_to!` raises `CannotTransferPersonalOrganization` for a personal organization: call `org.convert_to_shared!` first.
1527
+ - A personal organization can have other members. To keep it owner-only, veto joins in the membership gate, which covers every join path:
1528
+
1529
+ ```ruby
1530
+ config.on_member_joining do |ctx|
1531
+ raise Organizations::MembershipVetoed, "This is a personal workspace." if ctx.organization.personal?
1532
+ end
1533
+ ```
1534
+
1535
+ - Sign-up provisioning logs a failure and keeps the user, as before. `user.create_personal_organization!` is the repair path: it is idempotent, and concurrent calls converge on one organization.
1536
+ - An invited sign-up that skips auto-creation (see "Pattern 4: Hybrid Onboarding") gets no personal organization.
1537
+ - Existing organizations stay unclassified after the upgrade. The gem never guesses from a name, a member count or the oldest organization. To link organizations that you have reviewed, pass the mapping:
1538
+
1539
+ ```ruby
1540
+ report = Organizations::Organization.backfill_personal_owners!({ user => org, other_user.id => other_org.id }, dry_run: true)
1541
+ report.assignments # => [[user_id, organization_id], ...]
1542
+ report.unchanged # pairs that are already linked
1543
+ report.conflicts # => [{ user_id:, organization_id:, reason: :not_the_owner }, ...]
1544
+ Organizations::Organization.backfill_personal_owners!(mapping) # writes nothing when any pair conflicts
1545
+ ```
1546
+
1547
+ Conflict reasons: `:organization_not_found`, `:not_the_owner`, `:organization_personal_for_another_user`, `:user_has_another_personal_organization`, `:organization_mapped_twice`, `:user_mapped_twice`. The write runs in its own savepoint: a conflict found at write time (a concurrent provisioning) rolls back every assignment and comes back in the report.
1548
+
1549
+ A personal organization tells you who probably pays. Billing and entitlements stay with your app.
1550
+
1551
+ ## Deleting user accounts
1552
+
1553
+ By default a user who owns an organization cannot be deleted: `user.destroy` returns false with an error, and `user.destroy!` raises `ActiveRecord::RecordNotDestroyed`. Transfer ownership or delete those organizations first. The gem cannot cancel subscriptions or call external services for you, so run those steps in your own account-closure workflow, after the deletion commits.
1554
+
1555
+ Preview what a deletion would do. Nothing changes, and nothing is locked: the plan is a preview for a confirmation screen, and only `user.destroy` decides.
1556
+
1557
+ ```ruby
1558
+ plan = user.account_deletion_plan
1559
+ plan.allowed? # => false
1560
+ plan.blockers # => [#<struct organization=#<Organization "Acme">, reason=:owns_organization_with_members>]
1561
+ plan.organizations_to_destroy # organizations deleted with the account
1562
+ plan.memberships_to_remove # the user's other memberships, removed with the account
1563
+ plan.to_h # ids and symbols, for an audit log or a confirmation screen
1564
+ user.account_deletion_plan(policy: :destroy_personal_organization) # preview another policy
1565
+ ```
1566
+
1567
+ Apps with personal organizations can let a user delete their account together with their personal organization:
1568
+
1569
+ ```ruby
1570
+ config.account_deletion_policy = :destroy_personal_organization # default: :prevent
1571
+ ```
1572
+
1573
+ | The user owns… | `:prevent` | `:destroy_personal_organization` |
1574
+ |---|---|---|
1575
+ | nothing | deleted | deleted |
1576
+ | their personal organization, as its only member | blocked (`:personal_organization`) | deleted with the organization |
1577
+ | their personal organization, with other members | blocked (`:personal_organization_with_members`) | blocked |
1578
+ | another organization, as its only member | blocked (`:owns_organization`) | blocked |
1579
+ | another organization, with other members | blocked (`:owns_organization_with_members`) | blocked |
1580
+
1581
+ What the gem guarantees:
1582
+
1583
+ - It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
1584
+ - The organization deletion and the user deletion are one transaction, with its own savepoint, also inside a transaction of yours. A host `before_destroy` veto on the organization, or any later failure, rolls back everything, and the veto message is on `user.errors`.
1585
+ - The guard locks the user row and then the owned organization rows. A concurrent join or organization creation for that user, a join or transfer in an owned organization, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it.
1586
+ - The user's other memberships are deleted (`dependent: :destroy`), as before. Invitations they sent, join requests they decided, join codes they created and allowlist entries they claimed keep the record and lose the user reference.
1587
+
1122
1588
  ## User Onboarding Patterns
1123
1589
 
1124
1590
  The `always_create_personal_organization_for_each_user` setting controls how new users get their first organization. This is one of the most important decisions when integrating the gem.
@@ -1286,6 +1752,10 @@ Full configuration options:
1286
1752
  # config/initializers/organizations.rb
1287
1753
  Organizations.configure do |config|
1288
1754
  # === Authentication ===
1755
+ # Class name of your user model (default: "User"). Set it before the gem's
1756
+ # models load (initializers are fine).
1757
+ config.user_class = "User"
1758
+
1289
1759
  # Method that returns the current user (default: :current_user)
1290
1760
  config.current_user_method = :current_user
1291
1761
 
@@ -1299,6 +1769,14 @@ Organizations.configure do |config|
1299
1769
  # Name for auto-created organizations
1300
1770
  config.default_organization_name = ->(user) { "Personal" }
1301
1771
 
1772
+ # Record the auto-created organization as user.personal_organization (default: false)
1773
+ config.record_personal_organizations = false
1774
+
1775
+ # What deleting a user who owns organizations does (default: :prevent).
1776
+ # :destroy_personal_organization also deletes their personal organization
1777
+ # when they are its only member. See "Deleting user accounts".
1778
+ config.account_deletion_policy = :prevent
1779
+
1302
1780
  # === Invitations ===
1303
1781
  # How long invitations are valid
1304
1782
  config.invitation_expiry = 7.days
@@ -1306,6 +1784,39 @@ Organizations.configure do |config|
1306
1784
  # Custom mailer for invitations
1307
1785
  config.invitation_mailer = "Organizations::InvitationMailer"
1308
1786
 
1787
+ # Role of invited members when the inviter names none (default: :member)
1788
+ config.default_invitation_role = :member
1789
+
1790
+ # === Verified joining ===
1791
+ # A custom mailer needs code_email(join_request, plaintext_code) returning a
1792
+ # message (the gem calls .deliver_later). The plaintext code exists only in
1793
+ # that call: never store or log it.
1794
+ config.verification_mailer = "Organizations::VerificationMailer"
1795
+ config.verification_code_ttl = 15.minutes # emailed codes always expire
1796
+ config.verification_max_attempts = 5 # wrong codes per challenge
1797
+ config.verification_resend_interval = 60.seconds # between code sends
1798
+ config.verification_max_sends = 5 # code sends per join request
1799
+ config.verification_email_normalizer = nil # nil = downcase, strip, drop +tag; or ->(email) { ... }
1800
+ config.trust_confirmed_account_email = true # confirmed account email = proof of inbox (join_with_account_email!)
1801
+ config.join_request_expiry = 30.days # nil = pending requests never expire
1802
+ config.join_code_generator = nil # nil = 8 characters without lookalikes; or -> { ... }
1803
+
1804
+ # === Scoped resource grants (see "Scoped resource grants") ===
1805
+ config.full_resource_access_role = :admin # nil = grants limit every role
1806
+ config.grant_capabilities = nil # or %i[read edit ...]
1807
+ config.grant_management_permission = :edit_member_roles
1808
+
1809
+ # === Vocabulary (see "Vocabulary") ===
1810
+ config.vocabulary = nil # or :classroom, or ->(organization) { ... }
1811
+
1812
+ # === Domain authority (see "Domain authority") ===
1813
+ config.require_verified_domains = false # admit only through verified domains
1814
+ config.domain_verification_record_prefix = "_organizations-verification"
1815
+ config.domain_verification_ttl = 7.days
1816
+ config.domain_verification_timeout = 5 # seconds
1817
+ config.domain_txt_resolver = nil # nil = Resolv; or ->(name) { [String] }
1818
+ config.blocked_email_domains = [] # e.g. Organizations::PUBLIC_EMAIL_DOMAINS
1819
+
1309
1820
  # === Limits ===
1310
1821
  # Maximum organizations a user can own (nil = unlimited)
1311
1822
  config.max_organizations_per_user = nil
@@ -1347,12 +1858,19 @@ Organizations.configure do |config|
1347
1858
  config.no_organization_alert = "Please create an organization first."
1348
1859
  config.no_organization_notice = "Please create or join an organization to continue."
1349
1860
 
1861
+ # === Session ===
1862
+ # Session key that stores the current organization id
1863
+ config.session_key = :current_organization_id
1864
+
1350
1865
  # === Organizations Controller ===
1351
1866
  # Additional params to permit when creating/updating organizations
1352
1867
  # Use this to add custom fields like support_email, billing_email, logo
1353
1868
  config.additional_organization_params = [:support_email]
1354
1869
 
1355
1870
  # === Engine Controllers ===
1871
+ # Engine route groups to draw (nil = all). See "Installation".
1872
+ config.engine_routes = { except: [:organizations] }
1873
+
1356
1874
  # Base controller for authenticated routes (default: ::ApplicationController)
1357
1875
  config.parent_controller = "::ApplicationController"
1358
1876
 
@@ -1367,6 +1885,9 @@ Organizations.configure do |config|
1367
1885
  config.authenticated_controller_layout = "dashboard"
1368
1886
  config.public_controller_layout = "devise"
1369
1887
 
1888
+ # Host helper modules for the public (invitation) pages
1889
+ config.public_controller_helpers = ["ApplicationHelper"]
1890
+
1370
1891
  # === Handlers ===
1371
1892
  # Called when authorization fails
1372
1893
  config.on_unauthorized do |context|
@@ -1389,7 +1910,12 @@ Organizations.configure do |config|
1389
1910
  end
1390
1911
 
1391
1912
  config.on_member_invited do |ctx|
1392
- # ctx.organization, ctx.invitation, ctx.invited_by
1913
+ # ctx.organization, ctx.invitation, ctx.invited_by (strict: raise to veto)
1914
+ end
1915
+
1916
+ config.on_member_joining do |ctx|
1917
+ # THE membership gate (strict): raise Organizations::MembershipVetoed to veto
1918
+ # ctx.organization, ctx.user, ctx.role, ctx.joined_via, ctx.invitation / ctx.join_request
1393
1919
  end
1394
1920
 
1395
1921
  config.on_member_joined do |ctx|
@@ -1407,6 +1933,11 @@ Organizations.configure do |config|
1407
1933
  config.on_ownership_transferred do |ctx|
1408
1934
  # ctx.organization, ctx.old_owner, ctx.new_owner
1409
1935
  end
1936
+
1937
+ config.on_join_request_created { |ctx| } # ctx.organization, ctx.user, ctx.join_request
1938
+ config.on_join_request_approved { |ctx| } # …, ctx.membership, ctx.decided_by (nil when automatic)
1939
+ config.on_join_request_rejected { |ctx| } # …, ctx.decided_by
1940
+ config.on_verification_delivery_failed { |ctx| } # ctx.metadata["error_class"], ["error_message"]
1410
1941
  end
1411
1942
  ```
1412
1943
 
@@ -1641,7 +2172,7 @@ ActiveSupport.on_load(:organizations_membership) do
1641
2172
  end
1642
2173
  ```
1643
2174
 
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.
2175
+ **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`, `:organizations_grant`, plus `:organizations_application_controller` and `:organizations_public_controller` for the engine controllers.
1645
2176
 
1646
2177
  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
2178
 
@@ -1676,7 +2207,44 @@ class Portal::BaseController < ApplicationController
1676
2207
  end
1677
2208
  ```
1678
2209
 
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.
2210
+ Gate actions by permission instead of rank with `require_organization_permission :invite_members` (custom roles included; it takes `only:`/`except:` like any `before_action`). 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.
2211
+
2212
+ ### URL handles (slugs)
2213
+
2214
+ `OrganizationScoped` finds organizations by id unless you give it a finder. To address them by a handle such as `/org/acme/admin`, add the column yourself; the gem ships no slug schema:
2215
+
2216
+ ```ruby
2217
+ # db/migrate/xxx_add_handle_to_organizations.rb
2218
+ class AddHandleToOrganizations < ActiveRecord::Migration[8.1]
2219
+ def change
2220
+ add_column :organizations_organizations, :handle, :string
2221
+ add_index :organizations_organizations, :handle, unique: true # the database decides uniqueness
2222
+ end
2223
+ end
2224
+ ```
2225
+
2226
+ ```ruby
2227
+ # app/models/concerns/organization_handle.rb (register it with the :organizations_organization load hook)
2228
+ module OrganizationHandle
2229
+ extend ActiveSupport::Concern
2230
+ RESERVED = %w[admin api new settings join access].freeze # paths your app uses
2231
+
2232
+ included do
2233
+ before_validation { self.handle = handle.to_s.strip.downcase.presence }
2234
+ validates :handle, format: { with: /\A[a-z0-9][a-z0-9-]{1,38}[a-z0-9]\z/ }, exclusion: { in: RESERVED }, allow_nil: true
2235
+ end
2236
+ end
2237
+ ```
2238
+
2239
+ ```ruby
2240
+ self.organization_param = :handle
2241
+ self.organization_finder = ->(handle) { Organizations::Organization.find_by(handle: handle.to_s.downcase) }
2242
+ ```
2243
+
2244
+ - **Backfill populated installs before you require the column.** Generate a handle from the name, and on a collision add a suffix (`acme`, `acme-2`). The unique index catches the last race: rescue `ActiveRecord::RecordNotUnique` and try the next suffix.
2245
+ - **Do not override `to_param` globally.** The engine's routes find organizations by id, and every generated URL would break. Pass the handle explicitly in your own routes: `portal_path(handle: org.handle)`.
2246
+ - **Renames break old links.** Keep the old handle in a lookup table and redirect, or freeze handles after creation.
2247
+ - An existing slug gem (FriendlyId and others) works too: point `organization_finder` at its finder.
1680
2248
 
1681
2249
  ## Internationalization (i18n)
1682
2250
 
@@ -1692,7 +2260,83 @@ en:
1692
2260
  join_code_invalid: "Hmm, that code doesn't work."
1693
2261
  ```
1694
2262
 
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.
2263
+ 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. For wording that differs per organization, see "Vocabulary" below.
2264
+
2265
+ ### Vocabulary: different words for different organizations
2266
+
2267
+ A school calls its members students; a library calls them patrons. For one wording everywhere, override the ordinary keys as shown above. For organizations that need different wording, select a vocabulary:
2268
+
2269
+ ```ruby
2270
+ # One vocabulary for the whole app
2271
+ config.vocabulary = :classroom
2272
+
2273
+ # Or one per organization, from your own data (called only with an organization)
2274
+ config.vocabulary = ->(organization) { organization.metadata["vocabulary"] }
2275
+ ```
2276
+
2277
+ Then add only the keys that differ, under `organizations.vocabularies.<name>`:
2278
+
2279
+ ```yaml
2280
+ # config/locales/en.yml
2281
+ en:
2282
+ organizations:
2283
+ vocabularies:
2284
+ classroom:
2285
+ roles:
2286
+ member: "Student"
2287
+ admin: "Teacher"
2288
+ mailers:
2289
+ invitation:
2290
+ # A full sentence, not a noun inserted into an English sentence:
2291
+ # other languages need their own word order.
2292
+ subject: "%{inviter} added you to the class %{organization}"
2293
+ library:
2294
+ roles:
2295
+ member: "Patron"
2296
+ ```
2297
+
2298
+ Pass the organization record:
2299
+
2300
+ ```ruby
2301
+ Organizations.t("roles.member", organization: school) # => "Student"
2302
+ Organizations.t("roles.member", organization: library) # => "Patron"
2303
+ Organizations.t("roles.owner", organization: school) # => "Owner" (missing in the vocabulary: the ordinary key)
2304
+ Organizations.t("roles.member") # => "Member" (no organization: the resolver is not called)
2305
+ organization_role_label(:member, organization: school) # view helper => "Student"
2306
+ ```
2307
+
2308
+ The rules:
2309
+
2310
+ - **Lookup order:** when the vocabulary has the key, in the current locale or one of its I18n fallbacks, it wins. Otherwise the call is exactly the ordinary one: the `organizations.*` key with your overrides, then the caller's `default:` (Symbol defaults keep the `organizations.` scope). A resolver that returns `nil` selects the ordinary keys. Calls with an Array of keys or a custom `scope:` skip the vocabulary.
2311
+ - **A vocabulary entry is a complete translation.** Its interpolation variables must be supplied, and a plural entry needs every form your locale uses; I18n raises on a gap there instead of falling back to the ordinary key.
2312
+ - **`%{organization}`** interpolates the record's name. Only an `Organizations::Organization` record (or a subclass) selects a vocabulary; a String, a presenter or `nil` stays a plain interpolation value, as before.
2313
+ - **A vocabulary name is one label** (letters, digits, underscores). A resolver that raises or returns another name selects the ordinary wording and logs a warning. When the name comes from data that organization admins can edit, check it against your list of vocabularies.
2314
+ - **Count, interpolation and locale work as in I18n.** For plurals, give the vocabulary key `one:`/`other:` (and the other forms your locales need):
2315
+
2316
+ ```ruby
2317
+ Organizations.t("members_count", organization: school, count: 3) # => "3 students"
2318
+ ```
2319
+
2320
+ - **The organization is always explicit.** The gem never reads it from the session or a thread-global. Its own controllers, models and mailers pass the organization they act on, so two organizations rendered in one request, or interleaved jobs, never share wording.
2321
+ - **Mailers and background jobs:** pass the organization and set the locale, as with any I18n call:
2322
+
2323
+ ```ruby
2324
+ I18n.with_locale(user.locale) do
2325
+ Organizations.t(:"mailers.invitation.subject", organization: invitation.organization, inviter: inviter.name)
2326
+ end
2327
+ ```
2328
+
2329
+ - **`_html` keys** in your own views go through the Rails `t` helper (it escapes interpolations). Get the lookup chain from the gem:
2330
+
2331
+ ```erb
2332
+ <% primary, *fallbacks = Organizations.i18n_keys(:"mailers.invitation.invited_by_html", organization: @organization) %>
2333
+ <%= t(primary, default: fallbacks, inviter: @inviter_name, organization: @organization.name) %>
2334
+ ```
2335
+
2336
+ - **Display text only.** Calling an admin a teacher changes no stored role name, permission or authorization.
2337
+ - **Custom mailers and copied mailer views** that pass `organization: @organization.name` keep working, with the ordinary wording. Pass the record (`organization: @organization`) to get the vocabulary.
2338
+ - **Views copied before 0.7.0** print `role.capitalize`. To adopt vocabularies, change those calls to `organization_role_label(role, organization: the_organization)`, as the current `rails g organizations:views` templates do.
2339
+ - **Join codes keep one generic message.** The invalid-code and exhausted-code errors ignore the vocabulary, so a code's message never reveals which organization it belongs to.
1696
2340
 
1697
2341
  ## Database schema
1698
2342
 
@@ -1708,6 +2352,7 @@ organizations_organizations
1708
2352
  - name (string, required)
1709
2353
  - memberships_count (integer, counter cache, default: 0)
1710
2354
  - metadata (jsonb, default: {})
2355
+ - personal_owner_id (foreign key to users, nullable, unique; ON DELETE SET NULL)
1711
2356
  - created_at / updated_at
1712
2357
  ```
1713
2358
 
@@ -1751,6 +2396,23 @@ organizations_invitations
1751
2396
  partial unique index: [organization_id, lower(email)] where accepted_at is null
1752
2397
  ```
1753
2398
 
2399
+ ### organizations_grants (optional, `rails g organizations:grants`)
2400
+
2401
+ ```sql
2402
+ organizations_grants
2403
+ - id
2404
+ - membership_id (foreign key, nullable; ON DELETE CASCADE)
2405
+ - invitation_id (foreign key, nullable; ON DELETE CASCADE) — exactly one of the two (check constraint)
2406
+ - grantable_type (string) / grantable_id (the app's key type by default)
2407
+ - capabilities (jsonb array, at least one)
2408
+ - granted_by_id (foreign key to users, nullable; ON DELETE SET NULL)
2409
+ - created_at / updated_at
2410
+
2411
+ unique index: [membership_id, grantable_type, grantable_id]
2412
+ unique index: [invitation_id, grantable_type, grantable_id]
2413
+ index: [grantable_type, grantable_id]
2414
+ ```
2415
+
1754
2416
  ### organizations_domains
1755
2417
 
1756
2418
  ```sql
@@ -1758,6 +2420,11 @@ organizations_domains
1758
2420
  - id, organization_id (foreign key)
1759
2421
  - domain (string, required — stored lowercased, no leading '@')
1760
2422
  - membership_metadata / metadata (jsonb, default: {})
2423
+ - join_policy (string, default: 'none': none|suggest|auto_join)
2424
+ - verification_token (string, nullable) / verification_started_at (datetime)
2425
+ - verified_at (datetime) / verification_method (string: dns_txt|operator)
2426
+ - verified_by_id (foreign key to users, nullable; ON DELETE SET NULL)
2427
+ - verification_revoked_at (datetime)
1761
2428
  - created_at / updated_at
1762
2429
 
1763
2430
  unique index: [organization_id, domain]; index: [domain] (email → candidate orgs)
@@ -1949,16 +2616,6 @@ current_organization # Returns cached (subsequent calls)
1949
2616
  current_organization # Returns cached
1950
2617
  ```
1951
2618
 
1952
- ### Bulk operations
1953
-
1954
- For bulk invitations (coming in roadmap), the gem will support skipping per-record callbacks:
1955
-
1956
- ```ruby
1957
- # Future API
1958
- org.bulk_invite!(emails, skip_callbacks: true)
1959
- # Fires on_bulk_invited once instead of on_member_invited N times
1960
- ```
1961
-
1962
2619
  ## Data integrity
1963
2620
 
1964
2621
  The gem handles concurrent access and race conditions to ensure data consistency.
@@ -2023,7 +2680,7 @@ user.leave_organization!(org)
2023
2680
 
2024
2681
  **Role changes:**
2025
2682
  ```ruby
2026
- membership.promote_to!(:admin)
2683
+ membership.promote_to!(:admin, changed_by: current_user)
2027
2684
 
2028
2685
  # Internally:
2029
2686
  # 1. Lock membership row
@@ -2160,9 +2817,23 @@ Afterwards: `has_organizations` on `User`, move your model code into an extensio
2160
2817
  git clone https://github.com/rameerez/organizations
2161
2818
  cd organizations
2162
2819
  bin/setup
2163
- bundle exec rake test
2820
+ bundle exec rake # the test suite and RuboCop, as CI runs them
2821
+
2822
+ # Against each supported Rails version (Appraisal)
2823
+ bundle exec appraisal install
2824
+ bundle exec appraisal rails-7.2 rake
2825
+ bundle exec appraisal rails-8.1 rake
2826
+
2827
+ # The demo app and its HTTP suite (it is also the source of the generator templates)
2828
+ cd test/dummy
2829
+ bundle install
2830
+ bin/rails tailwindcss:build
2831
+ bin/rails db:test:prepare
2832
+ bin/rails test
2164
2833
  ```
2165
2834
 
2835
+ CI runs the suite on Ruby 3.3, 3.4 and 4.0 against Rails 7.2 and 8.1, once against PostgreSQL, plus RuboCop and the demo app's suite.
2836
+
2166
2837
  ## Contributing
2167
2838
 
2168
2839
  Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/organizations. Our code of conduct is: just be nice and make your mom proud of what you do and post online.