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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -9
- data/.rubocop_todo.yml +625 -0
- data/Appraisals +2 -0
- data/CHANGELOG.md +65 -0
- data/README.md +433 -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 +4 -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 +278 -3
- 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 +108 -18
- data/lib/organizations/models/join_code.rb +242 -0
- data/lib/organizations/models/join_request.rb +627 -0
- data/lib/organizations/models/membership.rb +48 -18
- data/lib/organizations/models/organization.rb +317 -19
- data/lib/organizations/organization_scoped.rb +135 -0
- data/lib/organizations/roles.rb +143 -36
- 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
|
@@ -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
|
data/lib/organizations/roles.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -101,13 +101,11 @@ module Organizations
|
|
|
101
101
|
# organization_role_label(:admin) # => "Admin"
|
|
102
102
|
#
|
|
103
103
|
def organization_role_label(role)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|