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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/CHANGELOG.md +93 -0
  6. data/README.md +687 -33
  7. data/app/controllers/organizations/invitations_controller.rb +27 -22
  8. data/app/controllers/organizations/memberships_controller.rb +37 -30
  9. data/app/controllers/organizations/organizations_controller.rb +8 -8
  10. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  11. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  12. data/app/mailers/organizations/verification_mailer.rb +1 -1
  13. data/app/models/organizations/grant.rb +6 -0
  14. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  15. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  16. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  17. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  18. data/config/locales/en.yml +22 -0
  19. data/config/locales/es.yml +22 -0
  20. data/config/routes.rb +1 -2
  21. data/gemfiles/rails_7.2.gemfile +5 -1
  22. data/gemfiles/rails_8.1.gemfile +5 -1
  23. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  24. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  25. data/lib/generators/organizations/install/install_generator.rb +1 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  27. data/lib/generators/organizations/join/join_generator.rb +113 -0
  28. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  30. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  33. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  34. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  35. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  36. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  37. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  38. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  39. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  40. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  41. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  42. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  43. data/lib/generators/organizations/views/views_generator.rb +4 -6
  44. data/lib/organizations/account_deletion_plan.rb +118 -0
  45. data/lib/organizations/configuration.rb +152 -0
  46. data/lib/organizations/controller_helpers.rb +55 -15
  47. data/lib/organizations/grant_access.rb +75 -0
  48. data/lib/organizations/grant_holder.rb +152 -0
  49. data/lib/organizations/grantable.rb +28 -0
  50. data/lib/organizations/join_flow.rb +16 -12
  51. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  52. data/lib/organizations/models/domain.rb +283 -1
  53. data/lib/organizations/models/grant.rb +152 -0
  54. data/lib/organizations/models/invitation.rb +76 -7
  55. data/lib/organizations/models/join_request.rb +102 -43
  56. data/lib/organizations/models/membership.rb +87 -5
  57. data/lib/organizations/models/organization.rb +219 -48
  58. data/lib/organizations/organization_scoped.rb +16 -0
  59. data/lib/organizations/personal_owner_backfill.rb +122 -0
  60. data/lib/organizations/public_email_domains.rb +49 -0
  61. data/lib/organizations/resource.rb +11 -0
  62. data/lib/organizations/roles.rb +55 -0
  63. data/lib/organizations/version.rb +1 -1
  64. data/lib/organizations/view_helpers.rb +40 -9
  65. data/lib/organizations.rb +99 -4
  66. metadata +22 -2
@@ -74,6 +74,15 @@ module Organizations
74
74
  def require_organization_role(role, **options)
75
75
  before_action(**options) { require_scoped_organization_role!(role) }
76
76
  end
77
+
78
+ # Gate actions behind a permission of the viewer's role in the URL's
79
+ # organization (custom roles included). Same not-found posture as
80
+ # require_organization_role.
81
+ # require_organization_permission :invite_members
82
+ # require_organization_permission :manage_settings, only: %i[verify_domain]
83
+ def require_organization_permission(permission, **options)
84
+ before_action(**options) { require_scoped_organization_permission!(permission) }
85
+ end
77
86
  end
78
87
 
79
88
  # The organization resolved from the URL for this request.
@@ -120,6 +129,13 @@ module Organizations
120
129
  scoped_organization_not_found!
121
130
  end
122
131
 
132
+ def require_scoped_organization_permission!(permission)
133
+ membership = current_scoped_membership
134
+ return if membership&.has_permission_to?(permission)
135
+
136
+ scoped_organization_not_found!
137
+ end
138
+
123
139
  def scoped_organization_not_found!
124
140
  case self.class.organization_not_found_behavior
