organizations 0.6.0 → 0.7.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/CHANGELOG.md +102 -0
  6. data/README.md +704 -33
  7. data/app/controllers/organizations/invitations_controller.rb +27 -22
  8. data/app/controllers/organizations/memberships_controller.rb +37 -30
  9. data/app/controllers/organizations/organizations_controller.rb +8 -8
  10. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  11. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  12. data/app/mailers/organizations/verification_mailer.rb +1 -1
  13. data/app/models/organizations/grant.rb +6 -0
  14. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  15. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  16. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  17. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  18. data/config/locales/en.yml +22 -0
  19. data/config/locales/es.yml +22 -0
  20. data/config/routes.rb +1 -2
  21. data/gemfiles/rails_7.2.gemfile +5 -1
  22. data/gemfiles/rails_8.1.gemfile +5 -1
  23. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  24. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  25. data/lib/generators/organizations/install/install_generator.rb +1 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  27. data/lib/generators/organizations/join/join_generator.rb +113 -0
  28. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  30. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  33. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  34. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  35. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  36. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  37. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  38. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  39. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  40. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  41. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  42. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  43. data/lib/generators/organizations/views/views_generator.rb +4 -6
  44. data/lib/organizations/account_deletion_plan.rb +118 -0
  45. data/lib/organizations/configuration.rb +152 -0
  46. data/lib/organizations/controller_helpers.rb +55 -15
  47. data/lib/organizations/grant_access.rb +75 -0
  48. data/lib/organizations/grant_holder.rb +152 -0
  49. data/lib/organizations/grantable.rb +28 -0
  50. data/lib/organizations/join_flow.rb +16 -12
  51. data/lib/organizations/models/concerns/has_organizations.rb +224 -9
  52. data/lib/organizations/models/domain.rb +283 -1
  53. data/lib/organizations/models/grant.rb +152 -0
  54. data/lib/organizations/models/invitation.rb +76 -7
  55. data/lib/organizations/models/join_request.rb +102 -43
  56. data/lib/organizations/models/membership.rb +87 -5
  57. data/lib/organizations/models/organization.rb +219 -48
  58. data/lib/organizations/organization_scoped.rb +16 -0
  59. data/lib/organizations/personal_owner_backfill.rb +122 -0
  60. data/lib/organizations/public_email_domains.rb +49 -0
  61. data/lib/organizations/resource.rb +11 -0
  62. data/lib/organizations/roles.rb +55 -0
  63. data/lib/organizations/version.rb +1 -1
  64. data/lib/organizations/view_helpers.rb +40 -9
  65. data/lib/organizations.rb +99 -4
  66. metadata +22 -2
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Access to one resource of the host app, with a list of capabilities. A
5
+ # grant belongs to a membership, or to an invitation until it is accepted.
6
+ #
7
+ # Grants narrow a membership: a member whose role ranks below
8
+ # config.full_resource_access_role sees only the resources granted to it,
9
+ # and on each one only the granted capabilities that its role also has.
10
+ # See Organizations::GrantHolder and Membership#can?.
11
+ #
12
+ # The resource is polymorphic (grantable_type, grantable_id). It can be
13
+ # an ActiveRecord record (`grant.grantable` loads it) or any object with an
14
+ # id, such as an Organizations::Resource (then only the type and id are
15
+ # stored, and `grantable` is not used).
16
+ class Grant < ActiveRecord::Base
17
+ self.table_name = "organizations_grants"
18
+
19
+ # === Associations ===
20
+
21
+ belongs_to :membership, class_name: "Organizations::Membership", inverse_of: :grants, optional: true
22
+ belongs_to :invitation, class_name: "Organizations::Invitation", inverse_of: :grants, optional: true
23
+ belongs_to :grantable, polymorphic: true, optional: true
24
+ belongs_to :granted_by, class_name: Organizations.user_class_name, optional: true
25
+
26
+ # === Validations ===
27
+
28
+ validates :grantable_type, :grantable_id, presence: true
29
+ validate :one_holder
30
+ validate :capabilities_listed
31
+
32
+ # === Callbacks ===
33
+
34
+ before_validation :normalize_capabilities
35
+
36
+ # === Scopes ===
37
+
38
+ # Grants on one resource (a record, any object with an id, or a Resource)
39
+ scope :for_resource, lambda { |resource|
40
+ type, id = Grant.resource_key(resource)
41
+ where(grantable_type: type, grantable_id: id)
42
+ }
43
+
44
+ # Grants on resources of one type (a class or a type name)
45
+ scope :of_type, ->(type) { where(grantable_type: Grant.type_name(type)) }
46
+
47
+ # Grants held by memberships (accepted), not by pending invitations
48
+ scope :active, -> { where.not(membership_id: nil) }
49
+
50
+ # [type, id] for a resource. The id must fit the grantable_id column
51
+ # without loss ("42-a" never becomes 42).
52
+ # @param resource [ActiveRecord::Base, Organizations::Resource, #id]
53
+ # @return [Array(String, Object)]
54
+ # @raise [ArgumentError] for an object without an id, or an id that does not fit
55
+ def self.resource_key(resource)
56
+ type, id =
57
+ case resource
58
+ when Organizations::Resource then [resource.type.to_s, resource.id]
59
+ when ActiveRecord::Base then [resource.class.polymorphic_name, resource.id]
60
+ else
61
+ raise ArgumentError, "A grant needs a resource with an id, got #{resource.inspect}" unless resource.respond_to?(:id)
62
+
63
+ [resource.class.name, resource.id]
64
+ end
65
+ [type, lossless_id(id)]
66
+ end
67
+
68
+ # @api private
69
+ # The id as the grantable_id column stores it, or ArgumentError when the
70
+ # column would change it.
71
+ def self.lossless_id(id)
72
+ cast = type_for_attribute("grantable_id").cast(id)
73
+ return cast if cast.to_s == id.to_s
74
+
75
+ raise ArgumentError, "The id #{id.inspect} does not fit the grantable_id column (#{columns_hash['grantable_id']&.type})"
76
+ end
77
+
78
+ # @api private
79
+ # The organization a resource belongs to, or nil when it does not say:
80
+ # an object with organization_id, or an Organizations::Resource naming
81
+ # an ActiveRecord model with an organization_id column (one query).
82
+ def self.resource_organization_id(resource)
83
+ if resource.is_a?(Organizations::Resource)
84
+ model = resource.type.to_s.safe_constantize
85
+ return nil unless tenant_model?(model)
86
+
87
+ model.where(model.primary_key => resource.id).pick(:organization_id)
88
+ elsif resource.respond_to?(:organization_id)
89
+ resource.organization_id.presence
90
+ end
91
+ end
92
+
93
+ # The stored type name for a class or a name.
94
+ # @return [String]
95
+ def self.type_name(type)
96
+ type.respond_to?(:polymorphic_name) ? type.polymorphic_name : type.to_s
97
+ end
98
+
99
+ # @api private
100
+ def self.tenant_model?(model)
101
+ model.is_a?(Class) && model < ActiveRecord::Base && model.column_names.include?("organization_id")
102
+ end
103
+
104
+ # @return [Array<String>]
105
+ def capabilities
106
+ Array(super).map(&:to_s)
107
+ end
108
+
109
+ # @param capability [Symbol, String]
110
+ # @return [Boolean] whether this grant lists the capability
111
+ def allows?(capability)
112
+ capabilities.include?(capability.to_s)
113
+ end
114
+
115
+ # The resource as an Organizations::Resource (no loading).
116
+ # @return [Organizations::Resource]
117
+ def resource
118
+ Organizations::Resource.new(grantable_type, grantable_id)
119
+ end
120
+
121
+ private
122
+
123
+ def normalize_capabilities
124
+ self.capabilities = Array(read_attribute(:capabilities)).map { |c| c.to_s.strip }.compact_blank.uniq
125
+ end
126
+
127
+ def one_holder
128
+ return if membership_id.present? ^ invitation_id.present?
129
+
130
+ errors.add(:base, Organizations.t(:"attributes.grant_one_holder"))
131
+ end
132
+
133
+ def capabilities_listed
134
+ if capabilities.empty?
135
+ errors.add(:capabilities, Organizations.t(:"attributes.grant_capabilities_blank"))
136
+ return
137
+ end
138
+
139
+ allowed = Organizations.configuration.grant_capabilities
140
+ return if allowed.nil?
141
+
142
+ unknown = capabilities - allowed.map(&:to_s)
143
+ return if unknown.empty?
144
+
145
+ errors.add(:capabilities,
146
+ Organizations.t(:"attributes.grant_capabilities_unknown", capabilities: unknown.join(", ")))
147
+ end
148
+ end
149
+ end
150
+
151
+ # Host extension seam — see the load-hooks note in models/organization.rb.
152
+ ActiveSupport.run_load_hooks(:organizations_grant, Organizations::Grant)
@@ -20,6 +20,10 @@ module Organizations
20
20
  class Invitation < ActiveRecord::Base
