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