125
141
  when :forbidden
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Links reviewed users to their personal organizations, for installs that
5
+ # created personal workspaces before personal organizations existed.
6
+ # Called through Organizations::Organization.backfill_personal_owners!.
7
+ #
8
+ # The mapping is the only source of truth. A sole member, a name such as
9
+ # "Personal", or the oldest organization never proves that an organization
10
+ # is someone's personal workspace, and a wrong link would later let the
11
+ # account-deletion policy delete a one-person business.
12
+ class PersonalOwnerBackfill
13
+ # assignments - [[user_id, organization_id], ...] written (or to write, in a dry run)
14
+ # unchanged - [[user_id, organization_id], ...] already linked
15
+ # conflicts - [{ user_id:, organization_id:, reason: }, ...]
16
+ Report = Struct.new(:assignments, :unchanged, :conflicts, :applied, keyword_init: true) do
17
+ def applied? = applied
18
+ end
19
+
20
+ CONFLICT_REASONS = %i[
21
+ organization_not_found
22
+ not_the_owner
23
+ organization_personal_for_another_user
24
+ user_has_another_personal_organization
25
+ organization_mapped_twice
26
+ user_mapped_twice
27
+ ].freeze
28
+
29
+ def initialize(mapping)
30
+ user_id_type = Organizations.user_class.type_for_attribute(Organizations.user_class.primary_key)
31
+ organization_id_type = Organization.type_for_attribute(Organization.primary_key)
32
+ # Pairs, not a Hash: a record and its id are two keys for one user.
33
+ @pairs = mapping.map do |user, organization|
34
+ [user_id_type.cast(id_of(user)), organization_id_type.cast(id_of(organization))]
35
+ end
36
+ end
37
+
38
+ # @param dry_run [Boolean]
39
+ # @return [Report]
40
+ def run(dry_run: false)
41
+ return analyze if dry_run
42
+
43
+ apply
44
+ rescue ActiveRecord::RecordNotUnique
45
+ # A concurrent provisioning linked one of these users meanwhile. The
46
+ # savepoint rolled back every write; report the state as it is now.
47
+ analyze
48
+ end
49
+
50
+ private
51
+
52
+ def apply
53
+ report = nil
54
+ # SAVEPOINT: a rollback must undo only these writes, also inside a host
55
+ # transaction, and leave it usable on PostgreSQL.
56
+ Organization.transaction(requires_new: true) do
57
+ # Lock the organizations so the checks hold until the write.
58
+ Organization.where(id: @pairs.map(&:last)).order(:id).lock.to_a
59
+ report = analyze
60
+ raise ActiveRecord::Rollback unless report.conflicts.empty?
61
+
62
+ report.assignments.each do |user_id, organization_id|
63
+ Organization.find(organization_id).update!(personal_owner_id: user_id)
64
+ end
65
+ report.applied = true
66
+ end
67
+ report
68
+ end
69
+
70
+ def analyze
71
+ load_lookups
72
+ report = Report.new(assignments: [], unchanged: [], conflicts: [], applied: false)
73
+ @pairs.each do |user_id, organization_id|
74
+ reason = conflict_for(user_id, organization_id)
75
+ if reason
76
+ report.conflicts << { user_id: user_id, organization_id: organization_id, reason: reason }
77
+ elsif @linked[user_id] == organization_id
78
+ report.unchanged << [user_id, organization_id]
79
+ else
80
+ report.assignments << [user_id, organization_id]
81
+ end
82
+ end
83
+ report
84
+ end
85
+
86
+ # One query per rule, for the whole mapping.
87
+ def load_lookups
88
+ user_ids = @pairs.map(&:first)
89
+ organization_ids = @pairs.map(&:last)
90
+ @organizations = Organization.where(id: organization_ids).index_by(&:id)
91
+ @owners = Membership.where(organization_id: @organizations.keys, role: "owner")
92
+ .pluck(:organization_id, :user_id).to_h
93
+ @linked = Organization.personal.where(personal_owner_id: user_ids).pluck(:personal_owner_id, :id).to_h
94
+ @mapped_twice = repeated(organization_ids)
95
+ @users_mapped_twice = repeated(user_ids)
96
+ end
97
+
98
+ def repeated(ids) = ids.tally.select { |_, count| count > 1 }.keys
99
+
100
+ def conflict_for(user_id, organization_id)
101
+ organization = @organizations[organization_id]
102
+ return :organization_not_found unless organization
103
+ return :user_mapped_twice if @users_mapped_twice.include?(user_id)
104
+ return :organization_mapped_twice if @mapped_twice.include?(organization_id)
105
+ return :not_the_owner unless @owners[organization_id] == user_id
106
+
107
+ link_conflict(user_id, organization)
108
+ end
109
+
110
+ def link_conflict(user_id, organization)
111
+ if organization.personal_owner_id.present? && organization.personal_owner_id != user_id
112
+ :organization_personal_for_another_user
113
+ elsif @linked[user_id] && @linked[user_id] != organization.id
114
+ :user_has_another_personal_organization
115
+ end
116
+ end
117
+
118
+ def id_of(record)
119
+ record.respond_to?(:id) ? record.id : record
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Common free email providers, for config.blocked_email_domains. Opt-in:
5
+ #
6
+ # config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS
7
+ # config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS + ["example-isp.net"]
8
+ #
9
+ # Defence in depth, not proof of authority and not a complete list: the
10
+ # proof is the DNS challenge. A blocked domain cannot be enrolled and never
11
+ # admits through a domain. People with these addresses can still join by
12
+ # invitation, code or allowlist.
13
+ PUBLIC_EMAIL_DOMAINS = %w[
14
+ aol.com
15
+ fastmail.com
16
+ gmail.com
17
+ gmx.com
18
+ gmx.de
19
+ gmx.net
20
+ googlemail.com
21
+ hey.com
22
+ hotmail.co.uk
23
+ hotmail.com
24
+ hotmail.es
25
+ hotmail.fr
26
+ icloud.com
27
+ live.com
28
+ mac.com
29
+ mail.com
30
+ mail.ru
31
+ me.com
32
+ msn.com
33
+ outlook.com
34
+ outlook.es
35
+ pm.me
36
+ proton.me
37
+ protonmail.com
38
+ qq.com
39
+ tutanota.com
40
+ web.de
41
+ yahoo.co.uk
42
+ yahoo.com
43
+ yahoo.es
44
+ yahoo.fr
45
+ yandex.com
46
+ yandex.ru
47
+ zoho.com
48
+ ].freeze
49
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # A resource of the host app named by type and id, for grants on records
5
+ # that are not ActiveRecord models (or that you do not want to load):
6
+ #
7
+ # entity = Organizations::Resource.new("Ledger::Entity", entity_id)
8
+ # membership.grant!(entity, capabilities: %w[read])
9
+ # membership.can?(:read, entity)
10
+ Resource = Data.define(:type, :id)
11
+ end
@@ -124,6 +124,52 @@ module Organizations
124
124
  idx_a < idx_b ? -1 : 1