21
21
  self.table_name = "organizations_invitations"
22
22
 
23
+ # grant!, revoke_grant!, grant_for, granted?: grants the membership gets
24
+ # when this invitation is accepted
25
+ include Organizations::GrantHolder
26
+
23
27
  # === Associations ===
24
28
 
25
29
  belongs_to :organization,
@@ -34,6 +38,11 @@ module Organizations
34
38
  # Alias for invited_by (semantic convenience as per README)
35
39
  alias_method :from, :invited_by
36
40
 
41
+ # Grants that move to the membership on acceptance (scoped resource
42
+ # grants). No dependent: option, because the table is optional.
43
+ has_many :grants, class_name: "Organizations::Grant", inverse_of: :invitation
44
+ before_destroy :delete_grants
45
+
37
46
  # === Validations ===
38
47
 
39
48
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
@@ -133,7 +142,7 @@ module Organizations
133
142
  # Validate email matches at model level (security)
134
143
  unless skip_email_validation
135
144
  if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email)
136
- raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch")
145
+ raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch", organization: organization)
137
146
  end
138
147
  end
139
148
 
@@ -150,27 +159,29 @@ module Organizations
150
159
  existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
151
160
  return existing_membership if existing_membership
152
161
 
153
- raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted")
162
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted", organization: organization)
154
163
  end
