organizations 0.4.3 → 0.6.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/Appraisals +2 -0
  5. data/CHANGELOG.md +65 -0
  6. data/README.md +433 -41
  7. data/app/controllers/organizations/application_controller.rb +4 -0
  8. data/app/controllers/organizations/memberships_controller.rb +3 -3
  9. data/app/controllers/organizations/public_controller.rb +26 -0
  10. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  11. data/app/mailers/organizations/verification_mailer.rb +56 -0
  12. data/app/models/organizations/allowlist_entry.rb +6 -0
  13. data/app/models/organizations/domain.rb +6 -0
  14. data/app/models/organizations/join_code.rb +6 -0
  15. data/app/models/organizations/join_request.rb +6 -0
  16. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  17. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  18. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  19. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  20. data/config/locales/en.yml +158 -0
  21. data/config/locales/es.yml +126 -0
  22. data/config/routes.rb +46 -28
  23. data/gemfiles/rails_7.2.gemfile +4 -3
  24. data/gemfiles/rails_8.1.gemfile +3 -3
  25. data/lib/generators/organizations/install/install_generator.rb +3 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  27. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  28. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  29. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  33. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  34. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  39. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  40. data/lib/generators/organizations/views/views_generator.rb +47 -0
  41. data/lib/organizations/callback_context.rb +9 -0
  42. data/lib/organizations/callbacks.rb +26 -18
  43. data/lib/organizations/configuration.rb +278 -3
  44. data/lib/organizations/controller_helpers.rb +38 -12
  45. data/lib/organizations/email_normalizer.rb +65 -0
  46. data/lib/organizations/join_flow.rb +227 -0
  47. data/lib/organizations/join_state.rb +117 -0
  48. data/lib/organizations/metadata_flags.rb +79 -0
  49. data/lib/organizations/models/allowlist_entry.rb +88 -0
  50. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  51. data/lib/organizations/models/domain.rb +85 -0
  52. data/lib/organizations/models/invitation.rb +108 -18
  53. data/lib/organizations/models/join_code.rb +242 -0
  54. data/lib/organizations/models/join_request.rb +627 -0
  55. data/lib/organizations/models/membership.rb +48 -18
  56. data/lib/organizations/models/organization.rb +317 -19
  57. data/lib/organizations/organization_scoped.rb +135 -0
  58. data/lib/organizations/roles.rb +143 -36
  59. data/lib/organizations/test_helpers.rb +35 -1
  60. data/lib/organizations/version.rb +1 -1
  61. data/lib/organizations/view_helpers.rb +8 -12
  62. data/lib/organizations.rb +165 -0
  63. metadata +34 -2
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # URL-SCOPED organization resolution — the second addressing mode.
5
+ #
6
+ # The engine's own controllers are SESSION-scoped (one "current"
7
+ # organization per user, workspace/tenant style — the LicenseSeat shape).
8
+ # Overlay-style hosts (community/marketplace apps where users belong to
9
+ # organizations but don't "work inside" one) address organizations BY URL
10
+ # instead: /org/:slug/admin, /teams/:id/settings. Before this concern,
11
+ # such hosts re-derived the same base controller by hand: find org by
12
+ # param → find viewer membership → role-gate → pick a not-found posture.
13
+ #
14
+ # @example A slug-addressed admin portal (the overlay shape)
15
+ # class Portal::BaseController < ApplicationController
16
+ # include Organizations::OrganizationScoped
17
+ #
18
+ # self.organization_param = :slug
19
+ # self.organization_finder = ->(param) { Organizations::Organization.find_by(slug: param) }
20
+ # # 404-never-403: don't disclose which orgs/surfaces exist (default).
21
+ # require_organization_role :admin
22
+ # end
23
+ #
24
+ # class Portal::MembersController < Portal::BaseController
25
+ # def index
26
+ # @memberships = current_scoped_organization.memberships.includes(:user)
27
+ # end
28
+ # end
29
+ #
30
+ # Knobs (class_attributes, inheritable/overridable per controller):
31
+ # organization_param — request param holding the identifier
32
+ # (default :organization_id)
33
+ # organization_finder — ->(param) { ... } returning the org or nil;
34
+ # instance_exec'd on the controller (default
35
+ # find_by(id:))
36
+ # organization_not_found_behavior — :not_found (default) raises
37
+ # ActionController::RoutingError so unknown orgs,
38
+ # non-members, and under-role members are all
39
+ # INDISTINGUISHABLE (no existence oracle);
40
+ # :forbidden responds 403 instead (internal
41
+ # tools where disclosure is fine).
42
+ #
43
+ # Deliberately DISTINCT from the session helpers (current_organization/
44
+ # current_membership): the two modes coexist — a session-tenant app can
45
+ # still expose a URL-scoped surface — so this concern never touches the
46
+ # session or the model-level current-organization context.
47
+ module OrganizationScoped
48
+ extend ActiveSupport::Concern
49
+ include Organizations::CurrentUserResolution
50
+
51
+ included do
52
+ class_attribute :organization_param, default: :organization_id, instance_writer: false
53
+ class_attribute :organization_finder,
54
+ default: ->(param) { Organizations::Organization.find_by(id: param) },
55
+ instance_writer: false
56
+ class_attribute :organization_not_found_behavior, default: :not_found, instance_writer: false
57
+
58
+ before_action :set_scoped_organization if respond_to?(:before_action)
59
+
60
+ if respond_to?(:helper_method)
61
+ helper_method :current_scoped_organization
62
+ helper_method :current_scoped_membership
63
+ end
64
+ end
65
+
66
+ class_methods do
67
+ # Gate every action (or only:/except: subsets, forwarded to
68
+ # before_action) behind a minimum role in the scoped organization.
69
+ # Below-role viewers get the configured not-found behavior — same
70
+ # posture as an unknown organization, on purpose.
71
+ #
72
+ # @param role [Symbol] minimum role (uses the gem hierarchy /
73
+ # custom-role at_least semantics)
74
+ def require_organization_role(role, **options)
75
+ before_action(**options) { require_scoped_organization_role!(role) }
76
+ end
77
+ end
78
+
79
+ # The organization resolved from the URL for this request.
80
+ # @return [Organizations::Organization, nil]
81
+ def current_scoped_organization
82
+ @current_scoped_organization
83
+ end
84
+
85
+ # The requesting user's membership in the scoped organization.
86
+ # Memoized per request; nil for strangers/signed-out.
87
+ # @return [Organizations::Membership, nil]
88
+ def current_scoped_membership
89
+ return @current_scoped_membership if defined?(@current_scoped_membership)
90
+
91
+ user = scoped_organization_viewer
92
+ @current_scoped_membership =
93
+ (current_scoped_organization.memberships.find_by(user_id: user.id) if user && current_scoped_organization)
94
+ end
95
+
96
+ private
97
+
98
+ # The requesting user via the shared resolution (configured
99
+ # current_user_method, Warden fallback) — own cache ivar so it never
100
+ # collides with ControllerHelpers' memoization when both are included.
101
+ def scoped_organization_viewer
102
+ resolve_organizations_current_user(
103
+ cache_ivar: :@_scoped_organization_viewer,
104
+ prefer_super_for_current_user: false,
105
+ prefer_warden_for_current_user: true
106
+ )
107
+ end
108
+
109
+ def set_scoped_organization
110
+ finder = self.class.organization_finder
111
+ @current_scoped_organization = instance_exec(params[self.class.organization_param], &finder)
112
+
113
+ scoped_organization_not_found! unless @current_scoped_organization
114
+ end
115
+
116
+ def require_scoped_organization_role!(role)
117
+ membership = current_scoped_membership
118
+ return if membership&.is_at_least?(role)
119
+
120
+ scoped_organization_not_found!
121
+ end
122
+
123
+ def scoped_organization_not_found!
124
+ case self.class.organization_not_found_behavior
125
+ when :forbidden
126
+ head :forbidden
127
+ else
128
+ # RoutingError renders the host's 404 page — chosen over head(:not_found)
129
+ # so the user sees the app's normal not-found experience, and over 403
130
+ # so existence is never disclosed.
131
+ raise ActionController::RoutingError, "Not Found"
132
+ end
133
+ end
134
+ end
135
+ 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,8 +114,10 @@ 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
 