125
125
  end
126
126
 
127
+ # Check if an account holding actor_role may act on role: give it to a
128
+ # member, take it away, remove a member who holds it, or invite to it.
129
+ # See assignment_refusal for the rule.
130
+ # @param actor_role [Symbol, String] The role of the account making the change
131
+ # @param role [Symbol, String] The role being acted on
132
+ # @return [Boolean]
133
+ #
134
+ # @example
135
+ # Roles.can_assign?(:admin, :admin) # => true (same rank)
136
+ # Roles.can_assign?(:admin, :member) # => true
137
+ # Roles.can_assign?(:member, :admin) # => false (admin ranks above member)
138
+ #
139
+ def can_assign?(actor_role, role) = assignment_refusal(actor_role, role).nil?
140
+
141
+ # Why an account holding actor_role may not act on role, or nil when it
142
+ # may. A role acts only on roles at or below its own rank, so a custom
143
+ # role that can edit member roles still cannot create or demote an
144
+ # admin. Rank alone is not enough below admin: two custom roles with the
145
+ # same parent are ranked by definition order, so a role below admin may
146
+ # also act only on roles whose permissions it holds itself. Admins and
147
+ # the owner keep the rank rule alone, as before. A role that is no
148
+ # longer configured ranks below every configured role and has no
149
+ # permissions; an actor whose own role is no longer configured may act
150
+ # on nothing. The owner role keeps its own rules on top of this: it
151
+ # moves only through transfer_ownership_to!.
152
+ # @param actor_role [Symbol, String] The role of the account making the change
153
+ # @param role [Symbol, String] The role being acted on
154
+ # @return [nil, :outranks, :lacks_permissions]
155
+ #
156
+ # @example
157
+ # Roles.assignment_refusal(:member, :admin) # => :outranks
158
+ # Roles.assignment_refusal(:lead, :finance) # => :lacks_permissions (a sibling with other powers)
159
+ # Roles.assignment_refusal(:lead, :member) # => nil
160
+ #
161
+ def assignment_refusal(actor_role, role)
162
+ return :outranks if actor_role.nil? || role.nil?
163
+
164
+ current = snapshot # one snapshot for the ranks and the permissions
165
+ actor_role = actor_role.to_sym
166
+ role = role.to_sym
167
+ return :outranks unless ranks_at_or_above?(current.hierarchy, actor_role, role)
168
+ return nil if ranks_at_or_above?(current.hierarchy, actor_role, :admin)
169
+
170
+ holds_permissions_of?(current.permission_sets, actor_role, role) ? nil : :lacks_permissions
171
+ end
172
+
127
173
  # Get all permissions for a role (pre-computed, O(1) lookup)