155
164
 
156
165
  if expired?
157
- raise InvitationExpired, Organizations.t(:"errors.invitation_expired")
166
+ raise InvitationExpired, Organizations.t(:"errors.invitation_expired", organization: organization)
158
167
  end
159
168
 
160
169
  # Owner role cannot be assigned via invitation (defense in depth)
161
170
  if role.to_sym == :owner
162
- raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner")
171
+ raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner", organization: organization)
163
172
  end
164
173
 
165
174
  # Check if user is already a member (race condition from another invitation)
166
175
  existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
167
176
  if existing_membership
177
+ move_grants_to!(existing_membership)
168
178
  update!(accepted_at: Time.current)
169
179
  return existing_membership
170
180
  end
171
181
 
172
182
  # Create the membership (with verified-joining provenance)
173
183
  membership = create_membership_for!(accepting_user, skip_email_validation)
184
+ move_grants_to!(membership)
174
185
 
175
186
  # Mark invitation as accepted
176
187
  update!(accepted_at: Time.current)
@@ -188,15 +199,21 @@ module Organizations
188
199
 
189
200
  # Resend the invitation email
190
201
  # Generates new token and resets expiry
202
+ # @param resent_by [User, nil] Who is resending it. When that account is a
203
+ # member, the invitation's role must rank at or below its own role.
191
204
  # @return [self]
192
- def resend!
205
+ # @raise [InvitationAlreadyAccepted] if already accepted
206
+ # @raise [Organizations::NotAuthorized] if the role ranks above resent_by's own
207
+ def resend!(resent_by: nil)
193
208
  ActiveRecord::Base.transaction do
