organizations 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -9
- data/.rubocop_todo.yml +625 -0
- data/CHANGELOG.md +46 -0
- data/README.md +405 -41
- data/app/controllers/organizations/application_controller.rb +4 -0
- data/app/controllers/organizations/memberships_controller.rb +3 -3
- data/app/controllers/organizations/public_controller.rb +26 -0
- data/app/mailers/organizations/invitation_mailer.rb +38 -18
- data/app/mailers/organizations/verification_mailer.rb +56 -0
- data/app/models/organizations/allowlist_entry.rb +6 -0
- data/app/models/organizations/domain.rb +6 -0
- data/app/models/organizations/join_code.rb +6 -0
- data/app/models/organizations/join_request.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
- data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
- data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
- data/config/locales/en.yml +158 -0
- data/config/locales/es.yml +126 -0
- data/config/routes.rb +46 -28
- data/gemfiles/rails_7.2.gemfile +3 -3
- data/gemfiles/rails_8.1.gemfile +3 -3
- data/lib/generators/organizations/install/install_generator.rb +3 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
- data/lib/generators/organizations/install/templates/initializer.rb +53 -0
- data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
- data/lib/generators/organizations/views/views_generator.rb +47 -0
- data/lib/organizations/callback_context.rb +9 -0
- data/lib/organizations/callbacks.rb +26 -18
- data/lib/organizations/configuration.rb +275 -1
- data/lib/organizations/controller_helpers.rb +38 -12
- data/lib/organizations/email_normalizer.rb +65 -0
- data/lib/organizations/join_flow.rb +227 -0
- data/lib/organizations/join_state.rb +117 -0
- data/lib/organizations/metadata_flags.rb +79 -0
- data/lib/organizations/models/allowlist_entry.rb +88 -0
- data/lib/organizations/models/concerns/has_organizations.rb +100 -12
- data/lib/organizations/models/domain.rb +85 -0
- data/lib/organizations/models/invitation.rb +105 -17
- data/lib/organizations/models/join_code.rb +242 -0
- data/lib/organizations/models/join_request.rb +627 -0
- data/lib/organizations/models/membership.rb +39 -8
- data/lib/organizations/models/organization.rb +317 -19
- data/lib/organizations/organization_scoped.rb +135 -0
- data/lib/organizations/test_helpers.rb +35 -1
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +8 -12
- data/lib/organizations.rb +165 -0
- metadata +34 -2
|
@@ -26,6 +26,22 @@ module Organizations
|
|
|
26
26
|
#
|
|
27
27
|
class Configuration
|
|
28
28
|
# === Authentication ===
|
|
29
|
+
# Class name of the host's user model (default: "User").
|
|
30
|
+
#
|
|
31
|
+
# Set this when your account model is named differently:
|
|
32
|
+
# config.user_class = "Account"
|
|
33
|
+
#
|
|
34
|
+
# ⚠️ Configure this BEFORE the gem's models are first referenced: the
|
|
35
|
+
# class name is read when each model class body executes. In Rails this
|
|
36
|
+
# is automatic (initializers run before Zeitwerk autoloads the models);
|
|
37
|
+
# in plain-Ruby usage, call Organizations.configure before touching
|
|
38
|
+
# Organizations::Organization & friends.
|
|
39
|
+
#
|
|
40
|
+
# ⚠️ The install generator's migrations reference `to_table: :users` —
|
|
41
|
+
# if your user model uses a different table, adjust the generated
|
|
42
|
+
# migration accordingly (see the comment inside the migration template).
|
|
43
|
+
attr_accessor :user_class
|
|
44
|
+
|
|
29
45
|
# Method that returns the current user (default: :current_user)
|
|
30
46
|
attr_accessor :current_user_method
|
|
31
47
|
|
|
@@ -81,6 +97,41 @@ module Organizations
|
|
|
81
97
|
# Custom mailer for invitations (class name as string)
|
|
82
98
|
attr_accessor :invitation_mailer
|
|
83
99
|
|
|
100
|
+
# === Verified Joining ===
|
|
101
|
+
# Custom mailer for email-verification codes (class name as string)
|
|
102
|
+
attr_accessor :verification_mailer
|
|
103
|
+
|
|
104
|
+
# How long an emailed verification code stays valid.
|
|
105
|
+
# Must be a Duration/Numeric — codes always expire (OTP hygiene).
|
|
106
|
+
attr_accessor :verification_code_ttl
|
|
107
|
+
|
|
108
|
+
# Maximum wrong-code attempts per challenge before it locks
|
|
109
|
+
attr_accessor :verification_max_attempts
|
|
110
|
+
|
|
111
|
+
# Minimum time between (re)sends of a verification code for one request
|
|
112
|
+
attr_accessor :verification_resend_interval
|
|
113
|
+
|
|
114
|
+
# Maximum number of code sends per join request
|
|
115
|
+
attr_accessor :verification_max_sends
|
|
116
|
+
|
|
117
|
+
# Custom email normalizer for the verified_email uniqueness invariant.
|
|
118
|
+
# nil = use Organizations::EmailNormalizer (downcase + strip + drop +tag).
|
|
119
|
+
# Can be a Proc/Lambda: ->(email) { ... } returning the normalized string.
|
|
120
|
+
attr_accessor :verification_email_normalizer
|
|
121
|
+
|
|
122
|
+
# Allow Organization#join_with_account_email! to trust a host user's
|
|
123
|
+
# already-confirmed account email (e.g. Devise :confirmable) as proof of
|
|
124
|
+
# inbox control, skipping the emailed code when the domain matches.
|
|
125
|
+
attr_accessor :trust_confirmed_account_email
|
|
126
|
+
|
|
127
|
+
# How long join requests stay pending before they read as expired
|
|
128
|
+
# (derived status, like invitations). nil = never expire.
|
|
129
|
+
attr_accessor :join_request_expiry
|
|
130
|
+
|
|
131
|
+
# Custom join code generator. nil = built-in 8-char ambiguity-free code.
|
|
132
|
+
# Can be a Proc/Lambda: -> { ... } returning the code string.
|
|
133
|
+
attr_accessor :join_code_generator
|
|
134
|
+
|
|
84
135
|
# === Limits ===
|
|
85
136
|
# Maximum organizations a user can own (nil = unlimited)
|
|
86
137
|
attr_accessor :max_organizations_per_user
|
|
@@ -132,6 +183,38 @@ module Organizations
|
|
|
132
183
|
attr_accessor :additional_organization_params
|
|
133
184
|
|
|
134
185
|
# === Engine configuration ===
|
|
186
|
+
|
|
187
|
+
# Which engine route groups to draw (devise_for-style skip/only, see
|
|
188
|
+
# https://rubydoc.info/github/heartcombo/devise/main/ActionDispatch/Routing/Mapper#devise_for-instance_method
|
|
189
|
+
# for the pattern precedent). nil (default) draws everything.
|
|
190
|
+
#
|
|
191
|
+
# config.engine_routes = { except: [:organizations] } # no org CRUD
|
|
192
|
+
# config.engine_routes = { only: [:switching, :public_invitations] }
|
|
193
|
+
#
|
|
194
|
+
# Groups: :switching (POST /organizations/switch/:id), :organizations
|
|
195
|
+
# (org CRUD), :memberships, :invitations (authenticated management),
|
|
196
|
+
# :public_invitations (token acceptance pages).
|
|
197
|
+
#
|
|
198
|
+
# Why this exists: hosts that keep the models but not the whole engine
|
|
199
|
+
# UI (e.g. an app where org creation is admin-vetted) previously had to
|
|
200
|
+
# SHADOW engine routes with redirects declared before the mount — a
|
|
201
|
+
# route-order-load-bearing hack that actually bit one production host
|
|
202
|
+
# (the engine's resources :organizations swallowed an app route as :id).
|
|
203
|
+
# Declare what you want instead.
|
|
204
|
+
attr_reader :engine_routes
|
|
205
|
+
|
|
206
|
+
ENGINE_ROUTE_GROUPS = %i[switching organizations memberships invitations public_invitations].freeze
|
|
207
|
+
|
|
208
|
+
def engine_routes=(value)
|
|
209
|
+
@engine_routes = value.nil? ? nil : normalize_engine_routes(value)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The resolved set of enabled groups.
|
|
213
|
+
# @return [Array<Symbol>]
|
|
214
|
+
def engine_route_groups
|
|
215
|
+
@engine_routes || ENGINE_ROUTE_GROUPS
|
|
216
|
+
end
|
|
217
|
+
|
|
135
218
|
# Base controller for authenticated routes (default: ::ApplicationController)
|
|
136
219
|
attr_accessor :parent_controller
|
|
137
220
|
|
|
@@ -147,6 +230,14 @@ module Organizations
|
|
|
147
230
|
# Can be nil (use controller default), String, or Symbol
|
|
148
231
|
attr_accessor :public_controller_layout
|
|
149
232
|
|
|
233
|
+
# Host helper modules to mix into the PUBLIC engine controller — it
|
|
234
|
+
# inherits ActionController::Base (not the host ApplicationController),
|
|
235
|
+
# so host layouts rendered by public pages need their helper modules
|
|
236
|
+
# declared here. Array of module names (Strings, constantized lazily at
|
|
237
|
+
# controller load — safe to set in an initializer) or Modules.
|
|
238
|
+
# @example config.public_controller_helpers = ["ApplicationHelper", "PageHelper"]
|
|
239
|
+
attr_accessor :public_controller_helpers
|
|
240
|
+
|
|
150
241
|
# === Handlers (blocks) ===
|
|
151
242
|
# @private - stored handler blocks
|
|
152
243
|
attr_reader :unauthorized_handler, :no_organization_handler
|
|
@@ -155,10 +246,15 @@ module Organizations
|
|
|
155
246
|
# @private - stored callback blocks
|
|
156
247
|
attr_reader :on_organization_created_callback,
|
|
157
248
|
:on_member_invited_callback,
|
|
249
|
+
:on_member_joining_callback,
|
|
158
250
|
:on_member_joined_callback,
|
|
159
251
|
:on_member_removed_callback,
|
|
160
252
|
:on_role_changed_callback,
|
|
161
|
-
:on_ownership_transferred_callback
|
|
253
|
+
:on_ownership_transferred_callback,
|
|
254
|
+
:on_join_request_created_callback,
|
|
255
|
+
:on_join_request_approved_callback,
|
|
256
|
+
:on_join_request_rejected_callback,
|
|
257
|
+
:on_verification_delivery_failed_callback
|
|
162
258
|
|
|
163
259
|
# === Custom Roles ===
|
|
164
260
|
# @private - custom roles definition
|
|
@@ -166,6 +262,7 @@ module Organizations
|
|
|
166
262
|
|
|
167
263
|
def initialize
|
|
168
264
|
# Authentication defaults
|
|
265
|
+
@user_class = "User"
|
|
169
266
|
@current_user_method = :current_user
|
|
170
267
|
@authenticate_user_method = :authenticate_user!
|
|
171
268
|
|
|
@@ -178,6 +275,17 @@ module Organizations
|
|
|
178
275
|
@default_invitation_role = :member
|
|
179
276
|
@invitation_mailer = "Organizations::InvitationMailer"
|
|
180
277
|
|
|
278
|
+
# Verified joining defaults
|
|
279
|
+
@verification_mailer = "Organizations::VerificationMailer"
|
|
280
|
+
@verification_code_ttl = 15.minutes
|
|
281
|
+
@verification_max_attempts = 5
|
|
282
|
+
@verification_resend_interval = 60.seconds
|
|
283
|
+
@verification_max_sends = 5
|
|
284
|
+
@verification_email_normalizer = nil
|
|
285
|
+
@trust_confirmed_account_email = true
|
|
286
|
+
@join_request_expiry = 30.days
|
|
287
|
+
@join_code_generator = nil
|
|
288
|
+
|
|
181
289
|
# Limits
|
|
182
290
|
@max_organizations_per_user = nil
|
|
183
291
|
|
|
@@ -202,10 +310,12 @@ module Organizations
|
|
|
202
310
|
@additional_organization_params = []
|
|
203
311
|
|
|
204
312
|
# Engine
|
|
313
|
+
@engine_routes = nil
|
|
205
314
|
@parent_controller = "::ApplicationController"
|
|
206
315
|
@public_controller = "ActionController::Base"
|
|
207
316
|
@authenticated_controller_layout = nil
|
|
208
317
|
@public_controller_layout = nil
|
|
318
|
+
@public_controller_helpers = []
|
|
209
319
|
|
|
210
320
|
# Handlers (nil by default - use default behavior)
|
|
211
321
|
@unauthorized_handler = nil
|
|
@@ -214,10 +324,15 @@ module Organizations
|
|
|
214
324
|
# Callbacks (nil by default - no-op)
|
|
215
325
|
@on_organization_created_callback = nil
|
|
216
326
|
@on_member_invited_callback = nil
|
|
327
|
+
@on_member_joining_callback = nil
|
|
217
328
|
@on_member_joined_callback = nil
|
|
218
329
|
@on_member_removed_callback = nil
|
|
219
330
|
@on_role_changed_callback = nil
|
|
220
331
|
@on_ownership_transferred_callback = nil
|
|
332
|
+
@on_join_request_created_callback = nil
|
|
333
|
+
@on_join_request_approved_callback = nil
|
|
334
|
+
@on_join_request_rejected_callback = nil
|
|
335
|
+
@on_verification_delivery_failed_callback = nil
|
|
221
336
|
|
|
222
337
|
# Custom roles
|
|
223
338
|
@custom_roles_definition = nil
|
|
@@ -267,6 +382,37 @@ module Organizations
|
|
|
267
382
|
@on_member_invited_callback = block if block_given?
|
|
268
383
|
end
|
|
269
384
|
|
|
385
|
+
# THE MEMBERSHIP GATE — called STRICTLY, inside the creating transaction,
|
|
386
|
+
# immediately BEFORE any non-owner membership row is inserted, on EVERY
|
|
387
|
+
# join path: add_member!, invitation acceptance, join-request approval
|
|
388
|
+
# (which covers join codes, domain-email verification, allowlists, and
|
|
389
|
+
# join_with_account_email!). Raise Organizations::MembershipVetoed (or any
|
|
390
|
+
# error) to veto: the transaction rolls back cleanly — no membership, join
|
|
391
|
+
# requests stay pending (resumable), invitations stay unaccepted.
|
|
392
|
+
#
|
|
393
|
+
# This is where hard limits belong (plan seat caps, per-org member caps,
|
|
394
|
+
# compliance holds). Unlike on_member_joined and the other after-callbacks
|
|
395
|
+
# (error-isolated by design), this one CAN and SHOULD abort the operation.
|
|
396
|
+
# It deliberately does NOT fire for owner memberships created with the
|
|
397
|
+
# organization itself (creating your own org is not "joining"), nor for
|
|
398
|
+
# idempotent already-a-member paths, nor for role changes.
|
|
399
|
+
#
|
|
400
|
+
# @yield [context] Block to execute (raise to veto)
|
|
401
|
+
# @yieldparam context [CallbackContext] organization, user, role,
|
|
402
|
+
# joined_via, and the instrument (invitation/join_request) when one applies
|
|
403
|
+
#
|
|
404
|
+
# @example Enforce plan seat limits on every join path (pricing_plans)
|
|
405
|
+
# config.on_member_joining do |ctx|
|
|
406
|
+
# limit = ctx.organization.current_plan&.limit_for(:team_members)
|
|
407
|
+
# if limit && ctx.organization.member_count >= limit
|
|
408
|
+
# raise Organizations::MembershipVetoed,
|
|
409
|
+
# "This organization has reached its plan's member limit."
|
|
410
|
+
# end
|
|
411
|
+
# end
|
|
412
|
+
def on_member_joining(&block)
|
|
413
|
+
@on_member_joining_callback = block if block_given?
|
|
414
|
+
end
|
|
415
|
+
|
|
270
416
|
# Called when a member joins (invitation accepted)
|
|
271
417
|
# @yield [context] Block to execute
|
|
272
418
|
# @yieldparam context [CallbackContext] Context with organization, membership, user
|
|
@@ -295,6 +441,41 @@ module Organizations
|
|
|
295
441
|
@on_ownership_transferred_callback = block if block_given?
|
|
296
442
|
end
|
|
297
443
|
|
|
444
|
+
# Called when a join request is created (request-to-join workflow)
|
|
445
|
+
# @yield [context] Block to execute
|
|
446
|
+
# @yieldparam context [CallbackContext] Context with organization, user, join_request
|
|
447
|
+
def on_join_request_created(&block)
|
|
448
|
+
@on_join_request_created_callback = block if block_given?
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Called when a join request is approved (manually or auto-approved).
|
|
452
|
+
# NOTE: like all after-callbacks, errors here are isolated — hosts must
|
|
453
|
+
# enforce hard caps (e.g. member limits) BEFORE approving, in their own code.
|
|
454
|
+
# @yield [context] Block to execute
|
|
455
|
+
# @yieldparam context [CallbackContext] Context with organization, user, join_request, membership, decided_by (nil for auto-approvals)
|
|
456
|
+
def on_join_request_approved(&block)
|
|
457
|
+
@on_join_request_approved_callback = block if block_given?
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Called when a join request is rejected
|
|
461
|
+
# @yield [context] Block to execute
|
|
462
|
+
# @yieldparam context [CallbackContext] Context with organization, user, join_request, decided_by
|
|
463
|
+
def on_join_request_rejected(&block)
|
|
464
|
+
@on_join_request_rejected_callback = block if block_given?
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Called when a verification-code email FAILS to enqueue/deliver.
|
|
468
|
+
# The gem already rolls the challenge's throttle bookkeeping back (so the
|
|
469
|
+
# user can retry immediately) — this hook is the host's observability
|
|
470
|
+
# seam: report to your error tracker (Rails.error, Sentry, …) so silent
|
|
471
|
+
# mail outages don't strand joiners.
|
|
472
|
+
# @yield [context] Block to execute
|
|
473
|
+
# @yieldparam context [CallbackContext] organization, user, join_request,
|
|
474
|
+
# metadata: { "error_class" =>, "error_message" => }
|
|
475
|
+
def on_verification_delivery_failed(&block)
|
|
476
|
+
@on_verification_delivery_failed_callback = block if block_given?
|
|
477
|
+
end
|
|
478
|
+
|
|
298
479
|
# === Roles Configuration ===
|
|
299
480
|
|
|
300
481
|
# Define custom roles with permissions
|
|
@@ -319,6 +500,16 @@ module Organizations
|
|
|
319
500
|
end
|
|
320
501
|
end
|
|
321
502
|
|
|
503
|
+
# Normalize an email through the configured (or default) normalizer
|
|
504
|
+
# @param email [String, nil]
|
|
505
|
+
# @return [String] normalized email
|
|
506
|
+
def normalize_verification_email(email)
|
|
507
|
+
normalizer = @verification_email_normalizer
|
|
508
|
+
return normalizer.call(email).to_s if normalizer.respond_to?(:call)
|
|
509
|
+
|
|
510
|
+
EmailNormalizer.normalize(email)
|
|
511
|
+
end
|
|
512
|
+
|
|
322
513
|
# Resolve the default organization name for a user
|
|
323
514
|
# @param user [Object] The user object
|
|
324
515
|
# @return [String] The organization name
|
|
@@ -338,6 +529,7 @@ module Organizations
|
|
|
338
529
|
def validate!
|
|
339
530
|
validate_authentication_methods!
|
|
340
531
|
validate_invitation_settings!
|
|
532
|
+
validate_verification_settings!
|
|
341
533
|
validate_limits!
|
|
342
534
|
validate_invitation_redirects!
|
|
343
535
|
validate_no_organization_messages!
|
|
@@ -348,6 +540,10 @@ module Organizations
|
|
|
348
540
|
private
|
|
349
541
|
|
|
350
542
|
def validate_authentication_methods!
|
|
543
|
+
unless @user_class.is_a?(String) && @user_class.present?
|
|
544
|
+
raise ConfigurationError, "user_class must be a non-empty String (e.g. \"User\")"
|
|
545
|
+
end
|
|
546
|
+
|
|
351
547
|
unless @current_user_method.is_a?(Symbol)
|
|
352
548
|
raise ConfigurationError, "current_user_method must be a Symbol"
|
|
353
549
|
end
|
|
@@ -367,6 +563,45 @@ module Organizations
|
|
|
367
563
|
end
|
|
368
564
|
end
|
|
369
565
|
|
|
566
|
+
def validate_verification_settings!
|
|
567
|
+
validate_duration_option!(@verification_code_ttl, "verification_code_ttl", "15.minutes")
|
|
568
|
+
validate_duration_option!(@verification_resend_interval, "verification_resend_interval", "60.seconds")
|
|
569
|
+
validate_positive_integer_option!(@verification_max_attempts, "verification_max_attempts")
|
|
570
|
+
validate_positive_integer_option!(@verification_max_sends, "verification_max_sends")
|
|
571
|
+
validate_callable_option!(@verification_email_normalizer, "verification_email_normalizer")
|
|
572
|
+
validate_callable_option!(@join_code_generator, "join_code_generator")
|
|
573
|
+
|
|
574
|
+
unless [true, false].include?(@trust_confirmed_account_email)
|
|
575
|
+
raise ConfigurationError, "trust_confirmed_account_email must be true or false"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
return if @join_request_expiry.nil? || duration_like?(@join_request_expiry)
|
|
579
|
+
|
|
580
|
+
raise ConfigurationError, "join_request_expiry must be a Duration (e.g., 30.days), Numeric, or nil (never expire)"
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def duration_like?(value)
|
|
584
|
+
value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def validate_duration_option!(value, option_name, example)
|
|
588
|
+
return if duration_like?(value)
|
|
589
|
+
|
|
590
|
+
raise ConfigurationError, "#{option_name} must be a Duration (e.g., #{example}) or Numeric"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def validate_positive_integer_option!(value, option_name)
|
|
594
|
+
return if value.is_a?(Integer) && value >= 1
|
|
595
|
+
|
|
596
|
+
raise ConfigurationError, "#{option_name} must be an Integer of at least 1"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def validate_callable_option!(value, option_name)
|
|
600
|
+
return if value.nil? || value.respond_to?(:call)
|
|
601
|
+
|
|
602
|
+
raise ConfigurationError, "#{option_name} must be nil or callable (Proc/Lambda)"
|
|
603
|
+
end
|
|
604
|
+
|
|
370
605
|
def validate_limits!
|
|
371
606
|
if @max_organizations_per_user && !@max_organizations_per_user.is_a?(Integer)
|
|
372
607
|
raise ConfigurationError, "max_organizations_per_user must be an Integer or nil"
|
|
@@ -395,6 +630,11 @@ module Organizations
|
|
|
395
630
|
def validate_controller_layouts!
|
|
396
631
|
validate_layout_option!(@authenticated_controller_layout, "authenticated_controller_layout")
|
|
397
632
|
validate_layout_option!(@public_controller_layout, "public_controller_layout")
|
|
633
|
+
|
|
634
|
+
unless @public_controller_helpers.is_a?(Array) &&
|
|
635
|
+
@public_controller_helpers.all? { |h| h.is_a?(String) || h.is_a?(Module) }
|
|
636
|
+
raise ConfigurationError, "public_controller_helpers must be an Array of Strings or Modules"
|
|
637
|
+
end
|
|
398
638
|
end
|
|
399
639
|
|
|
400
640
|
def validate_no_organization_messages!
|
|
@@ -422,5 +662,39 @@ module Organizations
|
|
|
422
662
|
raise ConfigurationError,
|
|
423
663
|
"#{option_name} must be nil or a String"
|
|
424
664
|
end
|
|
665
|
+
|
|
666
|
+
# Normalize {only:}/{except:}/Array(=only) into the enabled-group list,
|
|
667
|
+
# rejecting unknown group names loudly at configure time.
|
|
668
|
+
def normalize_engine_routes(value)
|
|
669
|
+
case value
|
|
670
|
+
when Array
|
|
671
|
+
validate_engine_route_groups!(value)
|
|
672
|
+
when Hash
|
|
673
|
+
only = value[:only]
|
|
674
|
+
except = value[:except]
|
|
675
|
+
if [only, except].compact.size != 1
|
|
676
|
+
raise ConfigurationError, "engine_routes takes exactly one of only:/except: (or an Array meaning only:)"
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
if only
|
|
680
|
+
validate_engine_route_groups!(Array(only))
|
|
681
|
+
else
|
|
682
|
+
ENGINE_ROUTE_GROUPS - validate_engine_route_groups!(Array(except))
|
|
683
|
+
end
|
|
684
|
+
else
|
|
685
|
+
raise ConfigurationError, "engine_routes must be a Hash with only:/except:, an Array, or nil"
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def validate_engine_route_groups!(groups)
|
|
690
|
+
groups = groups.map(&:to_sym)
|
|
691
|
+
unknown = groups - ENGINE_ROUTE_GROUPS
|
|
692
|
+
unless unknown.empty?
|
|
693
|
+
raise ConfigurationError,
|
|
694
|
+
"Unknown engine route group(s): #{unknown.join(', ')}. Valid: #{ENGINE_ROUTE_GROUPS.join(', ')}"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
groups
|
|
698
|
+
end
|
|
425
699
|
end
|
|
426
700
|
end
|
|
@@ -344,7 +344,7 @@ module Organizations
|
|
|
344
344
|
config_value,
|
|
345
345
|
invitation,
|
|
346
346
|
user,
|
|
347
|
-
default: -> { default_auth_required_redirect_path }
|
|
347
|
+
default: -> { default_auth_required_redirect_path(invitation) }
|
|
348
348
|
)
|
|
349
349
|
end
|
|
350
350
|
|
|
@@ -419,7 +419,7 @@ module Organizations
|
|
|
419
419
|
flash_options[:notice] = notice unless notice.nil?
|
|
420
420
|
|
|
421
421
|
# Keep current behavior for existing apps when nothing is configured/passed.
|
|
422
|
-
flash_options[:alert] = "
|
|
422
|
+
flash_options[:alert] = Organizations.t(:"notices.select_or_create_organization") if flash_options.empty?
|
|
423
423
|
|
|
424
424
|
redirect_to no_organization_redirect_path, **flash_options
|
|
425
425
|
false
|
|
@@ -468,7 +468,7 @@ module Organizations
|
|
|
468
468
|
|
|
469
469
|
unless membership_exists_for?(acting_user, org)
|
|
470
470
|
raise Organizations::NotAMember.new(
|
|
471
|
-
"
|
|
471
|
+
Organizations.t(:"errors.not_a_member"),
|
|
472
472
|
organization: org,
|
|
473
473
|
user: acting_user
|
|
474
474
|
)
|
|
@@ -624,11 +624,14 @@ module Organizations
|
|
|
624
624
|
|
|
625
625
|
def build_unauthorized_message(permission, required_role)
|
|
626
626
|
if required_role
|
|
627
|
-
|
|
627
|
+
# Same custom-role fallback as ViewHelpers#organization_role_label:
|
|
628
|
+
# humanized, so "superfan" renders as "Superfan" in both places.
|
|
629
|
+
Organizations.t(:"errors.unauthorized_role",
|
|
630
|
+
role: Organizations.t(:"roles.#{required_role}", default: required_role.to_s.humanize))
|
|
628
631
|
elsif permission
|
|
629
|
-
"
|
|
632
|
+
Organizations.t(:"errors.unauthorized_permission", permission: permission.to_s.humanize.downcase)
|
|
630
633
|
else
|
|
631
|
-
"
|
|
634
|
+
Organizations.t(:"errors.unauthorized")
|
|
632
635
|
end
|
|
633
636
|
end
|
|
634
637
|
|
|
@@ -662,7 +665,7 @@ module Organizations
|
|
|
662
665
|
notice: config.no_organization_notice
|
|
663
666
|
)
|
|
664
667
|
end
|
|
665
|
-
format.json { render json: { error: "
|
|
668
|
+
format.json { render json: { error: Organizations.t(:"errors.organization_required") }, status: :forbidden }
|
|
666
669
|
end
|
|
667
670
|
end
|
|
668
671
|
|
|
@@ -745,10 +748,20 @@ module Organizations
|
|
|
745
748
|
end
|
|
746
749
|
end
|
|
747
750
|
|
|
748
|
-
# Default path when invitation requires authentication
|
|
751
|
+
# Default path when invitation requires authentication.
|
|
752
|
+
#
|
|
753
|
+
# KNOWN-USER PROMOTION (default since 0.5.0): an invitation sent to an
|
|
754
|
+
# address that already has an account lands on SIGN-IN; everyone else
|
|
755
|
+
# lands on SIGN-UP. Both production hosts overrode the old always-sign-up
|
|
756
|
+
# default with this exact lambda — when every real host writes the same
|
|
757
|
+
# override, that's the default. Opt out by configuring
|
|
758
|
+
# redirect_path_when_invitation_requires_authentication yourself.
|
|
759
|
+
# @param invitation [Organizations::Invitation, nil]
|
|
749
760
|
# @return [String]
|
|
750
|
-
def default_auth_required_redirect_path
|
|
751
|
-
if main_app.respond_to?(:
|
|
761
|
+
def default_auth_required_redirect_path(invitation = nil)
|
|
762
|
+
if known_invited_user?(invitation) && main_app.respond_to?(:new_user_session_path)
|
|
763
|
+
main_app.new_user_session_path
|
|
764
|
+
elsif main_app.respond_to?(:new_user_registration_path)
|
|
752
765
|
main_app.new_user_registration_path
|
|
753
766
|
elsif main_app.respond_to?(:root_path)
|
|
754
767
|
main_app.root_path
|
|
@@ -757,6 +770,19 @@ module Organizations
|
|
|
757
770
|
end
|
|
758
771
|
end
|
|
759
772
|
|
|
773
|
+
# Does the invited address already belong to an account? Fails closed
|
|
774
|
+
# (false → sign-up) if the user class can't answer — never let a lookup
|
|
775
|
+
# error break the invitation flow.
|
|
776
|
+
def known_invited_user?(invitation)
|
|
777
|
+
email = invitation&.email.to_s.downcase
|
|
778
|
+
return false if email.blank?
|
|
779
|
+
|
|
780
|
+
klass = Organizations.user_class
|
|
781
|
+
klass.respond_to?(:where) && klass.where("LOWER(email) = ?", email).exists?
|
|
782
|
+
rescue StandardError
|
|
783
|
+
false
|
|
784
|
+
end
|
|
785
|
+
|
|
760
786
|
# Default path after invitation acceptance
|
|
761
787
|
# @return [String]
|
|
762
788
|
def default_after_accept_redirect_path
|
|
@@ -779,9 +805,9 @@ module Organizations
|
|
|
779
805
|
|
|
780
806
|
def default_pending_invitation_acceptance_notice(result)
|
|
781
807
|
organization_name = result.invitation.organization.name
|
|
782
|
-
return "
|
|
808
|
+
return Organizations.t(:"notices.already_member", organization: organization_name) if result.already_member?
|
|
783
809
|
|
|
784
|
-
"
|
|
810
|
+
Organizations.t(:"notices.welcome", organization: organization_name)
|
|
785
811
|
end
|
|
786
812
|
end
|
|
787
813
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Default email normalization for verified joining.
|
|
5
|
+
#
|
|
6
|
+
# Verified joining enforces "one proven email address => one membership per
|
|
7
|
+
# organization" (partial unique index on memberships.verified_email_normalized).
|
|
8
|
+
# That invariant is only meaningful if trivially-aliased addresses collapse to
|
|
9
|
+
# the same normalized value, otherwise one inbox can mint unlimited "distinct"
|
|
10
|
+
# identities via plus-addressing (user+1@corp.com, user+2@corp.com, ...).
|
|
11
|
+
#
|
|
12
|
+
# Normalization rules (deliberately conservative):
|
|
13
|
+
# - downcase + strip surrounding whitespace
|
|
14
|
+
# - strip a single trailing dot from the domain (FQDN form: "corp.com.")
|
|
15
|
+
# - drop the +tag suffix from the local part (RFC 5233 subaddressing)
|
|
16
|
+
#
|
|
17
|
+
# Gmail-style dot-collapsing in the local part is intentionally NOT applied:
|
|
18
|
+
# dots are significant in most corporate mail systems, and collapsing them
|
|
19
|
+
# would wrongly merge distinct mailboxes (j.doe@ vs jdoe@).
|
|
20
|
+
#
|
|
21
|
+
# Hosts can replace this wholesale via:
|
|
22
|
+
# config.verification_email_normalizer = ->(email) { ... }
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# EmailNormalizer.normalize(" J.Doe+carpool@INIZIO.COM. ") # => "j.doe@inizio.com"
|
|
26
|
+
# EmailNormalizer.domain_of("j.doe@inizio.com") # => "inizio.com"
|
|
27
|
+
# EmailNormalizer.domain_of("evil@a.com@b.com") # => nil (multi-@ rejected)
|
|
28
|
+
#
|
|
29
|
+
module EmailNormalizer
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Normalize an email address for uniqueness comparison.
|
|
33
|
+
# @param email [String, nil]
|
|
34
|
+
# @return [String] normalized email ("" for blank input)
|
|
35
|
+
def normalize(email)
|
|
36
|
+
value = email.to_s.strip.downcase
|
|
37
|
+
return "" if value.empty?
|
|
38
|
+
|
|
39
|
+
local, at, domain = value.rpartition("@")
|
|
40
|
+
return value if at.empty? # not an email shape; leave as-is (validation rejects it upstream)
|
|
41
|
+
|
|
42
|
+
local = local.split("+", 2).first.to_s
|
|
43
|
+
domain = domain.chomp(".")
|
|
44
|
+
|
|
45
|
+
"#{local}@#{domain}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Extract the domain of an email address, hardened against evasion shapes.
|
|
49
|
+
# Returns nil (instead of guessing) when the address doesn't have exactly
|
|
50
|
+
# one "@" — multi-@ addresses are a classic trick against naive splitting.
|
|
51
|
+
# A single trailing dot (FQDN form) is tolerated and stripped.
|
|
52
|
+
# @param email [String, nil]
|
|
53
|
+
# @return [String, nil] lowercased domain, or nil if not extractable
|
|
54
|
+
def domain_of(email)
|
|
55
|
+
value = email.to_s.strip.downcase
|
|
56
|
+
return nil if value.empty?
|
|
57
|
+
return nil unless value.count("@") == 1
|
|
58
|
+
|
|
59
|
+
domain = value.split("@", 2).last.to_s.chomp(".")
|
|
60
|
+
return nil if domain.empty? || domain.include?("@")
|
|
61
|
+
|
|
62
|
+
domain
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|