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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +405 -41
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +8 -12
  60. data/lib/organizations.rb +165 -0
  61. 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] = "Please select or create an organization." if flash_options.empty?
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
- "You are not a member of this organization",
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
- "You need #{required_role} access to perform this action"
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
- "You don't have permission to #{permission.to_s.humanize.downcase}"
632
+ Organizations.t(:"errors.unauthorized_permission", permission: permission.to_s.humanize.downcase)
630
633
  else
631
- "You are not authorized to perform this action"
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: "Organization required" }, status: :forbidden }
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?(:new_user_registration_path)
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 "You're already a member of #{organization_name}." if result.already_member?
808
+ return Organizations.t(:"notices.already_member", organization: organization_name) if result.already_member?
783
809
 
784
- "Welcome to #{organization_name}!"
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