128
174
  # @param role [Symbol, String] The role
129
175
  # @return [Array<Symbol>] All permissions for the role
@@ -206,6 +252,15 @@ module Organizations
206
252
 
207
253
  private
208
254
 
255
+ # A role missing from order ranks below every configured role; an actor
256
+ # missing from it ranks at or above nothing.
257
+ def ranks_at_or_above?(order, actor_role, role)
258
+ actor_rank = order.index(actor_role)
259
+ !actor_rank.nil? && actor_rank <= (order.index(role) || order.length)
260
+ end
261
+
262
+ def holds_permissions_of?(sets, actor_role, role) = (sets[role] || Set.new).subset?(sets[actor_role] || Set.new)
263
+
209
264
  # The hierarchy and permissions of one roles definition, computed
210
265
  # together and published as one immutable value. It belongs to the
211
266
  # definition object it was built from: after a reconfiguration (or a
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -95,32 +95,36 @@ module Organizations
95
95
 
96
96
  # Returns a human-readable role label
97
97
  # @param role [Symbol, String] The role
98
+ # @param organization [Organizations::Organization, nil] pass it to use
99
+ # that organization's vocabulary (config.vocabulary); never inferred
98
100
  # @return [String]
99
101
  #
100
102
  # @example
101
103
  # organization_role_label(:admin) # => "Admin"
104
+ # organization_role_label(:member, organization: school) # => "Student"
102
105
  #
103
- def organization_role_label(role)
106
+ def organization_role_label(role, organization: nil)
104
107
  # I18n-backed (organizations.roles.*) so hosts localize/retheme labels
105
108
  # by overriding locale keys instead of monkey-patching this helper.
106
109
  # Custom roles defined via config.roles fall back to humanize unless
107
110
  # the host adds a matching organizations.roles.<name> key.
108
- Organizations.t(:"roles.#{role}", default: role.to_s.humanize)
111
+ Organizations.t(:"roles.#{role}", organization: organization, default: role.to_s.humanize)
109
112
  end
110
113
 
111
114
  # Returns a hash of role information for building badges
112
115
  # @param role [Symbol, String] The role
116
+ # @param organization [Organizations::Organization, nil] for its vocabulary
113
117
  # @return [Hash] Hash with :role, :label, and :color keys
114
118
  #
115
119
  # @example
116
120
  # organization_role_info(:admin)
117
121
  # # => { role: :admin, label: "Admin", color: :blue }
118
122
  #
119
- def organization_role_info(role)
123
+ def organization_role_info(role, organization: nil)
120
124
  role_sym = role.to_sym
121
125
  {
122
126
  role: role_sym,
123
- label: organization_role_label(role_sym),
127
+ label: role_label_for(role_sym, organization),
124
128
  color: role_color(role_sym)
125
129
  }
126
130
  end
@@ -172,8 +176,8 @@ module Organizations
172
176
  name: user.respond_to?(:name) && user.name.present? ? user.name : user.email,
173
177
  email: user.email,
174
178
  role: membership.role.to_sym,
175
- role_label: organization_role_label(membership.role),
176
- role_info: organization_role_info(membership.role),
179
+ role_label: role_label_for(membership.role, organization),
180
+ role_info: organization_role_info(membership.role, organization: organization),
177
181
  joined_at: membership.created_at,
178
182
  is_owner: membership.owner?
179
183
  }
@@ -190,7 +194,7 @@ module Organizations
190
194
  id: invitation.id,
191
195
  email: invitation.email,
192
196
  role: invitation.role.to_sym,
193
- role_label: organization_role_label(invitation.role),
197
+ role_label: role_label_for(invitation.role, organization),
194
198
  invited_by: inviter,
195
199
  invited_by_name: inviter_display_name(inviter),
196
200
  status: organization_invitation_status(invitation),
@@ -240,6 +244,8 @@ module Organizations
240
244
  end
241
245
 
242
246
  # Check if current user can remove a member
247
+ # Follows the same rule as Organization#remove_member!: the member's role
248
+ # must be one the user may act on (see Roles.assignment_refusal).
243
249
  # @param user [User] The user performing the action