@@ -146,19 +152,18 @@ module Organizations
146
152
  # Get the pre-computed permission hash (for direct access)
147
153
  # @return [Hash<Symbol, Array<Symbol>>]
148
154
  def permissions
149
- @permissions ||= compute_permissions
155
+ snapshot.permissions
150
156
  end
151
157
 
152
158
  # Get the pre-computed permission sets (for O(1) lookups)
153
159
  # @return [Hash<Symbol, Set<Symbol>>]
154
160
  def permission_sets
155
- @permission_sets ||= permissions.transform_values { |perms| Set.new(perms) }
161
+ snapshot.permission_sets
156
162
  end
157
163
 
158
164
  # Reset computed permissions (used when custom roles are defined)
159
165
  def reset!
160
- @permissions = nil
161
- @permission_sets = nil
166
+ @snapshot = nil
162
167
  end
163
168
 
164
169
  # Get the next role up in hierarchy
@@ -167,10 +172,11 @@ module Organizations
167
172
  def higher_role(role)
168
173
  return nil if role.nil?
169
174
 
170
- idx = HIERARCHY.index(role.to_sym)
175
+ order = hierarchy # one snapshot for the lookup and the result
176
+ idx = order.index(role.to_sym)
171
177
  return nil unless idx && idx > 0