194
209
  lock!
195
210
 
196
211
  if accepted?
197
- raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted")
212
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted", organization: organization)
198
213
  end
199
214
 
215
+ authorize_management!(resent_by)
216
+
200
217
  update!(
201
218
  token: generate_unique_token,
202
219
  expires_at: calculate_expiry
@@ -207,6 +224,19 @@ module Organizations
207
224
  self
208
225
  end
209
226
 
227
+ # Revoke (delete) the invitation, so its link stops working
228
+ # @param revoked_by [User, nil] Who is revoking it. When that account is a
229
+ # member, the invitation's role must rank at or below its own role.
230
+ # @return [self]
231
+ # @raise [Organizations::NotAuthorized] if the role ranks above revoked_by's own
232
+ def revoke!(revoked_by: nil)
233
+ ActiveRecord::Base.transaction do
234
+ lock!
235
+ authorize_management!(revoked_by)
236
+ destroy!
237
+ end
238
+ end
239
+
210
240
  # Get the acceptance URL
211
241
  # @param base_url [String, nil] Base URL for the link
212
242
  # @return [String]
@@ -227,6 +257,36 @@ module Organizations
227
257
 
228
258
  private
229
259
 
260
+ # The invitation's grants become the membership's (a grant on the same
261
+ # resource takes the invitation's capabilities). The inviter was
262
+ # checked when the invitation was sent.
263
+ # Grants are written only while the invitation is pending, under its
264
+ # row lock (accept! takes the same lock).
265
+ def lock_for_grant_write!
266
+ return unless persisted?
267
+
268
+ lock!
269
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted", organization: organization) if accepted?
270
+ end
271
+
272
+ # Each grant is checked again, against the inviter as it is now and the
273
+ # membership that receives it (an existing member may hold a higher
274
+ # role than the invitation): a grant the inviter may no longer give, or
275
+ # whose capabilities config.grant_capabilities no longer allows, is
276
+ # dropped and logged. (Deleting the inviter deletes the grants of the
277
+ # invitations they sent, so a nil inviter here is a trusted actor.)
278
+ def move_grants_to!(membership)
279
+ return unless Organizations::GrantHolder.grants_table?
280
+
281
+ grants.each do |grant|
282
+ membership.authorize_grants!([[grant.resource, grant.capabilities]], invited_by)
283
+ membership.send(:upsert_grant!, grant.grantable_type, grant.grantable_id, grant.capabilities, invited_by)
284
+ rescue Organizations::NotAuthorized, ArgumentError, ActiveRecord::RecordInvalid => e
285
+ Callbacks.log_warn("[Organizations] Invitation grant on #{grant.grantable_type}##{grant.grantable_id} dropped: #{e.message}")
286
+ end
287
+ grants.delete_all(:delete_all)
288
+ end
289
+
230
290
  # Create the membership for an acceptance.
231
291
  # Uses invited_by_id instead of invited_by to avoid Rails class reloading
232
292
  # issues (AssociationTypeMismatch when User is reloaded in development).
@@ -315,6 +375,15 @@ module Organizations
315
375
  }
316
376
  end
317
377
 
378
+ # A member may only revoke or resend invitations for roles that rank at or
379
+ # below its own, so a custom role that can invite members still cannot
380
+ # manage an admin's invitation (see Membership.authorize_rank!).
381
+ def authorize_management!(actor)
382
+ Membership.authorize_rank!(organization, actor, [role],
383
+ permission: :invite_members,
384
+ message_key: :"errors.invitation_management_outranks_actor")
385
+ end
386
+
318
387
  def normalize_email
319
388
  self.email = email.to_s.downcase.strip if email.present?
320
389
  end
@@ -352,7 +421,7 @@ module Organizations
352
421
  .exists?
353
422
 
354
423
  if existing
355
- errors.add(:email, Organizations.t(:"attributes.invitation_taken"))
424
+ errors.add(:email, Organizations.t(:"attributes.invitation_taken", organization: organization))
356
425
  end