244
250
  # @param membership [Organizations::Membership] The membership to remove
245
251
  # @return [Boolean]
@@ -247,10 +253,12 @@ module Organizations
247
253
  return false unless user_has_permission_in_org?(user, membership.organization, :remove_members)
248
254
  return false if membership.owner? # Can't remove owner
249
255
 
250
- true
256
+ user_can_act_on_role?(user, membership)
251
257
  end
252
258
 
253
259
  # Check if current user can change a member's role
260
+ # Follows the same rule as Organization#change_role_of!: the member's
261
+ # current role must be one the user may act on (see Roles.assignment_refusal).
254
262
  # @param user [User] The user performing the action
255
263
  # @param membership [Organizations::Membership] The membership to update
256
264
  # @return [Boolean]
@@ -262,7 +270,7 @@ module Organizations
262
270
  if membership.owner?
263
271
  user_has_permission_in_org?(user, membership.organization, :transfer_ownership)
264
272
  else
265
- true
273
+ user_can_act_on_role?(user, membership)
266
274
  end
267
275
  end
268
276
 
@@ -353,6 +361,24 @@ module Organizations
353
361
  end
354
362
  end
355
363
 
364
+ # organization_role_label with the organization, unless a host override
365
+ # of it takes only the role (written before 0.7.0).
366
+ def role_label_for(role, organization)
367
+ if role_label_takes_organization?
368
+ organization_role_label(role, organization: organization)
369
+ else
370
+ organization_role_label(role)
371
+ end
372
+ end
373
+
374
+ def role_label_takes_organization?
375
+ return @_role_label_takes_organization if defined?(@_role_label_takes_organization)
376
+
377
+ @_role_label_takes_organization = method(:organization_role_label).parameters.any? do |kind, name|
378
+ kind == :keyrest || name == :organization
379
+ end
380
+ end
381
+
356
382
  def role_color(role)
357
383
  case role.to_sym
358
384
  when :owner then :purple
@@ -379,6 +405,11 @@ module Organizations
379
405
  role && Roles.has_permission?(role, permission)
380
406
  end
381
407
 
408
+ def user_can_act_on_role?(user, membership)
409
+ role = user.role_in(membership.organization)
410
+ role.present? && Roles.can_assign?(role, membership.role)
411
+ end
412
+
382
413
  # Returns display name for invitation sender (handles nil inviter)
383
414
  def inviter_display_name(inviter)
384
415
  return nil unless inviter
data/lib/organizations.rb CHANGED
@@ -71,6 +71,20 @@ module Organizations
71
71
  end
72
72
  end
73
73
 
74
+ # Domain authority: why a DNS verification failed. reason is one of
75
+ # :not_started, :expired, :changed (the claim changed during the check),
76
+ # :record_not_found, :lookup_failed.
77
+ class DomainVerificationFailed < Error
78
+ REASONS = %i[not_started expired changed record_not_found lookup_failed].freeze
79
+
80
+ attr_reader :reason
81
+
82
+ def initialize(reason)
83
+ @reason = reason
84
+ super(Organizations.t(:"errors.domain_verification.#{reason}"))
85
+ end
86
+ end
87
+
74
88
  # Join code errors (verified joining)
75
89
  # JoinCodeInvalid covers unknown, revoked, and expired codes — hosts should
76
90
  # show the same generic message for all three (don't leak which codes exist).
@@ -105,6 +119,13 @@ module Organizations
105
119
  autoload :JoinState, "organizations/join_state"
106
120
  autoload :MetadataFlags, "organizations/metadata_flags"
107
121
  autoload :OrganizationScoped, "organizations/organization_scoped"
122
+ autoload :PersonalOwnerBackfill, "organizations/personal_owner_backfill"
123
+ autoload :PUBLIC_EMAIL_DOMAINS, "organizations/public_email_domains"
124
+ autoload :Resource, "organizations/resource"
125
+ autoload :GrantHolder, "organizations/grant_holder"
126
+ autoload :GrantAccess, "organizations/grant_access"
127
+ autoload :Grantable, "organizations/grantable"
128
+ autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
108
129
 
109
130
  # Alias for README compatibility: `include Organizations::Controller`
110
131
  Controller = ControllerHelpers
@@ -142,6 +163,7 @@ module Organizations
142
163
  autoload :JoinCode, "organizations/models/join_code"
