organizations 0.6.0 → 0.7.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 +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/CHANGELOG.md +93 -0
- data/README.md +687 -33
- data/app/controllers/organizations/invitations_controller.rb +27 -22
- data/app/controllers/organizations/memberships_controller.rb +37 -30
- data/app/controllers/organizations/organizations_controller.rb +8 -8
- data/app/controllers/organizations/public_invitations_controller.rb +1 -1
- data/app/mailers/organizations/invitation_mailer.rb +2 -2
- data/app/mailers/organizations/verification_mailer.rb +1 -1
- data/app/models/organizations/grant.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
- data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
- data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/config/routes.rb +1 -2
- data/gemfiles/rails_7.2.gemfile +5 -1
- data/gemfiles/rails_8.1.gemfile +5 -1
- data/lib/generators/organizations/grants/grants_generator.rb +47 -0
- data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
- data/lib/generators/organizations/install/install_generator.rb +1 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
- data/lib/generators/organizations/join/join_generator.rb +113 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
- data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
- data/lib/generators/organizations/join/templates/routes.rb +19 -0
- data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
- data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
- data/lib/generators/organizations/views/views_generator.rb +4 -6
- data/lib/organizations/account_deletion_plan.rb +118 -0
- data/lib/organizations/configuration.rb +152 -0
- data/lib/organizations/controller_helpers.rb +55 -15
- data/lib/organizations/grant_access.rb +75 -0
- data/lib/organizations/grant_holder.rb +152 -0
- data/lib/organizations/grantable.rb +28 -0
- data/lib/organizations/join_flow.rb +16 -12
- data/lib/organizations/models/concerns/has_organizations.rb +217 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +76 -7
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +87 -5
- data/lib/organizations/models/organization.rb +219 -48
- data/lib/organizations/organization_scoped.rb +16 -0
- data/lib/organizations/personal_owner_backfill.rb +122 -0
- data/lib/organizations/public_email_domains.rb +49 -0
- data/lib/organizations/resource.rb +11 -0
- data/lib/organizations/roles.rb +55 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
> !
|
|
399
|
-
> This gem handles organization membership and org-level permissions (who can invite members, who can manage billing). For per-record
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
org.
|
|
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)
|
|
531
|
-
membership.demote_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!
|
|
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,107 @@ 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
|
+
config.roles do
|
|
1016
|
+
role :viewer do
|
|
1017
|
+
can :view_organization
|
|
1018
|
+
can :read
|
|
1019
|
+
end
|
|
1020
|
+
role :client, inherits: :viewer do # a narrow custom role
|
|
1021
|
+
can :comment
|
|
1022
|
+
end
|
|
1023
|
+
role :member, inherits: :viewer do
|
|
1024
|
+
can :comment
|
|
1025
|
+
can :edit
|
|
1026
|
+
end
|
|
1027
|
+
# admin and owner as usual (they inherit member)
|
|
1028
|
+
end
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
```ruby
|
|
1032
|
+
class Project < ApplicationRecord
|
|
1033
|
+
include Organizations::Grantable # project.organization_grants, Project.visible_to(membership)
|
|
1034
|
+
end
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
```ruby
|
|
1038
|
+
membership = org.memberships.find_by!(user: contractor)
|
|
1039
|
+
|
|
1040
|
+
membership.grant!(project, capabilities: %i[read edit], granted_by: current_user)
|
|
1041
|
+
membership.grant!(project, capabilities: %i[read]) # again: replaces the capabilities
|
|
1042
|
+
membership.grant!(project, capabilities: []) # no capabilities: removes the grant
|
|
1043
|
+
membership.revoke_grant!(project, revoked_by: current_user)
|
|
1044
|
+
|
|
1045
|
+
membership.can?(:edit, project) # grant lists it AND the role has the :edit permission
|
|
1046
|
+
membership.capabilities_for(project) # => #<Set: {:read}>
|
|
1047
|
+
membership.granted?(project)
|
|
1048
|
+
membership.grant_for(project) # => #<Organizations::Grant capabilities: ["read"]>
|
|
1049
|
+
membership.limited_by_grants? # => true for a member, false for an admin
|
|
1050
|
+
|
|
1051
|
+
Project.visible_to(membership) # what they may see
|
|
1052
|
+
Project.visible_to(membership, capability: :edit) # …and edit
|
|
1053
|
+
membership.visible(Project.active) # the same, for any relation
|
|
1054
|
+
membership.granted_ids(Project, capability: :edit) # just the ids
|
|
1055
|
+
|
|
1056
|
+
org.memberships.with_grant_on(project) # who was granted this project
|
|
1057
|
+
org.grants # every grant in the organization
|
|
1058
|
+
Organizations::Grant.for_resource(project)
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
Records that are not ActiveRecord models (rows read through another library, ids from an API) work by type and id:
|
|
1062
|
+
|
|
1063
|
+
```ruby
|
|
1064
|
+
entity = Organizations::Resource.new("Ledger::Entity", entity_id)
|
|
1065
|
+
membership.grant!(entity, capabilities: %w[read draft])
|
|
1066
|
+
membership.granted_ids("Ledger::Entity") # => [entity_id, ...]
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
Any object with an `id` also works; its class name is the type.
|
|
1070
|
+
|
|
1071
|
+
**Invitations carry grants.** The grants move to the membership when the invitation is accepted (also when the invitee was already a member):
|
|
1072
|
+
|
|
1073
|
+
```ruby
|
|
1074
|
+
org.send_invite_to!("client@example.com", invited_by: current_user, role: :client,
|
|
1075
|
+
grants: { project => %i[read comment], other_project => %i[read] })
|
|
1076
|
+
current_user.send_organization_invite_to!(email, role: :client, grants: { project => %i[read] })
|
|
1077
|
+
invitation.grant!(project, capabilities: %i[read]) # add one later
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
The rules:
|
|
1081
|
+
|
|
1082
|
+
- **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.
|
|
1083
|
+
- **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.
|
|
1084
|
+
- **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.
|
|
1085
|
+
- **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.
|
|
1086
|
+
- **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.
|
|
1087
|
+
- **Capability names.** Any name by default. To catch typos, list them: `config.grant_capabilities = %i[read edit comment]`.
|
|
1088
|
+
- **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.
|
|
1089
|
+
- **Concurrent grants converge** on one row per membership and record (unique index).
|
|
1090
|
+
- **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.
|
|
1091
|
+
- **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.
|
|
1092
|
+
|
|
1093
|
+
Configuration:
|
|
1094
|
+
|
|
1095
|
+
```ruby
|
|
1096
|
+
config.full_resource_access_role = :admin # roles at or above see every record; nil = grants limit every role
|
|
1097
|
+
config.grant_capabilities = nil # or a list of allowed names
|
|
1098
|
+
config.grant_management_permission = :edit_member_roles
|
|
1099
|
+
```
|
|
743
1100
|
|
|
744
1101
|
### Built-in roles
|
|
745
1102
|
|
|
@@ -751,6 +1108,10 @@ owner > admin > member > viewer
|
|
|
751
1108
|
|
|
752
1109
|
Each role inherits all permissions from roles below it.
|
|
753
1110
|
|
|
1111
|
+
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.
|
|
1112
|
+
|
|
1113
|
+
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.
|
|
1114
|
+
|
|
754
1115
|
### Default permissions
|
|
755
1116
|
|
|
756
1117
|
| Permission | viewer | member | admin | owner |
|
|
@@ -826,7 +1187,8 @@ require_organization_role!(:client) # clients and everyone above them
|
|
|
826
1187
|
```
|
|
827
1188
|
|
|
828
1189
|
- 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.
|
|
1190
|
+
- 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.
|
|
1191
|
+
- 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
1192
|
- 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
1193
|
- 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
1194
|
- 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 +1354,7 @@ Expired invitations can be resent:
|
|
|
992
1354
|
|
|
993
1355
|
```ruby
|
|
994
1356
|
invitation.expired? # => true
|
|
995
|
-
invitation.resend!
|
|
1357
|
+
invitation.resend!(resent_by: current_user) # Generates new token, resets expiry, sends email
|
|
996
1358
|
```
|
|
997
1359
|
|
|
998
1360
|
### Accepted invitations
|
|
@@ -1119,6 +1481,93 @@ Organizations.configure do |config|
|
|
|
1119
1481
|
end
|
|
1120
1482
|
```
|
|
1121
1483
|
|
|
1484
|
+
### Personal organizations
|
|
1485
|
+
|
|
1486
|
+
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:
|
|
1487
|
+
|
|
1488
|
+
```ruby
|
|
1489
|
+
Organizations.configure do |config|
|
|
1490
|
+
config.always_create_personal_organization_for_each_user = true
|
|
1491
|
+
config.record_personal_organizations = true # default: false
|
|
1492
|
+
end
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
```ruby
|
|
1496
|
+
user.personal_organization # => the organization, or nil. Never creates anything.
|
|
1497
|
+
user.create_personal_organization! # creates it, or returns the existing one
|
|
1498
|
+
user.create_personal_organization!("Ana's space")
|
|
1499
|
+
|
|
1500
|
+
org.personal? # => true
|
|
1501
|
+
org.personal_owner # => the user
|
|
1502
|
+
Organizations::Organization.personal # every personal organization
|
|
1503
|
+
Organizations::Organization.not_personal # every other organization
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
The rules:
|
|
1507
|
+
|
|
1508
|
+
- The link is `organizations.personal_owner_id`, with a unique index. A user has at most one personal organization.
|
|
1509
|
+
- 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.
|
|
1510
|
+
- A personal organization can have other members. To keep it owner-only, veto joins in the membership gate, which covers every join path:
|
|
1511
|
+
|
|
1512
|
+
```ruby
|
|
1513
|
+
config.on_member_joining do |ctx|
|
|
1514
|
+
raise Organizations::MembershipVetoed, "This is a personal workspace." if ctx.organization.personal?
|
|
1515
|
+
end
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
- 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.
|
|
1519
|
+
- An invited sign-up that skips auto-creation (see "Pattern 4: Hybrid Onboarding") gets no personal organization.
|
|
1520
|
+
- 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:
|
|
1521
|
+
|
|
1522
|
+
```ruby
|
|
1523
|
+
report = Organizations::Organization.backfill_personal_owners!({ user => org, other_user.id => other_org.id }, dry_run: true)
|
|
1524
|
+
report.assignments # => [[user_id, organization_id], ...]
|
|
1525
|
+
report.unchanged # pairs that are already linked
|
|
1526
|
+
report.conflicts # => [{ user_id:, organization_id:, reason: :not_the_owner }, ...]
|
|
1527
|
+
Organizations::Organization.backfill_personal_owners!(mapping) # writes nothing when any pair conflicts
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
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.
|
|
1531
|
+
|
|
1532
|
+
A personal organization tells you who probably pays. Billing and entitlements stay with your app.
|
|
1533
|
+
|
|
1534
|
+
## Deleting user accounts
|
|
1535
|
+
|
|
1536
|
+
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.
|
|
1537
|
+
|
|
1538
|
+
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.
|
|
1539
|
+
|
|
1540
|
+
```ruby
|
|
1541
|
+
plan = user.account_deletion_plan
|
|
1542
|
+
plan.allowed? # => false
|
|
1543
|
+
plan.blockers # => [#<struct organization=#<Organization "Acme">, reason=:owns_organization_with_members>]
|
|
1544
|
+
plan.organizations_to_destroy # organizations deleted with the account
|
|
1545
|
+
plan.memberships_to_remove # the user's other memberships, removed with the account
|
|
1546
|
+
plan.to_h # ids and symbols, for an audit log or a confirmation screen
|
|
1547
|
+
user.account_deletion_plan(policy: :destroy_personal_organization) # preview another policy
|
|
1548
|
+
```
|
|
1549
|
+
|
|
1550
|
+
Apps with personal organizations can let a user delete their account together with their personal organization:
|
|
1551
|
+
|
|
1552
|
+
```ruby
|
|
1553
|
+
config.account_deletion_policy = :destroy_personal_organization # default: :prevent
|
|
1554
|
+
```
|
|
1555
|
+
|
|
1556
|
+
| The user owns… | `:prevent` | `:destroy_personal_organization` |
|
|
1557
|
+
|---|---|---|
|
|
1558
|
+
| nothing | deleted | deleted |
|
|
1559
|
+
| their personal organization, as its only member | blocked (`:personal_organization`) | deleted with the organization |
|
|
1560
|
+
| their personal organization, with other members | blocked (`:personal_organization_with_members`) | blocked |
|
|
1561
|
+
| another organization, as its only member | blocked (`:owns_organization`) | blocked |
|
|
1562
|
+
| another organization, with other members | blocked (`:owns_organization_with_members`) | blocked |
|
|
1563
|
+
|
|
1564
|
+
What the gem guarantees:
|
|
1565
|
+
|
|
1566
|
+
- It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
|
|
1567
|
+
- 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`.
|
|
1568
|
+
- 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.
|
|
1569
|
+
- 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.
|
|
1570
|
+
|
|
1122
1571
|
## User Onboarding Patterns
|
|
1123
1572
|
|
|
1124
1573
|
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 +1735,10 @@ Full configuration options:
|
|
|
1286
1735
|
# config/initializers/organizations.rb
|
|
1287
1736
|
Organizations.configure do |config|
|
|
1288
1737
|
# === Authentication ===
|
|
1738
|
+
# Class name of your user model (default: "User"). Set it before the gem's
|
|
1739
|
+
# models load (initializers are fine).
|
|
1740
|
+
config.user_class = "User"
|
|
1741
|
+
|
|
1289
1742
|
# Method that returns the current user (default: :current_user)
|
|
1290
1743
|
config.current_user_method = :current_user
|
|
1291
1744
|
|
|
@@ -1299,6 +1752,14 @@ Organizations.configure do |config|
|
|
|
1299
1752
|
# Name for auto-created organizations
|
|
1300
1753
|
config.default_organization_name = ->(user) { "Personal" }
|
|
1301
1754
|
|
|
1755
|
+
# Record the auto-created organization as user.personal_organization (default: false)
|
|
1756
|
+
config.record_personal_organizations = false
|
|
1757
|
+
|
|
1758
|
+
# What deleting a user who owns organizations does (default: :prevent).
|
|
1759
|
+
# :destroy_personal_organization also deletes their personal organization
|
|
1760
|
+
# when they are its only member. See "Deleting user accounts".
|
|
1761
|
+
config.account_deletion_policy = :prevent
|
|
1762
|
+
|
|
1302
1763
|
# === Invitations ===
|
|
1303
1764
|
# How long invitations are valid
|
|
1304
1765
|
config.invitation_expiry = 7.days
|
|
@@ -1306,6 +1767,39 @@ Organizations.configure do |config|
|
|
|
1306
1767
|
# Custom mailer for invitations
|
|
1307
1768
|
config.invitation_mailer = "Organizations::InvitationMailer"
|
|
1308
1769
|
|
|
1770
|
+
# Role of invited members when the inviter names none (default: :member)
|
|
1771
|
+
config.default_invitation_role = :member
|
|
1772
|
+
|
|
1773
|
+
# === Verified joining ===
|
|
1774
|
+
# A custom mailer needs code_email(join_request, plaintext_code) returning a
|
|
1775
|
+
# message (the gem calls .deliver_later). The plaintext code exists only in
|
|
1776
|
+
# that call: never store or log it.
|
|
1777
|
+
config.verification_mailer = "Organizations::VerificationMailer"
|
|
1778
|
+
config.verification_code_ttl = 15.minutes # emailed codes always expire
|
|
1779
|
+
config.verification_max_attempts = 5 # wrong codes per challenge
|
|
1780
|
+
config.verification_resend_interval = 60.seconds # between code sends
|
|
1781
|
+
config.verification_max_sends = 5 # code sends per join request
|
|
1782
|
+
config.verification_email_normalizer = nil # nil = downcase, strip, drop +tag; or ->(email) { ... }
|
|
1783
|
+
config.trust_confirmed_account_email = true # confirmed account email = proof of inbox (join_with_account_email!)
|
|
1784
|
+
config.join_request_expiry = 30.days # nil = pending requests never expire
|
|
1785
|
+
config.join_code_generator = nil # nil = 8 characters without lookalikes; or -> { ... }
|
|
1786
|
+
|
|
1787
|
+
# === Scoped resource grants (see "Scoped resource grants") ===
|
|
1788
|
+
config.full_resource_access_role = :admin # nil = grants limit every role
|
|
1789
|
+
config.grant_capabilities = nil # or %i[read edit ...]
|
|
1790
|
+
config.grant_management_permission = :edit_member_roles
|
|
1791
|
+
|
|
1792
|
+
# === Vocabulary (see "Vocabulary") ===
|
|
1793
|
+
config.vocabulary = nil # or :classroom, or ->(organization) { ... }
|
|
1794
|
+
|
|
1795
|
+
# === Domain authority (see "Domain authority") ===
|
|
1796
|
+
config.require_verified_domains = false # admit only through verified domains
|
|
1797
|
+
config.domain_verification_record_prefix = "_organizations-verification"
|
|
1798
|
+
config.domain_verification_ttl = 7.days
|
|
1799
|
+
config.domain_verification_timeout = 5 # seconds
|
|
1800
|
+
config.domain_txt_resolver = nil # nil = Resolv; or ->(name) { [String] }
|
|
1801
|
+
config.blocked_email_domains = [] # e.g. Organizations::PUBLIC_EMAIL_DOMAINS
|
|
1802
|
+
|
|
1309
1803
|
# === Limits ===
|
|
1310
1804
|
# Maximum organizations a user can own (nil = unlimited)
|
|
1311
1805
|
config.max_organizations_per_user = nil
|
|
@@ -1347,12 +1841,19 @@ Organizations.configure do |config|
|
|
|
1347
1841
|
config.no_organization_alert = "Please create an organization first."
|
|
1348
1842
|
config.no_organization_notice = "Please create or join an organization to continue."
|
|
1349
1843
|
|
|
1844
|
+
# === Session ===
|
|
1845
|
+
# Session key that stores the current organization id
|
|
1846
|
+
config.session_key = :current_organization_id
|
|
1847
|
+
|
|
1350
1848
|
# === Organizations Controller ===
|
|
1351
1849
|
# Additional params to permit when creating/updating organizations
|
|
1352
1850
|
# Use this to add custom fields like support_email, billing_email, logo
|
|
1353
1851
|
config.additional_organization_params = [:support_email]
|
|
1354
1852
|
|
|
1355
1853
|
# === Engine Controllers ===
|
|
1854
|
+
# Engine route groups to draw (nil = all). See "Installation".
|
|
1855
|
+
config.engine_routes = { except: [:organizations] }
|
|
1856
|
+
|
|
1356
1857
|
# Base controller for authenticated routes (default: ::ApplicationController)
|
|
1357
1858
|
config.parent_controller = "::ApplicationController"
|
|
1358
1859
|
|
|
@@ -1367,6 +1868,9 @@ Organizations.configure do |config|
|
|
|
1367
1868
|
config.authenticated_controller_layout = "dashboard"
|
|
1368
1869
|
config.public_controller_layout = "devise"
|
|
1369
1870
|
|
|
1871
|
+
# Host helper modules for the public (invitation) pages
|
|
1872
|
+
config.public_controller_helpers = ["ApplicationHelper"]
|
|
1873
|
+
|
|
1370
1874
|
# === Handlers ===
|
|
1371
1875
|
# Called when authorization fails
|
|
1372
1876
|
config.on_unauthorized do |context|
|
|
@@ -1389,7 +1893,12 @@ Organizations.configure do |config|
|
|
|
1389
1893
|
end
|
|
1390
1894
|
|
|
1391
1895
|
config.on_member_invited do |ctx|
|
|
1392
|
-
# ctx.organization, ctx.invitation, ctx.invited_by
|
|
1896
|
+
# ctx.organization, ctx.invitation, ctx.invited_by (strict: raise to veto)
|
|
1897
|
+
end
|
|
1898
|
+
|
|
1899
|
+
config.on_member_joining do |ctx|
|
|
1900
|
+
# THE membership gate (strict): raise Organizations::MembershipVetoed to veto
|
|
1901
|
+
# ctx.organization, ctx.user, ctx.role, ctx.joined_via, ctx.invitation / ctx.join_request
|
|
1393
1902
|
end
|
|
1394
1903
|
|
|
1395
1904
|
config.on_member_joined do |ctx|
|
|
@@ -1407,6 +1916,11 @@ Organizations.configure do |config|
|
|
|
1407
1916
|
config.on_ownership_transferred do |ctx|
|
|
1408
1917
|
# ctx.organization, ctx.old_owner, ctx.new_owner
|
|
1409
1918
|
end
|
|
1919
|
+
|
|
1920
|
+
config.on_join_request_created { |ctx| } # ctx.organization, ctx.user, ctx.join_request
|
|
1921
|
+
config.on_join_request_approved { |ctx| } # …, ctx.membership, ctx.decided_by (nil when automatic)
|
|
1922
|
+
config.on_join_request_rejected { |ctx| } # …, ctx.decided_by
|
|
1923
|
+
config.on_verification_delivery_failed { |ctx| } # ctx.metadata["error_class"], ["error_message"]
|
|
1410
1924
|
end
|
|
1411
1925
|
```
|
|
1412
1926
|
|
|
@@ -1641,7 +2155,7 @@ ActiveSupport.on_load(:organizations_membership) do
|
|
|
1641
2155
|
end
|
|
1642
2156
|
```
|
|
1643
2157
|
|
|
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.
|
|
2158
|
+
**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
2159
|
|
|
1646
2160
|
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
2161
|
|
|
@@ -1676,7 +2190,44 @@ class Portal::BaseController < ApplicationController
|
|
|
1676
2190
|
end
|
|
1677
2191
|
```
|
|
1678
2192
|
|
|
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.
|
|
2193
|
+
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.
|
|
2194
|
+
|
|
2195
|
+
### URL handles (slugs)
|
|
2196
|
+
|
|
2197
|
+
`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:
|
|
2198
|
+
|
|
2199
|
+
```ruby
|
|
2200
|
+
# db/migrate/xxx_add_handle_to_organizations.rb
|
|
2201
|
+
class AddHandleToOrganizations < ActiveRecord::Migration[8.1]
|
|
2202
|
+
def change
|
|
2203
|
+
add_column :organizations_organizations, :handle, :string
|
|
2204
|
+
add_index :organizations_organizations, :handle, unique: true # the database decides uniqueness
|
|
2205
|
+
end
|
|
2206
|
+
end
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
```ruby
|
|
2210
|
+
# app/models/concerns/organization_handle.rb (register it with the :organizations_organization load hook)
|
|
2211
|
+
module OrganizationHandle
|
|
2212
|
+
extend ActiveSupport::Concern
|
|
2213
|
+
RESERVED = %w[admin api new settings join access].freeze # paths your app uses
|
|
2214
|
+
|
|
2215
|
+
included do
|
|
2216
|
+
before_validation { self.handle = handle.to_s.strip.downcase.presence }
|
|
2217
|
+
validates :handle, format: { with: /\A[a-z0-9][a-z0-9-]{1,38}[a-z0-9]\z/ }, exclusion: { in: RESERVED }, allow_nil: true
|
|
2218
|
+
end
|
|
2219
|
+
end
|
|
2220
|
+
```
|
|
2221
|
+
|
|
2222
|
+
```ruby
|
|
2223
|
+
self.organization_param = :handle
|
|
2224
|
+
self.organization_finder = ->(handle) { Organizations::Organization.find_by(handle: handle.to_s.downcase) }
|
|
2225
|
+
```
|
|
2226
|
+
|
|
2227
|
+
- **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.
|
|
2228
|
+
- **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)`.
|
|
2229
|
+
- **Renames break old links.** Keep the old handle in a lookup table and redirect, or freeze handles after creation.
|
|
2230
|
+
- An existing slug gem (FriendlyId and others) works too: point `organization_finder` at its finder.
|
|
1680
2231
|
|
|
1681
2232
|
## Internationalization (i18n)
|
|
1682
2233
|
|
|
@@ -1692,7 +2243,83 @@ en:
|
|
|
1692
2243
|
join_code_invalid: "Hmm, that code doesn't work."
|
|
1693
2244
|
```
|
|
1694
2245
|
|
|
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.
|
|
2246
|
+
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.
|
|
2247
|
+
|
|
2248
|
+
### Vocabulary: different words for different organizations
|
|
2249
|
+
|
|
2250
|
+
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:
|
|
2251
|
+
|
|
2252
|
+
```ruby
|
|
2253
|
+
# One vocabulary for the whole app
|
|
2254
|
+
config.vocabulary = :classroom
|
|
2255
|
+
|
|
2256
|
+
# Or one per organization, from your own data (called only with an organization)
|
|
2257
|
+
config.vocabulary = ->(organization) { organization.metadata["vocabulary"] }
|
|
2258
|
+
```
|
|
2259
|
+
|
|
2260
|
+
Then add only the keys that differ, under `organizations.vocabularies.<name>`:
|
|
2261
|
+
|
|
2262
|
+
```yaml
|
|
2263
|
+
# config/locales/en.yml
|
|
2264
|
+
en:
|
|
2265
|
+
organizations:
|
|
2266
|
+
vocabularies:
|
|
2267
|
+
classroom:
|
|
2268
|
+
roles:
|
|
2269
|
+
member: "Student"
|
|
2270
|
+
admin: "Teacher"
|
|
2271
|
+
mailers:
|
|
2272
|
+
invitation:
|
|
2273
|
+
# A full sentence, not a noun inserted into an English sentence:
|
|
2274
|
+
# other languages need their own word order.
|
|
2275
|
+
subject: "%{inviter} added you to the class %{organization}"
|
|
2276
|
+
library:
|
|
2277
|
+
roles:
|
|
2278
|
+
member: "Patron"
|
|
2279
|
+
```
|
|
2280
|
+
|
|
2281
|
+
Pass the organization record:
|
|
2282
|
+
|
|
2283
|
+
```ruby
|
|
2284
|
+
Organizations.t("roles.member", organization: school) # => "Student"
|
|
2285
|
+
Organizations.t("roles.member", organization: library) # => "Patron"
|
|
2286
|
+
Organizations.t("roles.owner", organization: school) # => "Owner" (missing in the vocabulary: the ordinary key)
|
|
2287
|
+
Organizations.t("roles.member") # => "Member" (no organization: the resolver is not called)
|
|
2288
|
+
organization_role_label(:member, organization: school) # view helper => "Student"
|
|
2289
|
+
```
|
|
2290
|
+
|
|
2291
|
+
The rules:
|
|
2292
|
+
|
|
2293
|
+
- **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.
|
|
2294
|
+
- **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.
|
|
2295
|
+
- **`%{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.
|
|
2296
|
+
- **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.
|
|
2297
|
+
- **Count, interpolation and locale work as in I18n.** For plurals, give the vocabulary key `one:`/`other:` (and the other forms your locales need):
|
|
2298
|
+
|
|
2299
|
+
```ruby
|
|
2300
|
+
Organizations.t("members_count", organization: school, count: 3) # => "3 students"
|
|
2301
|
+
```
|
|
2302
|
+
|
|
2303
|
+
- **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.
|
|
2304
|
+
- **Mailers and background jobs:** pass the organization and set the locale, as with any I18n call:
|
|
2305
|
+
|
|
2306
|
+
```ruby
|
|
2307
|
+
I18n.with_locale(user.locale) do
|
|
2308
|
+
Organizations.t(:"mailers.invitation.subject", organization: invitation.organization, inviter: inviter.name)
|
|
2309
|
+
end
|
|
2310
|
+
```
|
|
2311
|
+
|
|
2312
|
+
- **`_html` keys** in your own views go through the Rails `t` helper (it escapes interpolations). Get the lookup chain from the gem:
|
|
2313
|
+
|
|
2314
|
+
```erb
|
|
2315
|
+
<% primary, *fallbacks = Organizations.i18n_keys(:"mailers.invitation.invited_by_html", organization: @organization) %>
|
|
2316
|
+
<%= t(primary, default: fallbacks, inviter: @inviter_name, organization: @organization.name) %>
|
|
2317
|
+
```
|
|
2318
|
+
|
|
2319
|
+
- **Display text only.** Calling an admin a teacher changes no stored role name, permission or authorization.
|
|
2320
|
+
- **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.
|
|
2321
|
+
- **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.
|
|
2322
|
+
- **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
2323
|
|
|
1697
2324
|
## Database schema
|
|
1698
2325
|
|
|
@@ -1708,6 +2335,7 @@ organizations_organizations
|
|
|
1708
2335
|
- name (string, required)
|
|
1709
2336
|
- memberships_count (integer, counter cache, default: 0)
|
|
1710
2337
|
- metadata (jsonb, default: {})
|
|
2338
|
+
- personal_owner_id (foreign key to users, nullable, unique; ON DELETE SET NULL)
|
|
1711
2339
|
- created_at / updated_at
|
|
1712
2340
|
```
|
|
1713
2341
|
|
|
@@ -1751,6 +2379,23 @@ organizations_invitations
|
|
|
1751
2379
|
partial unique index: [organization_id, lower(email)] where accepted_at is null
|
|
1752
2380
|
```
|
|
1753
2381
|
|
|
2382
|
+
### organizations_grants (optional, `rails g organizations:grants`)
|
|
2383
|
+
|
|
2384
|
+
```sql
|
|
2385
|
+
organizations_grants
|
|
2386
|
+
- id
|
|
2387
|
+
- membership_id (foreign key, nullable; ON DELETE CASCADE)
|
|
2388
|
+
- invitation_id (foreign key, nullable; ON DELETE CASCADE) — exactly one of the two (check constraint)
|
|
2389
|
+
- grantable_type (string) / grantable_id (the app's key type by default)
|
|
2390
|
+
- capabilities (jsonb array, at least one)
|
|
2391
|
+
- granted_by_id (foreign key to users, nullable; ON DELETE SET NULL)
|
|
2392
|
+
- created_at / updated_at
|
|
2393
|
+
|
|
2394
|
+
unique index: [membership_id, grantable_type, grantable_id]
|
|
2395
|
+
unique index: [invitation_id, grantable_type, grantable_id]
|
|
2396
|
+
index: [grantable_type, grantable_id]
|
|
2397
|
+
```
|
|
2398
|
+
|
|
1754
2399
|
### organizations_domains
|
|
1755
2400
|
|
|
1756
2401
|
```sql
|
|
@@ -1758,6 +2403,11 @@ organizations_domains
|
|
|
1758
2403
|
- id, organization_id (foreign key)
|
|
1759
2404
|
- domain (string, required — stored lowercased, no leading '@')
|
|
1760
2405
|
- membership_metadata / metadata (jsonb, default: {})
|
|
2406
|
+
- join_policy (string, default: 'none': none|suggest|auto_join)
|
|
2407
|
+
- verification_token (string, nullable) / verification_started_at (datetime)
|
|
2408
|
+
- verified_at (datetime) / verification_method (string: dns_txt|operator)
|
|
2409
|
+
- verified_by_id (foreign key to users, nullable; ON DELETE SET NULL)
|
|
2410
|
+
- verification_revoked_at (datetime)
|
|
1761
2411
|
- created_at / updated_at
|
|
1762
2412
|
|
|
1763
2413
|
unique index: [organization_id, domain]; index: [domain] (email → candidate orgs)
|
|
@@ -1949,16 +2599,6 @@ current_organization # Returns cached (subsequent calls)
|
|
|
1949
2599
|
current_organization # Returns cached
|
|
1950
2600
|
```
|
|
1951
2601
|
|
|
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
2602
|
## Data integrity
|
|
1963
2603
|
|
|
1964
2604
|
The gem handles concurrent access and race conditions to ensure data consistency.
|
|
@@ -2023,7 +2663,7 @@ user.leave_organization!(org)
|
|
|
2023
2663
|
|
|
2024
2664
|
**Role changes:**
|
|
2025
2665
|
```ruby
|
|
2026
|
-
membership.promote_to!(:admin)
|
|
2666
|
+
membership.promote_to!(:admin, changed_by: current_user)
|
|
2027
2667
|
|
|
2028
2668
|
# Internally:
|
|
2029
2669
|
# 1. Lock membership row
|
|
@@ -2160,9 +2800,23 @@ Afterwards: `has_organizations` on `User`, move your model code into an extensio
|
|
|
2160
2800
|
git clone https://github.com/rameerez/organizations
|
|
2161
2801
|
cd organizations
|
|
2162
2802
|
bin/setup
|
|
2163
|
-
bundle exec rake test
|
|
2803
|
+
bundle exec rake # the test suite and RuboCop, as CI runs them
|
|
2804
|
+
|
|
2805
|
+
# Against each supported Rails version (Appraisal)
|
|
2806
|
+
bundle exec appraisal install
|
|
2807
|
+
bundle exec appraisal rails-7.2 rake
|
|
2808
|
+
bundle exec appraisal rails-8.1 rake
|
|
2809
|
+
|
|
2810
|
+
# The demo app and its HTTP suite (it is also the source of the generator templates)
|
|
2811
|
+
cd test/dummy
|
|
2812
|
+
bundle install
|
|
2813
|
+
bin/rails tailwindcss:build
|
|
2814
|
+
bin/rails db:test:prepare
|
|
2815
|
+
bin/rails test
|
|
2164
2816
|
```
|
|
2165
2817
|
|
|
2818
|
+
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.
|
|
2819
|
+
|
|
2166
2820
|
## Contributing
|
|
2167
2821
|
|
|
2168
2822
|
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.
|