organizations 0.5.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 (67) 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/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. metadata +22 -2
@@ -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
@@ -56,6 +56,9 @@ module Organizations
56
56
  ].freeze
57
57
  }.freeze
58
58
 
59
+ # One published roles configuration (see Roles.snapshot).
60
+ Snapshot = Struct.new(:definition, :hierarchy, :permissions, :permission_sets)
61
+
59
62
  class << self
60
63
  # Get the default permission structure
61
64
  # @return [Hash<Symbol, Array<Symbol>>]
@@ -66,13 +69,13 @@ module Organizations
66
69
  # Get the role hierarchy
67
70
  # @return [Array<Symbol>]
68
71
  def hierarchy
69
- HIERARCHY
72
+ snapshot.hierarchy
70
73
  end
71
74
 
72
75
  # Get all valid role names
73
76
  # @return [Array<Symbol>]
74
77
  def valid_roles
75
- HIERARCHY
78
+ hierarchy
76
79
  end
77
80
 
78
81
  # Check if a role is valid
@@ -81,7 +84,7 @@ module Organizations
81
84
  def valid_role?(role)
82
85
  return false if role.nil?
83
86
 
84
- HIERARCHY.include?(role.to_sym)
87
+ hierarchy.include?(role.to_sym)
85
88
  end
86
89
 
87
90
  # Check if role_a is at least as high as role_b in hierarchy
@@ -96,8 +99,9 @@ module Organizations
96
99
  def at_least?(role_a, role_b)
97
100
  return false if role_a.nil? || role_b.nil?
98
101
 
99
- idx_a = HIERARCHY.index(role_a.to_sym)
100
- idx_b = HIERARCHY.index(role_b.to_sym)
102
+ order = hierarchy # one snapshot for both lookups
103
+ idx_a = order.index(role_a.to_sym)
104
+ idx_b = order.index(role_b.to_sym)
101
105
 
102
106
  return false unless idx_a && idx_b
103
107
 
@@ -110,14 +114,62 @@ module Organizations
110
114
  # @param role_b [Symbol, String] Second role
111
115
  # @return [Integer] -1 if a > b, 0 if equal, 1 if a < b
112
116
  def compare(role_a, role_b)
113
- idx_a = HIERARCHY.index(role_a.to_sym)
114
- idx_b = HIERARCHY.index(role_b.to_sym)
117
+ # A role that is no longer configured ranks below every configured role.
118
+ order = hierarchy # one snapshot for both lookups
119
+ idx_a = order.index(role_a.to_sym) || order.length
120
+ idx_b = order.index(role_b.to_sym) || order.length
115
121
 
116
122
  return 0 if idx_a == idx_b
117
123
 
118
124
  idx_a < idx_b ? -1 : 1
119
125
  end
120
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
+
121
173
  # Get all permissions for a role (pre-computed, O(1) lookup)
122
174
  # @param role [Symbol, String] The role
123
175
  # @return [Array<Symbol>] All permissions for the role
@@ -146,19 +198,18 @@ module Organizations
146
198
  # Get the pre-computed permission hash (for direct access)
147
199
  # @return [Hash<Symbol, Array<Symbol>>]
148
200
  def permissions
149
- @permissions ||= compute_permissions
201
+ snapshot.permissions
150
202
  end
151
203
 
152
204
  # Get the pre-computed permission sets (for O(1) lookups)
153
205
  # @return [Hash<Symbol, Set<Symbol>>]
154
206
  def permission_sets
155
- @permission_sets ||= permissions.transform_values { |perms| Set.new(perms) }
207
+ snapshot.permission_sets
156
208
  end
157
209
 
158
210
  # Reset computed permissions (used when custom roles are defined)
159
211
  def reset!
160
- @permissions = nil
161
- @permission_sets = nil
212
+ @snapshot = nil
162
213
  end
163
214
 
164
215
  # Get the next role up in hierarchy
@@ -167,10 +218,11 @@ module Organizations
167
218
  def higher_role(role)
168
219
  return nil if role.nil?
169
220
 
170
- idx = HIERARCHY.index(role.to_sym)
221
+ order = hierarchy # one snapshot for the lookup and the result
222
+ idx = order.index(role.to_sym)
171
223
  return nil unless idx && idx > 0
172
224
 
173
- HIERARCHY[idx - 1]
225
+ order[idx - 1]
174
226
  end
175
227
 
176
228
  # Get the next role down in hierarchy