143
164
  autoload :AllowlistEntry, "organizations/models/allowlist_entry"
144
165
  autoload :JoinRequest, "organizations/models/join_request"
166
+ autoload :Grant, "organizations/models/grant"
145
167
 
146
168
  # Non-Rails contexts (plain ActiveRecord, the gem's own test suite) don't
147
169
  # get the Rails engine's automatic `config/locales` pickup, so register
@@ -281,13 +303,86 @@ module Organizations
281
303
  # Hosts override any key the standard Rails way — app locale files load
282
304
  # after engine locale files, so the host's value wins.
283
305
  #
284
- # @param key [String, Symbol] key under the `organizations.` scope,
306
+ # VOCABULARY (config.vocabulary): pass the organization record as
307
+ # `organization:`. When the selected vocabulary has the key
308
+ # (`organizations.vocabularies.<vocabulary>.<key>`, in the current locale
309
+ # or one of its I18n fallbacks), that translation wins. Otherwise the call
310
+ # is exactly the ordinary one: same key, scope, defaults and options.
311
+ # `%{organization}` interpolates the record's name. A String
312
+ # `organization:` stays a plain interpolation value, as before. Calls
313
+ # with an Array key or a custom `scope:` skip the vocabulary.
314
+ #
315
+ # Organizations.t("roles.member", organization: school) # => "Student"
316
+ #
317
+ # @param key [String, Symbol, Array] key under the `organizations.` scope,
285
318
  # e.g. :"errors.join_code_invalid" or "roles.owner"
286
- # @param options [Hash] I18n options (interpolations, :locale, :default…)
319
+ # @param options [Hash] I18n options (interpolations, :locale, :count,
320
+ # :default…) and :organization (a record or a String)
287
321
  # @return [String]
288
- def translate(key, **)
289
- I18n.t(key, scope: :organizations, **)
322
+ def translate(key, **options)
323
+ # A nil organization is no organization: the ordinary call stays unchanged.
324
+ options = options.except(:organization) if options.key?(:organization) && options[:organization].nil?
325
+ record = options[:organization].is_a?(Organizations::Organization) ? options[:organization] : nil
326
+ options = options.merge(organization: record.name) if record
327
+ vocabulary_key = vocabulary_key_for(key, record, options)
328
+ # The vocabulary key exists, so the caller's default (meant for the
329
+ # ordinary key) does not apply.
330
+ return I18n.t(vocabulary_key, scope: :organizations, **options.except(:default)) if vocabulary_key
331
+
332
+ I18n.t(key, scope: :organizations, **options)
290
333
  end
334
+
335
+ # The I18n lookup chain for a key, most specific first: the vocabulary
336
+ # key (when config.vocabulary selects one for this organization), then
337
+ # the ordinary key. Absolute keys, for the Rails `t` view helper (needed
338
+ # for `_html` keys):
339
+ #
340
+ # primary, *fallbacks = Organizations.i18n_keys(:"mailers.invitation.invited_by_html", organization: org)
341
+ # t(primary, default: fallbacks, inviter: name, organization: org.name)
342
+ #
343
+ # @param key [String, Symbol]
344
+ # @param organization [Organizations::Organization, nil]
345
+ # @return [Array<Symbol>]
346
+ def i18n_keys(key, organization: nil)
347
+ ordinary = :"organizations.#{key}"
348
+ vocabulary = vocabulary_for(organization)
349
+ vocabulary ? [:"organizations.vocabularies.#{vocabulary}.#{key}", ordinary] : [ordinary]
350
+ end
351
+
352
+ # @api private
353
+ # The vocabulary key to use for this call, or nil for the ordinary call.
354
+ def vocabulary_key_for(key, record, options)
355
+ return nil if key.is_a?(Array) || options.key?(:scope)
356
+
357
+ vocabulary = vocabulary_for(record)
358
+ return nil unless vocabulary
359
+
360
+ candidate = :"vocabularies.#{vocabulary}.#{key}"
361
+ I18n.exists?(candidate, scope: :organizations, locale: options[:locale] || I18n.locale) ? candidate : nil
362
+ end
363
+
364
+ # The vocabulary config.vocabulary selects: a Symbol/String for the whole
365
+ # app, or a callable that receives the organization (never called
366
+ # without one). nil selects the ordinary translations.
367
+ # @param organization [Organizations::Organization, nil]
368
+ # @return [String, nil]
369
+ # A resolver that raises, or returns a name that is not one plain label
370
+ # (letters, digits, underscores), selects the ordinary translations and
371
+ # logs a warning: wording must never break a page or an email.
372
+ def vocabulary_for(organization)
373
+ setting = configuration.vocabulary
374
+ setting = organization ? setting.call(organization) : nil if setting.respond_to?(:call)
375
+ name = setting.to_s.presence
376
+ return nil unless name
377
+ return name if name.match?(/\A[a-z0-9_]+\z/i)
378
+
379
+ Callbacks.log_warn("[Organizations] Ignored vocabulary #{name.inspect}: use letters, digits and underscores")
380
+ nil
381
+ rescue StandardError => e
382
+ Callbacks.log_warn("[Organizations] The vocabulary resolver failed: #{e.class}: #{e.message}")
383
+ nil
384
+ end
385
+
291
386
  alias t translate