172
178
 
173
- HIERARCHY[idx - 1]
179
+ order[idx - 1]
174
180
  end
175
181
 
176
182
  # Get the next role down in hierarchy
@@ -179,23 +185,54 @@ module Organizations
179
185
  def lower_role(role)
180
186
  return nil if role.nil?
181
187
 
182
- idx = HIERARCHY.index(role.to_sym)
183
- return nil unless idx && idx < HIERARCHY.length - 1
188
+ order = hierarchy # one snapshot for the lookup and the result
189
+ idx = order.index(role.to_sym)
190
+ return nil unless idx && idx < order.length - 1
184
191
 
185
- HIERARCHY[idx + 1]
192
+ order[idx + 1]
193
+ end
194
+
195
+ # The role hierarchy (highest first) of a roles definition: the four
196
+ # built-in roles, and each custom role directly above the role it
197
+ # inherits from (a custom role without `inherits:` sits at the bottom).
198
+ def hierarchy_for(definition)
199
+ return HIERARCHY unless definition
200
+
201
+ builder = RoleBuilder.new
202
+ builder.instance_eval(&definition)
203
+ builder.validate_privileges!
204
+ builder.hierarchy
186
205
  end
187
206
 
188
207
  private
189
208
 
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
209
+ # The hierarchy and permissions of one roles definition, computed
210
+ # together and published as one immutable value. It belongs to the
211
+ # definition object it was built from: after a reconfiguration (or a
212
+ # rolled-back one) the next reader sees the identity change and builds a
213
+ # new snapshot, so a reader that raced a reset can never keep an
214
+ # obsolete hierarchy in place.
215
+ def snapshot
216
+ definition = Organizations.configuration&.custom_roles_definition
217
+ current = @snapshot
218
+ return current if current && current.definition.equal?(definition)
219
+
220
+ @snapshot = build_snapshot(definition)
221
+ end
194
222
 
195
- # Build custom permissions using DSL
196
- builder = RoleBuilder.new
197
- builder.instance_eval(&custom_def)
198
- builder.to_permissions
223
+ def build_snapshot(definition)
224
+ if definition
225
+ builder = RoleBuilder.new
226
+ builder.instance_eval(&definition)
227
+ builder.validate_privileges!
228
+ hierarchy = builder.hierarchy
229
+ permissions = builder.to_permissions
230
+ else
231
+ hierarchy = HIERARCHY
232
+ permissions = DEFAULT_PERMISSIONS
233
+ end
234
+ sets = permissions.transform_values { |perms| Set.new(perms).freeze }.freeze
235
+ Snapshot.new(definition, hierarchy, permissions, sets).freeze
199
236
  end
200
237
  end
201
238
 
@@ -211,6 +248,10 @@ module Organizations
211
248
  # @param inherits [Symbol, nil] Role to inherit from
212
249
  # @yield Block to define permissions
213
250
  def role(name, inherits: nil, &block)