357
426
  end
358
427
 
@@ -31,7 +31,7 @@ module Organizations
31
31
  # The class carries the request's FULL lifecycle (statuses, challenge,
32
32
  # decisions) exactly like its mirror Invitation does — splitting it would
33
33
  # scatter one cohesive state machine across files.
34
- # rubocop:disable Metrics/ClassLength
34
+ # rubocop:disable-next Metrics/ClassLength
35
35
  class JoinRequest < ActiveRecord::Base
36
36
  self.table_name = "organizations_join_requests"
37
37
 
@@ -146,7 +146,7 @@ module Organizations
146
146
  address = email.to_s.strip
147
147
 
148
148
  unless address.match?(URI::MailTo::EMAIL_REGEXP)
149
- raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid")
149
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid", organization: organization)
150
150
  end
151
151
 
152
152
  code = nil
@@ -183,6 +183,9 @@ module Organizations
183
183
  lock!
184
184
  ensure_open!
185
185
  ensure_active_challenge!
186
+ # The domain or roster entry may have been removed, revoked or
187
+ # blocked since the code was sent: the code then proves nothing.
188
+ ensure_instruments_still_eligible!
186
189
 
187
190
  if correct_code?(code)
188
191
  # Burn the code: single-use by construction.
@@ -192,13 +195,15 @@ module Organizations
192
195
  # raising here would roll the increment back and give attackers
193
196
  # unlimited tries.
194
197
  update!(verification_attempts: verification_attempts + 1)
195
- failure = VerificationCodeInvalid.new(Organizations.t(:"errors.verification_code_invalid"))
198
+ failure = VerificationCodeInvalid.new(
199
+ Organizations.t(:"errors.verification_code_invalid", organization: organization)
200
+ )
196
201
  end
197
202
  end
198
203
 
199
204
  raise failure if failure
200
205
 
201
- return approve!(decided_by: nil) if auto_approvable_after_verification?
206
+ return send(:auto_approve!) if auto_approvable_after_verification?
202
207
 
203
208
  self
204
209
  end
@@ -218,32 +223,22 @@ module Organizations
218
223
  # @return [Membership]
219
224
  # @raise [JoinRequestExpired, JoinRequestAlreadyDecided]
220
225
  def approve!(decided_by: nil)
221
- membership = nil
222
- reused_existing = false
223
-
224
- ActiveRecord::Base.transaction do
225
- lock!
226
-
227
- return approved_membership! if approved? # idempotent re-approval
228
-
229
- ensure_open!
230
-
231
- existing = existing_membership
232
-
233
- if existing
234
- membership = existing
235
- reused_existing = true
236
- else
237
- membership = create_membership!
238
- claim_matched_allowlist_entry!
239
- end
240
-
241
- update!(status: "approved", decided_by_id: decided_by&.id, decided_at: Time.current)
242
- end
226
+ perform_approval!(decided_by: decided_by, recheck_instruments: false)
227
+ end
243
228
 
244
- dispatch_approval_callbacks(membership, decided_by, reused_existing)
245
- membership
229
+ # The automatic approval behind a verified email (verify_email_code! and
230
+ # Organization#join_with_account_email!). Like approve!, but it needs a
231
+ # verified email and a matched domain or roster entry, and checks again,
232
+ # under row locks, that the instrument still admits members. A manual
233
+ # approve! by an admin does not need that check: the admin decides.
234
+ # @return [Membership]
235
+ # @raise [VerificationEmailNotEligible] when the instrument is gone, revoked or blocked
236
+ def auto_approve!
237
+ perform_approval!(decided_by: nil, recheck_instruments: true)
246
238
  end
239
+ # Callable from Organization#join_with_account_email! only. Hosts call
240
+ # approve! (a decision) or verify_email_code! (a proof).
241
+ private :auto_approve!
247
242
 
248
243
  # Reject this request.
249
244
  # @param rejected_by [User, nil]
@@ -324,9 +319,72 @@ module Organizations
324
319
 
325
320
  private
326
321
 