@@ -179,23 +231,63 @@ module Organizations
179
231
  def lower_role(role)
180
232
  return nil if role.nil?
181
233
 
182
- idx = HIERARCHY.index(role.to_sym)
183
- return nil unless idx && idx < HIERARCHY.length - 1
234
+ order = hierarchy # one snapshot for the lookup and the result
235
+ idx = order.index(role.to_sym)
236
+ return nil unless idx && idx < order.length - 1
237
+
238
+ order[idx + 1]
239
+ end
184
240
 
185
- HIERARCHY[idx + 1]
241
+ # The role hierarchy (highest first) of a roles definition: the four
242
+ # built-in roles, and each custom role directly above the role it
243
+ # inherits from (a custom role without `inherits:` sits at the bottom).
244
+ def hierarchy_for(definition)
245
+ return HIERARCHY unless definition
246
+
247
+ builder = RoleBuilder.new
248
+ builder.instance_eval(&definition)
249
+ builder.validate_privileges!
250
+ builder.hierarchy
186
251
  end
187
252
 
188
253
  private
189
254
 
190
- def compute_permissions
191
- # Use custom roles if defined, otherwise use defaults
192
- custom_def = Organizations.configuration&.custom_roles_definition
193
- return DEFAULT_PERMISSIONS.dup unless custom_def
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
194
261
 
195
- # Build custom permissions using DSL
196
- builder = RoleBuilder.new
197
- builder.instance_eval(&custom_def)
198
- builder.to_permissions
262
+ def holds_permissions_of?(sets, actor_role, role) = (sets[role] || Set.new).subset?(sets[actor_role] || Set.new)
263
+
264
+ # The hierarchy and permissions of one roles definition, computed
265
+ # together and published as one immutable value. It belongs to the
266
+ # definition object it was built from: after a reconfiguration (or a
267
+ # rolled-back one) the next reader sees the identity change and builds a
268
+ # new snapshot, so a reader that raced a reset can never keep an
269
+ # obsolete hierarchy in place.
270
+ def snapshot
271
+ definition = Organizations.configuration&.custom_roles_definition
272
+ current = @snapshot
273
+ return current if current && current.definition.equal?(definition)
274
+
275
+ @snapshot = build_snapshot(definition)
276
+ end
277
+
278
+ def build_snapshot(definition)
279
+ if definition
280
+ builder = RoleBuilder.new
281
+ builder.instance_eval(&definition)
282
+ builder.validate_privileges!
283
+ hierarchy = builder.hierarchy
284
+ permissions = builder.to_permissions
285
+ else
286
+ hierarchy = HIERARCHY
287
+ permissions = DEFAULT_PERMISSIONS
288
+ end
289
+ sets = permissions.transform_values { |perms| Set.new(perms).freeze }.freeze
290
+ Snapshot.new(definition, hierarchy, permissions, sets).freeze
199
291
  end
200
292
  end
201
293
 
@@ -211,6 +303,10 @@ module Organizations
211
303
  # @param inherits [Symbol, nil] Role to inherit from
212
304
  # @yield Block to define permissions
213
305
  def role(name, inherits: nil, &block)