251
+ name = name.to_sym
252
+ inherits = inherits&.to_sym
253
+ validate_role_definition!(name, inherits)
254
+
214
255
  @roles[name] = {
215
256
  inherits: inherits,
216
257
  permissions: []
@@ -228,28 +269,94 @@ module Organizations
228
269
  @roles[@current_role][:permissions] << permission.to_sym
229
270
  end
230
271
 
272
+ # The hierarchy, highest first: the built-in roles in their fixed order,
273
+ # and each custom role inserted directly above the role it inherits from.
274
+ # @return [Array<Symbol>]
275
+ def hierarchy
276
+ order = Roles::HIERARCHY.dup
277
+ custom_roles.each do |name|
278
+ parent = @roles[name][:inherits]
279
+ parent ? order.insert(order.index(parent), name) : order << name
280
+ end
281
+ order.freeze
282
+ end
283
+
231
284
  # Build final permissions hash with inheritance resolved
232
285
  # @return [Hash<Symbol, Array<Symbol>>]
233
286
  def to_permissions
234
- result = {}
235
-
236
287
  # Process roles in reverse hierarchy order (lowest first)
237
288
  # so inheritance works correctly
238
- Roles::HIERARCHY.reverse_each do |role_name|
239
- next unless @roles.key?(role_name)
289
+ hierarchy.reverse_each.with_object({}) do |role_name, result|
290
+ perms = @roles.key?(role_name) ? defined_permissions(role_name, result) : omitted_permissions(role_name)
291
+ result[role_name] = perms.freeze if perms
292
+ end.freeze
293
+ end
240
294
 
241
- role_def = @roles[role_name]
242
- perms = role_def[:permissions].dup
295
+ # An admin can assign a custom role, so a custom role may not grant a
296
+ # permission that the owner role has and the admin role lacks (for
297
+ # example manage_billing or transfer_ownership). Otherwise an admin
298
+ # could give another account a power that the admin does not have.
299
+ def validate_privileges!
300
+ validate_built_in_parents!
301
+ permissions = to_permissions
302
+ owner_only = (permissions[:owner] || DEFAULT_PERMISSIONS[:owner]) -
303
+ (permissions[:admin] || DEFAULT_PERMISSIONS[:admin])
304
+ custom_roles.each do |name|
305
+ granted = @roles[name][:permissions] & owner_only
306
+ next if granted.empty?
307
+
308
+ raise ConfigurationError, "Custom role #{name} cannot grant #{granted.join(', ')}: " \
309
+ "admins can assign it, and only the owner has that permission"
310
+ end
311
+ end
243
312
 
244
- # Add inherited permissions
245
- if role_def[:inherits] && result.key?(role_def[:inherits])
246
- perms = (result[role_def[:inherits]] + perms).uniq
247
- end
313
+ private
314
+
315
+ # A built-in role may inherit a custom role only when that custom role
316
+ # ranks below it; otherwise the inheritance would be dropped silently.
317
+ def validate_built_in_parents!
318
+ order = hierarchy
319
+ (@roles.keys & Roles::HIERARCHY).each do |name|
320
+ parent = @roles[name][:inherits]
321
+ next unless parent && custom_roles.include?(parent)
322
+ next if order.index(parent) > order.index(name)
248
323
 
249
- result[role_name] = perms.freeze
324
+ raise ConfigurationError, "Role #{name} cannot inherit from #{parent}: #{parent} ranks above #{name}"
325
+ end
326
+ end
327
+
328
+ def custom_roles = @roles.keys - Roles::HIERARCHY
329
+
330
+ # A role's own permissions plus its parent's. A custom role that inherits
331
+ # a built-in role the block does not redefine inherits its defaults.
332
+ def defined_permissions(role_name, result)
333
+ role_def = @roles[role_name]
334
+ parent = role_def[:inherits]
335
+ inherited = parent && (result[parent] || (custom_roles.include?(role_name) && DEFAULT_PERMISSIONS[parent]))
336
+ inherited ? (inherited + role_def[:permissions]).uniq : role_def[:permissions].dup
337
+ end
338
+
339
+ # A block that only adds custom roles keeps the built-in roles' defaults.
340
+ # An empty block still defines no permissions, as before custom roles.
341
+ def omitted_permissions(role_name) = custom_only? ? DEFAULT_PERMISSIONS[role_name] : nil
342
+ def custom_only? = custom_roles.any? && !@roles.keys.intersect?(Roles::HIERARCHY)
343
+
344
+ # A custom role needs a plain name, and a parent that already exists (a
345
+ # built-in role below admin or a custom role defined above it). Nothing
346
+ # inherits admin or owner.
347
+ def validate_role_definition!(name, inherits)
348
+ unless name.to_s.match?(/\A[a-z][a-z0-9_]{0,31}\z/)
349
+ raise ConfigurationError, "Role name #{name.inspect} must be lowercase letters, digits and underscores"
350
+ end
351
+ return if Roles::HIERARCHY.include?(name)
352
+ raise ConfigurationError, "Role #{name} is defined twice" if @roles.key?(name)
353
+ # A custom role ranks below admin, so it never outranks the people who can assign it.
354
+ if %i[owner admin].include?(inherits)
355
+ raise ConfigurationError, "Custom role #{name} cannot inherit from #{inherits}: a custom role ranks below admin"
250
356
  end
357
+ return if inherits.nil? || %i[member viewer].include?(inherits) || @roles.key?(inherits)
251
358
 
252
- result.freeze
359
+ raise ConfigurationError, "Custom role #{name} inherits from #{inherits}, which is not defined above it"
253
360
  end
254
361
  end
255
362
  end
@@ -111,7 +111,9 @@ module Organizations
111
111
  # @param role [Symbol] Role in the organization
112
112
  # @return [User]
113
113
  def create_user(email: "user@example.com", name: "Test User", org: nil, role: :member)
114
- user = User.create!(email: email, name: name)
114
+ # Respects config.user_class so hosts with a differently-named account
115
+ # model can use this helper unchanged.
116
+ user = Organizations.user_class.create!(email: email, name: name)
115
117
 
116
118
  if org
117
119
  Organizations::Membership.create!(
@@ -124,6 +126,38 @@ module Organizations
124
126
  user
125
127
  end
126
128
 
129
+ # Force a KNOWN verification code onto a join request's active challenge,
130
+ # so tests can complete the emailed-code flow without intercepting mail.
131
+ #
132
+ # The database only ever stores a digest (SHA-256, peppered by row id) —
133
+ # before this helper, every host test suite reverse-engineered that
134
+ # recipe by hand (overwriting verification_code_digest with
135
+ # digest_verification_code). That's gem INTERNALS leaking into host
136
+ # tests; use this instead:
137
+ #
138
+ # request.start_email_verification!(email: "j.doe@acme.com")
139
+ # code = issue_verification_code(request) # => "424242"
140
+ # request.verify_email_code!(code) # => Membership
141
+ #
142
+ # Also callable WITHOUT including the module —
143
+ # Organizations::TestHelpers.issue_verification_code(request)
144
+ # — for suites whose own factory names (create_user, …) would collide
145
+ # with this module's (a real host hit exactly that).
146
+ #
147
+ # @param join_request [Organizations::JoinRequest] with a challenge started
148
+ # @param code [String] the plaintext code to force (default "424242")
149
+ # @return [String] the plaintext code, for typing into the flow
150
+ def issue_verification_code(join_request, code: "424242")
151
+ join_request.update!(
152
+ verification_code_digest: Organizations::JoinRequest.digest_verification_code(code, join_request.id)
153
+ )
154
+ code
155
+ end
156
+ module_function :issue_verification_code
157
+ # module_function makes the INSTANCE copy private — fine for included
158
+ # test usage (implicit receiver) while enabling the module-level call.
159
+ public :issue_verification_code
160
+
127
161
  # Assert that a user is a member of an organization
128
162
  # @param user [User] The user
129
163
  # @param org [Organizations::Organization] The organization
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.4.3"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -101,13 +101,11 @@ module Organizations
101
101
  # organization_role_label(:admin) # => "Admin"
102
102
  #
103
103
  def organization_role_label(role)
104
- case role.to_sym
105
- when :owner then "Owner"
106
- when :admin then "Admin"
107
- when :member then "Member"
108
- when :viewer then "Viewer"
109
- else role.to_s.humanize
110
- end
104
+ # I18n-backed (organizations.roles.*) so hosts localize/retheme labels
105
+ # by overriding locale keys instead of monkey-patching this helper.
106
+ # Custom roles defined via config.roles fall back to humanize unless
107
+ # the host adds a matching organizations.roles.<name> key.
108
+ Organizations.t(:"roles.#{role}", default: role.to_s.humanize)
111
109
  end
112
110
 
113
111
  # Returns a hash of role information for building badges
@@ -143,11 +141,9 @@ module Organizations
143
141
  # @param invitation [Organizations::Invitation] The invitation
144
142
  # @return [String]
145
143
  def organization_invitation_status_label(invitation)
146
- case organization_invitation_status(invitation)
147
- when :pending then "Pending"
148
- when :accepted then "Accepted"
149
- when :expired then "Expired"
150
- end
144
+ status = organization_invitation_status(invitation)
145
+ # I18n-backed (organizations.invitation_status.*) — see role labels.
146
+ Organizations.t(:"invitation_status.#{status}", default: status.to_s.humanize)
151
147
  end
152
148
 
153
149
  # Returns a hash of invitation status information