322
+ def perform_approval!(decided_by:, recheck_instruments:)
323
+ membership = nil
324
+ reused_existing = false
325
+
326
+ ActiveRecord::Base.transaction do
327
+ lock!
328
+
329
+ return approved_membership! if approved? # idempotent re-approval
330
+
331
+ ensure_open!
332
+ ensure_instruments_still_eligible!(require_proof: true) if recheck_instruments
333
+
334
+ existing = existing_membership
335
+
336
+ if existing
337
+ membership = existing
338
+ reused_existing = true
339
+ else
340
+ membership = create_membership!
341
+ claim_matched_allowlist_entry!
342
+ end
343
+
344
+ update!(status: "approved", decided_by_id: decided_by&.id, decided_at: Time.current)
345
+ end
346
+
347
+ dispatch_approval_callbacks(membership, decided_by, reused_existing)
348
+ membership
349
+ end
350
+
351
+ # The domain (or allowlist entry) that made the verification email
352
+ # eligible must still admit: present, matching the email, joinable
353
+ # (Domain.joinable), and for an entry, unclaimed by anyone else. The row
354
+ # is locked until the admission commits, so a revocation or removal
355
+ # either finishes first (and refuses) or waits.
356
+ #
357
+ # Called at the challenge check (verify_email_code!) before the proof
358
+ # exists, and again by auto_approve! after it, which also needs it.
359
+ def ensure_instruments_still_eligible!(require_proof: false)
360
+ return if verification_email.blank? && !require_proof
361
+ return if instruments_still_eligible?(require_proof)
362
+
363
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible", organization: organization)
364
+ end
365
+
366
+ def instruments_still_eligible?(require_proof)
367
+ return false if require_proof && (!email_verified? || verification_email.blank?)
368
+
369
+ domain_id = (metadata || {})["matched_domain_id"]
370
+ entry_id = (metadata || {})["matched_allowlist_entry_id"]
371
+ return !require_proof unless domain_id || entry_id
372
+
373
+ matched_instrument_scope(domain_id, entry_id).lock.pick(:id).present?
374
+ end
375
+
376
+ def matched_instrument_scope(domain_id, entry_id)
377
+ if domain_id
378
+ organization.domains.joinable.matching_email(verification_email).where(id: domain_id)
379
+ else
380
+ organization.allowlist_entries.for_email(verification_email).where(id: entry_id)
381
+ .where("claimed_at IS NULL OR claimed_by_id = ?", user_id)
382
+ end
383
+ end
384
+
327
385
  # Raise unless this request is still open (pending and not expired)
328
386
  def ensure_open!
329
- raise JoinRequestExpired, Organizations.t(:"errors.join_request_expired") if expired?
387
+ raise JoinRequestExpired, Organizations.t(:"errors.join_request_expired", organization: organization) if expired?
330
388
  raise JoinRequestAlreadyDecided, already_decided_message if decided?
331
389
  end
332
390
 
@@ -340,8 +398,10 @@ module Organizations
340
398
  # (organizations.join_request_status.*, lowercase for mid-sentence use)
341
399
  # so the whole sentence localizes as one unit.
342
400
  def already_decided_message
343
- Organizations.t(:"errors.join_request_already_decided",
344
- status: Organizations.t(:"join_request_status.#{status}", default: status))
401
+ Organizations.t(:"errors.join_request_already_decided", organization: organization,
402
+ status: Organizations.t(:"join_request_status.#{status}",
403
+ organization: organization,
404
+ default: status))
345
405
  end
346
406
 
347
407
  def auto_approvable_after_verification?
@@ -358,7 +418,7 @@ module Organizations
358
418
  # Raises if it was removed after approval — the request can't be reused.
359
419
  def approved_membership!
360
420
  membership = existing_membership
361
- raise JoinRequestAlreadyDecided, Organizations.t(:"errors.join_request_membership_gone") unless membership
421
+ raise JoinRequestAlreadyDecided, Organizations.t(:"errors.join_request_membership_gone", organization: organization) unless membership
362
422
 