306
+ name = name.to_sym
307
+ inherits = inherits&.to_sym
308
+ validate_role_definition!(name, inherits)
309
+
214
310
  @roles[name] = {
215
311
  inherits: inherits,
216
312
  permissions: []
@@ -228,28 +324,94 @@ module Organizations
228
324
  @roles[@current_role][:permissions] << permission.to_sym
229
325
  end
230
326
 
327
+ # The hierarchy, highest first: the built-in roles in their fixed order,
328
+ # and each custom role inserted directly above the role it inherits from.
329
+ # @return [Array<Symbol>]
330
+ def hierarchy
331
+ order = Roles::HIERARCHY.dup
332
+ custom_roles.each do |name|
333
+ parent = @roles[name][:inherits]
334
+ parent ? order.insert(order.index(parent), name) : order << name
335
+ end
336
+ order.freeze
337
+ end
338
+
231
339
  # Build final permissions hash with inheritance resolved
232
340
  # @return [Hash<Symbol, Array<Symbol>>]
233
341
  def to_permissions
234
- result = {}
235
-
236
342
  # Process roles in reverse hierarchy order (lowest first)
237
343
  # so inheritance works correctly
238
- Roles::HIERARCHY.reverse_each do |role_name|
239
- next unless @roles.key?(role_name)
344
+ hierarchy.reverse_each.with_object({}) do |role_name, result|
345
+ perms = @roles.key?(role_name) ? defined_permissions(role_name, result) : omitted_permissions(role_name)
346
+ result[role_name] = perms.freeze if perms
347
+ end.freeze
348
+ end
240
349
 
241
- role_def = @roles[role_name]
242
- perms = role_def[:permissions].dup
350
+ # An admin can assign a custom role, so a custom role may not grant a
351
+ # permission that the owner role has and the admin role lacks (for
352
+ # example manage_billing or transfer_ownership). Otherwise an admin
353
+ # could give another account a power that the admin does not have.
354
+ def validate_privileges!
355
+ validate_built_in_parents!
356
+ permissions = to_permissions
357
+ owner_only = (permissions[:owner] || DEFAULT_PERMISSIONS[:owner]) -
358
+ (permissions[:admin] || DEFAULT_PERMISSIONS[:admin])
359
+ custom_roles.each do |name|
360
+ granted = @roles[name][:permissions] & owner_only
361
+ next if granted.empty?
362
+
363
+ raise ConfigurationError, "Custom role #{name} cannot grant #{granted.join(', ')}: " \
364
+ "admins can assign it, and only the owner has that permission"
365
+ end
366
+ end
367
+
368
+ private
369
+
370
+ # A built-in role may inherit a custom role only when that custom role
371
+ # ranks below it; otherwise the inheritance would be dropped silently.
372
+ def validate_built_in_parents!
373
+ order = hierarchy
374
+ (@roles.keys & Roles::HIERARCHY).each do |name|
375
+ parent = @roles[name][:inherits]
376
+ next unless parent && custom_roles.include?(parent)
377
+ next if order.index(parent) > order.index(name)
378
+
379
+ raise ConfigurationError, "Role #{name} cannot inherit from #{parent}: #{parent} ranks above #{name}"
380
+ end
381
+ end
243
382
 
244
- # Add inherited permissions
245
- if role_def[:inherits] && result.key?(role_def[:inherits])
246
- perms = (result[role_def[:inherits]] + perms).uniq
247
- end
383
+ def custom_roles = @roles.keys - Roles::HIERARCHY
248
384
 
249
- result[role_name] = perms.freeze
385
+ # A role's own permissions plus its parent's. A custom role that inherits
386
+ # a built-in role the block does not redefine inherits its defaults.
387
+ def defined_permissions(role_name, result)
388
+ role_def = @roles[role_name]
389
+ parent = role_def[:inherits]
390
+ inherited = parent && (result[parent] || (custom_roles.include?(role_name) && DEFAULT_PERMISSIONS[parent]))
391
+ inherited ? (inherited + role_def[:permissions]).uniq : role_def[:permissions].dup
392
+ end
393
+
394
+ # A block that only adds custom roles keeps the built-in roles' defaults.
395
+ # An empty block still defines no permissions, as before custom roles.
396
+ def omitted_permissions(role_name) = custom_only? ? DEFAULT_PERMISSIONS[role_name] : nil
397
+ def custom_only? = custom_roles.any? && !@roles.keys.intersect?(Roles::HIERARCHY)
398
+
399
+ # A custom role needs a plain name, and a parent that already exists (a
400
+ # built-in role below admin or a custom role defined above it). Nothing
401
+ # inherits admin or owner.
402
+ def validate_role_definition!(name, inherits)
403
+ unless name.to_s.match?(/\A[a-z][a-z0-9_]{0,31}\z/)
404
+ raise ConfigurationError, "Role name #{name.inspect} must be lowercase letters, digits and underscores"
405
+ end
406
+ return if Roles::HIERARCHY.include?(name)
407
+ raise ConfigurationError, "Role #{name} is defined twice" if @roles.key?(name)
408
+ # A custom role ranks below admin, so it never outranks the people who can assign it.
409
+ if %i[owner admin].include?(inherits)
410
+ raise ConfigurationError, "Custom role #{name} cannot inherit from #{inherits}: a custom role ranks below admin"
250
411
  end
412
+ return if inherits.nil? || %i[member viewer].include?(inherits) || @roles.key?(inherits)
251
413
 
252
- result.freeze
414
+ raise ConfigurationError, "Custom role #{name} inherits from #{inherits}, which is not defined above it"
253
415
  end
254
416
  end
255
417
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.5.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