292
387
  end
293
388
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-17 00:00:00.000000000 Z
10
+ date: 2026-10-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -101,6 +101,7 @@ files:
101
101
  - app/mailers/organizations/verification_mailer.rb
102
102
  - app/models/organizations/allowlist_entry.rb
103
103
  - app/models/organizations/domain.rb
104
+ - app/models/organizations/grant.rb
104
105
  - app/models/organizations/invitation.rb
105
106
  - app/models/organizations/join_code.rb
106
107
  - app/models/organizations/join_request.rb
@@ -120,9 +121,20 @@ files:
120
121
  - docs/organizations-team-members.webp
121
122
  - gemfiles/rails_7.2.gemfile
122
123
  - gemfiles/rails_8.1.gemfile
124
+ - lib/generators/organizations/grants/grants_generator.rb
125
+ - lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb
123
126
  - lib/generators/organizations/install/install_generator.rb
124
127
  - lib/generators/organizations/install/templates/create_organizations_tables.rb.erb
125
128
  - lib/generators/organizations/install/templates/initializer.rb
129
+ - lib/generators/organizations/join/join_generator.rb
130
+ - lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb
131
+ - lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb
132
+ - lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb
133
+ - lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb
134
+ - lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb
135
+ - lib/generators/organizations/join/templates/routes.rb
136
+ - lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb
137
+ - lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb
126
138
  - lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb
127
139
  - lib/generators/organizations/upgrade/upgrade_generator.rb
128
140
  - lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb
@@ -137,6 +149,7 @@ files:
137
149
  - lib/generators/organizations/views/templates/organizations/organizations/show.html.erb
138
150
  - lib/generators/organizations/views/views_generator.rb
139
151
  - lib/organizations.rb
152
+ - lib/organizations/account_deletion_plan.rb
140
153
  - lib/organizations/acts_as_tenant_integration.rb
141
154
  - lib/organizations/callback_context.rb
142
155
  - lib/organizations/callbacks.rb
@@ -145,6 +158,9 @@ files:
145
158
  - lib/organizations/current_user_resolution.rb
146
159
  - lib/organizations/email_normalizer.rb
147
160
  - lib/organizations/engine.rb
161
+ - lib/organizations/grant_access.rb
162
+ - lib/organizations/grant_holder.rb
163
+ - lib/organizations/grantable.rb
148
164
  - lib/organizations/invitation_acceptance_failure.rb
149
165
  - lib/organizations/invitation_acceptance_result.rb
150
166
  - lib/organizations/join_flow.rb
@@ -153,12 +169,16 @@ files:
153
169
  - lib/organizations/models/allowlist_entry.rb
154
170
  - lib/organizations/models/concerns/has_organizations.rb
155
171
  - lib/organizations/models/domain.rb
172
+ - lib/organizations/models/grant.rb
156
173
  - lib/organizations/models/invitation.rb
157
174
  - lib/organizations/models/join_code.rb
158
175
  - lib/organizations/models/join_request.rb
159
176
  - lib/organizations/models/membership.rb
160
177
  - lib/organizations/models/organization.rb
161
178
  - lib/organizations/organization_scoped.rb
179
+ - lib/organizations/personal_owner_backfill.rb
180
+ - lib/organizations/public_email_domains.rb
181
+ - lib/organizations/resource.rb
162
182
  - lib/organizations/roles.rb
163
183
  - lib/organizations/test_helpers.rb
164
184
  - lib/organizations/version.rb