363
423
  membership
364
424
  end
@@ -392,11 +452,11 @@ module Organizations
392
452
  # Domains win over allowlist entries (an org can have both).
393
453
  # @return [Array(Domain|nil, AllowlistEntry|nil)]
394
454
  def eligible_instruments_for!(address)
395
- matched_domain = organization.domains.matching_email(address).first
455
+ matched_domain = organization.domains.joinable.matching_email(address).first
396
456
  matched_entry = matched_domain ? nil : organization.allowlist_entries.unclaimed.for_email(address).first
397
457
 
398
458
  unless matched_domain || matched_entry
399
- raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
459
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible", organization: organization)
400
460
  end
401
461
 
402
462
  [matched_domain, matched_entry]
@@ -409,34 +469,34 @@ module Organizations
409
469
  return unless Membership.where(organization_id: organization_id, verified_email_normalized: normalized).exists?
410
470
 
411
471
  raise VerificationEmailAlreadyClaimed,
412
- Organizations.t(:"errors.verification_email_already_claimed")
472
+ Organizations.t(:"errors.verification_email_already_claimed", organization: organization)
413
473
  end
414
474
 
415
475
  def ensure_send_allowed!
416
476
  config = Organizations.configuration
417
477
 
418
478
  if verification_sends_count >= config.verification_max_sends
419
- raise VerificationThrottled, Organizations.t(:"errors.verification_sends_exceeded")
479
+ raise VerificationThrottled, Organizations.t(:"errors.verification_sends_exceeded", organization: organization)
420
480
  end
421
481
 
422
482
  resend_floor = interval_ago(config.verification_resend_interval)
423
483
  return unless verification_sent_at.present? && verification_sent_at > resend_floor
424
484
 
425
- raise VerificationThrottled, Organizations.t(:"errors.verification_resend_throttled")
485
+ raise VerificationThrottled, Organizations.t(:"errors.verification_resend_throttled", organization: organization)
426
486
  end
427
487
 
428
488
  def ensure_active_challenge!
429
489
  config = Organizations.configuration
430
490
 
431
- raise VerificationCodeInvalid, Organizations.t(:"errors.verification_code_missing") if verification_code_digest.blank?
491
+ raise VerificationCodeInvalid, Organizations.t(:"errors.verification_code_missing", organization: organization) if verification_code_digest.blank?
432
492
 
433
493
  if verification_attempts >= config.verification_max_attempts
434
- raise VerificationAttemptsExceeded, Organizations.t(:"errors.verification_attempts_exceeded")
494
+ raise VerificationAttemptsExceeded, Organizations.t(:"errors.verification_attempts_exceeded", organization: organization)
435
495
  end
436
496
 
437
497
  return unless verification_expires_at.blank? || verification_expires_at <= Time.current
438
498
 
439
- raise VerificationCodeExpired, Organizations.t(:"errors.verification_code_expired")
499
+ raise VerificationCodeExpired, Organizations.t(:"errors.verification_code_expired", organization: organization)
440
500
  end
441
501
 
442
502
  def correct_code?(code)
@@ -487,7 +547,7 @@ module Organizations
487
547
  return existing if existing
488
548
 
489
549
  raise VerificationEmailAlreadyClaimed,
490
- Organizations.t(:"errors.verification_email_already_claimed")
550
+ Organizations.t(:"errors.verification_email_already_claimed", organization: organization)
491
551
  end
492
552
 
493
553
  def insert_membership_row!(effective_joined_via)
@@ -617,10 +677,9 @@ module Organizations
617
677
  .where.not(id: id)
618
678
  .exists?
619
679
 
620
- errors.add(:user_id, Organizations.t(:"attributes.pending_request_taken")) if existing
680
+ errors.add(:user_id, Organizations.t(:"attributes.pending_request_taken", organization: organization)) if existing
621
681
  end
622
682
  end
623
- # rubocop:enable Metrics/ClassLength
624
683
  end
625
684
 
626
685
  # Host extension seam — see the load-hooks note in models/organization.rb.