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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "active_support/security_utils"
|
|
5
|
+
|
|
6
|
+
module Organizations
|
|
7
|
+
# A user's petition to join an organization — the mirror image of Invitation
|
|
8
|
+
# (invitation = org→user, join request = user→org; both converge on
|
|
9
|
+
# Membership). Memberships stay active-only: all "pending" state lives here,
|
|
10
|
+
# so every existing membership invariant (counter cache, single owner,
|
|
11
|
+
# uniqueness) is untouched.
|
|
12
|
+
#
|
|
13
|
+
# Stored statuses: pending → approved | rejected | withdrawn.
|
|
14
|
+
# `:expired` is DERIVED from expires_at (same approach as invitations).
|
|
15
|
+
#
|
|
16
|
+
# A request may carry an email-verification challenge: the user proves
|
|
17
|
+
# control of an inbox that either belongs to one of the organization's
|
|
18
|
+
# Domains or matches an unclaimed AllowlistEntry. The 6-digit code is
|
|
19
|
+
# stored as a SHA-256 digest only (peppered with the row id) — plaintext
|
|
20
|
+
# never touches the database.
|
|
21
|
+
#
|
|
22
|
+
# @example Request to join (manual approval)
|
|
23
|
+
# request = user.request_to_join!(org, message: "Soy socio nº 442")
|
|
24
|
+
# org.approve_join_request!(request, approved_by: admin)
|
|
25
|
+
#
|
|
26
|
+
# @example Domain-email verified join
|
|
27
|
+
# request = user.request_to_join!(org)
|
|
28
|
+
# request.start_email_verification!(email: "j.doe@inizio.com")
|
|
29
|
+
# request.verify_email_code!("492817") # => Membership (auto-approved)
|
|
30
|
+
#
|
|
31
|
+
# The class carries the request's FULL lifecycle (statuses, challenge,
|
|
32
|
+
# decisions) exactly like its mirror Invitation does — splitting it would
|
|
33
|
+
# scatter one cohesive state machine across files.
|
|
34
|
+
# rubocop:disable Metrics/ClassLength
|
|
35
|
+
class JoinRequest < ActiveRecord::Base
|
|
36
|
+
self.table_name = "organizations_join_requests"
|
|
37
|
+
|
|
38
|
+
STATUSES = %w[pending approved rejected withdrawn].freeze
|
|
39
|
+
|
|
40
|
+
# === Associations ===
|
|
41
|
+
|
|
42
|
+
belongs_to :organization,
|
|
43
|
+
class_name: "Organizations::Organization",
|
|
44
|
+
inverse_of: :join_requests
|
|
45
|
+
|
|
46
|
+
# Explicit class_name (NOT inferred from the association name) so hosts
|
|
47
|
+
# with a differently-named account model work: config.user_class.
|
|
48
|
+
belongs_to :user, class_name: Organizations.user_class_name
|
|
49
|
+
|
|
50
|
+
belongs_to :join_code,
|
|
51
|
+
class_name: "Organizations::JoinCode",
|
|
52
|
+
inverse_of: :join_requests,
|
|
53
|
+
optional: true
|
|
54
|
+
|
|
55
|
+
belongs_to :decided_by,
|
|
56
|
+
class_name: Organizations.user_class_name,
|
|
57
|
+
optional: true
|
|
58
|
+
|
|
59
|
+
# === Validations ===
|
|
60
|
+
|
|
61
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
62
|
+
validate :unique_pending_request, on: :create
|
|
63
|
+
|
|
64
|
+
# === Callbacks ===
|
|
65
|
+
|
|
66
|
+
before_validation :set_expiry, on: :create, if: -> { expires_at.blank? }
|
|
67
|
+
|
|
68
|
+
# === Scopes ===
|
|
69
|
+
|
|
70
|
+
# Open requests (pending status and not past expiry)
|
|
71
|
+
scope :pending, lambda {
|
|
72
|
+
where(status: "pending")
|
|
73
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Requests that timed out while pending
|
|
77
|
+
scope :expired, lambda {
|
|
78
|
+
where(status: "pending")
|
|
79
|
+
.where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
scope :approved, -> { where(status: "approved") }
|
|
83
|
+
scope :rejected, -> { where(status: "rejected") }
|
|
84
|
+
scope :withdrawn, -> { where(status: "withdrawn") }
|
|
85
|
+
|
|
86
|
+
# === Status Methods ===
|
|
87
|
+
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def pending?
|
|
90
|
+
status == "pending" && !expired?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def approved?
|
|
95
|
+
status == "approved"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def rejected?
|
|
100
|
+
status == "rejected"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def withdrawn?
|
|
105
|
+
status == "withdrawn"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Boolean] request timed out while pending
|
|
109
|
+
def expired?
|
|
110
|
+
status == "pending" && expires_at.present? && expires_at <= Time.current
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Boolean] a terminal decision was made
|
|
114
|
+
def decided?
|
|
115
|
+
approved? || rejected? || withdrawn?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Effective status as a symbol (derives :expired)
|
|
119
|
+
# @return [Symbol] :pending, :approved, :rejected, :withdrawn, or :expired
|
|
120
|
+
def effective_status
|
|
121
|
+
return :expired if expired?
|
|
122
|
+
|
|
123
|
+
status.to_sym
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Boolean] the email challenge was completed
|
|
127
|
+
def email_verified?
|
|
128
|
+
verified_at.present?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# === Email Verification Challenge ===
|
|
132
|
+
|
|
133
|
+
# Start (or restart) the emailed-code challenge for an address the user
|
|
134
|
+
# claims to control. The address must belong to one of the organization's
|
|
135
|
+
# domains or match an unclaimed allowlist entry — proof of control is
|
|
136
|
+
# required in BOTH cases (a leaked roster must not grant membership
|
|
137
|
+
# without inbox access).
|
|
138
|
+
#
|
|
139
|
+
# @param email [String] the address to prove (may differ from user.email)
|
|
140
|
+
# @return [self]
|
|
141
|
+
# @raise [VerificationEmailNotEligible] address invalid or not eligible for this org
|
|
142
|
+
# @raise [VerificationEmailAlreadyClaimed] address already proven by another membership
|
|
143
|
+
# @raise [VerificationThrottled] resend interval / send cap hit
|
|
144
|
+
# @raise [JoinRequestExpired, JoinRequestAlreadyDecided] request not open
|
|
145
|
+
def start_email_verification!(email:)
|
|
146
|
+
address = email.to_s.strip
|
|
147
|
+
|
|
148
|
+
unless address.match?(URI::MailTo::EMAIL_REGEXP)
|
|
149
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
code = nil
|
|
153
|
+
|
|
154
|
+
ActiveRecord::Base.transaction do
|
|
155
|
+
lock!
|
|
156
|
+
ensure_open!
|
|
157
|
+
|
|
158
|
+
matched_domain, matched_entry = eligible_instruments_for!(address)
|
|
159
|
+
ensure_address_unclaimed!(address)
|
|
160
|
+
ensure_send_allowed!
|
|
161
|
+
|
|
162
|
+
code = generate_verification_code
|
|
163
|
+
update!(challenge_attributes(address, code, matched_domain, matched_entry))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
deliver_verification_email(code)
|
|
167
|
+
self
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Verify the emailed code. On success, auto-approves when the governing
|
|
171
|
+
# instrument allows it (domain/roster joins: always; code joins: per the
|
|
172
|
+
# code's auto_approve flag).
|
|
173
|
+
#
|
|
174
|
+
# @param code [String] the 6-digit code as typed
|
|
175
|
+
# @return [Membership] when verification auto-approved the request
|
|
176
|
+
# @return [self] when verified but awaiting manual approval
|
|
177
|
+
# @raise [VerificationCodeInvalid, VerificationCodeExpired, VerificationAttemptsExceeded]
|
|
178
|
+
# @raise [JoinRequestExpired, JoinRequestAlreadyDecided]
|
|
179
|
+
def verify_email_code!(code)
|
|
180
|
+
failure = nil
|
|
181
|
+
|
|
182
|
+
ActiveRecord::Base.transaction do
|
|
183
|
+
lock!
|
|
184
|
+
ensure_open!
|
|
185
|
+
ensure_active_challenge!
|
|
186
|
+
|
|
187
|
+
if correct_code?(code)
|
|
188
|
+
# Burn the code: single-use by construction.
|
|
189
|
+
update!(verified_at: Time.current, verification_code_digest: nil)
|
|
190
|
+
else
|
|
191
|
+
# Persist the failed attempt, then raise OUTSIDE the transaction —
|
|
192
|
+
# raising here would roll the increment back and give attackers
|
|
193
|
+
# unlimited tries.
|
|
194
|
+
update!(verification_attempts: verification_attempts + 1)
|
|
195
|
+
failure = VerificationCodeInvalid.new(Organizations.t(:"errors.verification_code_invalid"))
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
raise failure if failure
|
|
200
|
+
|
|
201
|
+
return approve!(decided_by: nil) if auto_approvable_after_verification?
|
|
202
|
+
|
|
203
|
+
self
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# === Decisions ===
|
|
207
|
+
|
|
208
|
+
# Approve this request and create the membership (the ONLY way a join
|
|
209
|
+
# request becomes a membership). Row-locked and idempotent: approving an
|
|
210
|
+
# already-approved request returns the existing membership.
|
|
211
|
+
#
|
|
212
|
+
# Provenance is stamped on the membership (joined_via, verified_email,
|
|
213
|
+
# verified_at) and `membership_metadata` from the governing instruments is
|
|
214
|
+
# merged in (matched domain, then matched allowlist entry, then join code
|
|
215
|
+
# — later wins).
|
|
216
|
+
#
|
|
217
|
+
# @param decided_by [User, nil] approver (nil for auto-approvals)
|
|
218
|
+
# @return [Membership]
|
|
219
|
+
# @raise [JoinRequestExpired, JoinRequestAlreadyDecided]
|
|
220
|
+
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
|
|
243
|
+
|
|
244
|
+
dispatch_approval_callbacks(membership, decided_by, reused_existing)
|
|
245
|
+
membership
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Reject this request.
|
|
249
|
+
# @param rejected_by [User, nil]
|
|
250
|
+
# @param reason [String, nil] stored in metadata for audit; never shown by the gem
|
|
251
|
+
# @return [self]
|
|
252
|
+
# @raise [JoinRequestAlreadyDecided]
|
|
253
|
+
def reject!(rejected_by: nil, reason: nil)
|
|
254
|
+
ActiveRecord::Base.transaction do
|
|
255
|
+
lock!
|
|
256
|
+
ensure_undecided!
|
|
257
|
+
|
|
258
|
+
new_metadata = metadata || {}
|
|
259
|
+
new_metadata = new_metadata.merge("rejection_reason" => reason) if reason.present?
|
|
260
|
+
|
|
261
|
+
update!(
|
|
262
|
+
status: "rejected",
|
|
263
|
+
decided_by_id: rejected_by&.id,
|
|
264
|
+
decided_at: Time.current,
|
|
265
|
+
metadata: new_metadata
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
Callbacks.dispatch(
|
|
270
|
+
:join_request_rejected,
|
|
271
|
+
organization: organization,
|
|
272
|
+
user: user,
|
|
273
|
+
join_request: self,
|
|
274
|
+
decided_by: rejected_by
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Withdraw this request (the user cancels their own petition).
|
|
281
|
+
# @return [self]
|
|
282
|
+
# @raise [JoinRequestAlreadyDecided]
|
|
283
|
+
def withdraw!
|
|
284
|
+
ActiveRecord::Base.transaction do
|
|
285
|
+
lock!
|
|
286
|
+
ensure_undecided!
|
|
287
|
+
|
|
288
|
+
update!(status: "withdrawn", decided_at: Time.current)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
self
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Digest a verification code, peppered with the row id so identical codes
|
|
295
|
+
# on different requests produce different digests.
|
|
296
|
+
# @return [String]
|
|
297
|
+
def self.digest_verification_code(code, request_id)
|
|
298
|
+
Digest::SHA256.hexdigest("#{code}-#{request_id}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Data-minimization sweep (GDPR posture): decided (rejected/withdrawn)
|
|
302
|
+
# and expired requests hold a verification email address with no ongoing
|
|
303
|
+
# purpose — purge them once they're old enough. APPROVED requests are
|
|
304
|
+
# deliberately kept: they are the join audit trail (provenance for the
|
|
305
|
+
# membership they created). The retention PERIOD is host policy; the
|
|
306
|
+
# knowledge of which states hold purposeless PII is the gem's.
|
|
307
|
+
#
|
|
308
|
+
# Wire it from a scheduled job/rake task, e.g.:
|
|
309
|
+
# Organizations::JoinRequest.purge_stale!(older_than: 12.months)
|
|
310
|
+
#
|
|
311
|
+
# @param older_than [ActiveSupport::Duration] minimum age before purging
|
|
312
|
+
# @return [Integer] number of purged rows
|
|
313
|
+
def self.purge_stale!(older_than: 12.months)
|
|
314
|
+
cutoff = Time.current - older_than
|
|
315
|
+
|
|
316
|
+
# in_batches: this is a maintenance sweep over potentially years of
|
|
317
|
+
# rows — batched deletes keep the lock/undo footprint flat on large
|
|
318
|
+
# tables (returns the summed count, same contract as delete_all).
|
|
319
|
+
where(status: %w[rejected withdrawn]).where(decided_at: ...cutoff)
|
|
320
|
+
.or(expired.where(expires_at: ...cutoff))
|
|
321
|
+
.in_batches
|
|
322
|
+
.delete_all
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
326
|
+
|
|
327
|
+
# Raise unless this request is still open (pending and not expired)
|
|
328
|
+
def ensure_open!
|
|
329
|
+
raise JoinRequestExpired, Organizations.t(:"errors.join_request_expired") if expired?
|
|
330
|
+
raise JoinRequestAlreadyDecided, already_decided_message if decided?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Like ensure_open! but tolerates expiry — used by reject!/withdraw! so
|
|
334
|
+
# stale requests can still be cleaned up explicitly.
|
|
335
|
+
def ensure_undecided!
|
|
336
|
+
raise JoinRequestAlreadyDecided, already_decided_message if decided?
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# "…has already been %{status}" — the status word itself is translated
|
|
340
|
+
# (organizations.join_request_status.*, lowercase for mid-sentence use)
|
|
341
|
+
# so the whole sentence localizes as one unit.
|
|
342
|
+
def already_decided_message
|
|
343
|
+
Organizations.t(:"errors.join_request_already_decided",
|
|
344
|
+
status: Organizations.t(:"join_request_status.#{status}", default: status))
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def auto_approvable_after_verification?
|
|
348
|
+
return join_code.auto_approve? if join_code
|
|
349
|
+
|
|
350
|
+
true
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def existing_membership
|
|
354
|
+
organization.memberships.find_by(user_id: user_id)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The membership behind an already-approved request (idempotent path).
|
|
358
|
+
# Raises if it was removed after approval — the request can't be reused.
|
|
359
|
+
def approved_membership!
|
|
360
|
+
membership = existing_membership
|
|
361
|
+
raise JoinRequestAlreadyDecided, Organizations.t(:"errors.join_request_membership_gone") unless membership
|
|
362
|
+
|
|
363
|
+
membership
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Post-approval callback dispatches (outside the approval transaction).
|
|
367
|
+
# member_joined is skipped when the membership pre-existed via another
|
|
368
|
+
# path — it already fired when that membership was created.
|
|
369
|
+
def dispatch_approval_callbacks(membership, decided_by, reused_existing)
|
|
370
|
+
unless reused_existing
|
|
371
|
+
Callbacks.dispatch(
|
|
372
|
+
:member_joined,
|
|
373
|
+
organization: organization,
|
|
374
|
+
membership: membership,
|
|
375
|
+
user: user
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
Callbacks.dispatch(
|
|
380
|
+
:join_request_approved,
|
|
381
|
+
organization: organization,
|
|
382
|
+
user: user,
|
|
383
|
+
join_request: self,
|
|
384
|
+
membership: membership,
|
|
385
|
+
decided_by: decided_by
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# === Challenge guards & builders (all called under lock) ===
|
|
390
|
+
|
|
391
|
+
# Resolve which join instrument makes this address eligible.
|
|
392
|
+
# Domains win over allowlist entries (an org can have both).
|
|
393
|
+
# @return [Array(Domain|nil, AllowlistEntry|nil)]
|
|
394
|
+
def eligible_instruments_for!(address)
|
|
395
|
+
matched_domain = organization.domains.matching_email(address).first
|
|
396
|
+
matched_entry = matched_domain ? nil : organization.allowlist_entries.unclaimed.for_email(address).first
|
|
397
|
+
|
|
398
|
+
unless matched_domain || matched_entry
|
|
399
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
[matched_domain, matched_entry]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# One proven email => one membership per org. Pre-check for a friendly
|
|
406
|
+
# error; the unique index on memberships is the backstop.
|
|
407
|
+
def ensure_address_unclaimed!(address)
|
|
408
|
+
normalized = Organizations.configuration.normalize_verification_email(address)
|
|
409
|
+
return unless Membership.where(organization_id: organization_id, verified_email_normalized: normalized).exists?
|
|
410
|
+
|
|
411
|
+
raise VerificationEmailAlreadyClaimed,
|
|
412
|
+
Organizations.t(:"errors.verification_email_already_claimed")
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def ensure_send_allowed!
|
|
416
|
+
config = Organizations.configuration
|
|
417
|
+
|
|
418
|
+
if verification_sends_count >= config.verification_max_sends
|
|
419
|
+
raise VerificationThrottled, Organizations.t(:"errors.verification_sends_exceeded")
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
resend_floor = interval_ago(config.verification_resend_interval)
|
|
423
|
+
return unless verification_sent_at.present? && verification_sent_at > resend_floor
|
|
424
|
+
|
|
425
|
+
raise VerificationThrottled, Organizations.t(:"errors.verification_resend_throttled")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def ensure_active_challenge!
|
|
429
|
+
config = Organizations.configuration
|
|
430
|
+
|
|
431
|
+
raise VerificationCodeInvalid, Organizations.t(:"errors.verification_code_missing") if verification_code_digest.blank?
|
|
432
|
+
|
|
433
|
+
if verification_attempts >= config.verification_max_attempts
|
|
434
|
+
raise VerificationAttemptsExceeded, Organizations.t(:"errors.verification_attempts_exceeded")
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
return unless verification_expires_at.blank? || verification_expires_at <= Time.current
|
|
438
|
+
|
|
439
|
+
raise VerificationCodeExpired, Organizations.t(:"errors.verification_code_expired")
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def correct_code?(code)
|
|
443
|
+
submitted = self.class.digest_verification_code(code.to_s.strip, id)
|
|
444
|
+
ActiveSupport::SecurityUtils.secure_compare(submitted, verification_code_digest)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def challenge_attributes(address, code, matched_domain, matched_entry)
|
|
448
|
+
config = Organizations.configuration
|
|
449
|
+
|
|
450
|
+
{
|
|
451
|
+
verification_email: address,
|
|
452
|
+
verification_email_normalized: config.normalize_verification_email(address),
|
|
453
|
+
verification_code_digest: self.class.digest_verification_code(code, id),
|
|
454
|
+
verification_sent_at: Time.current,
|
|
455
|
+
verification_expires_at: Time.current + config.verification_code_ttl,
|
|
456
|
+
verification_attempts: 0,
|
|
457
|
+
verification_sends_count: verification_sends_count + 1,
|
|
458
|
+
verified_at: nil,
|
|
459
|
+
joined_via: joined_via.presence || (matched_domain ? "domain_email" : "allowlist"),
|
|
460
|
+
metadata: (metadata || {}).merge(
|
|
461
|
+
"matched_domain_id" => matched_domain&.id,
|
|
462
|
+
"matched_allowlist_entry_id" => matched_entry&.id
|
|
463
|
+
).compact
|
|
464
|
+
}
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def create_membership!
|
|
468
|
+
effective_joined_via = joined_via.presence || "manual"
|
|
469
|
+
dispatch_member_joining_gate!(effective_joined_via)
|
|
470
|
+
|
|
471
|
+
# SAVEPOINT (requires_new): this INSERT runs inside approve!'s locked
|
|
472
|
+
# transaction and its unique-violation is RESCUED below. On PostgreSQL
|
|
473
|
+
# a statement error ABORTS the whole transaction ("current transaction
|
|
474
|
+
# is aborted, commands ignored…"), so without the savepoint the rescue
|
|
475
|
+
# path's queries explode instead of degrading gracefully — proven by
|
|
476
|
+
# running this suite against PG (SQLite forgives the pattern, which is
|
|
477
|
+
# how it stayed green). Source: https://www.postgresql.org/docs/current/tutorial-transactions.html
|
|
478
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
479
|
+
insert_membership_row!(effective_joined_via)
|
|
480
|
+
end
|
|
481
|
+
rescue ActiveRecord::RecordNotUnique
|
|
482
|
+
# Two possible unique collisions:
|
|
483
|
+
# 1. (user, org) membership race — another path just made them a member.
|
|
484
|
+
# 2. (org, verified_email_normalized) — the proven address was claimed
|
|
485
|
+
# concurrently. Surface the same friendly error as the pre-check.
|
|
486
|
+
existing = organization.memberships.find_by(user_id: user_id)
|
|
487
|
+
return existing if existing
|
|
488
|
+
|
|
489
|
+
raise VerificationEmailAlreadyClaimed,
|
|
490
|
+
Organizations.t(:"errors.verification_email_already_claimed")
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def insert_membership_row!(effective_joined_via)
|
|
494
|
+
organization.memberships.create!(
|
|
495
|
+
user: user,
|
|
496
|
+
role: "member",
|
|
497
|
+
joined_via: effective_joined_via,
|
|
498
|
+
verified_email: email_verified? ? verification_email : nil,
|
|
499
|
+
verified_email_normalized: email_verified? ? verification_email_normalized : nil,
|
|
500
|
+
verified_at: verified_at,
|
|
501
|
+
metadata: resolved_membership_metadata
|
|
502
|
+
)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# THE MEMBERSHIP GATE (strict, vetoing, pre-persist) — covers every
|
|
506
|
+
# verified-joining path, since approval is the only way a request becomes
|
|
507
|
+
# a membership (codes, domains, allowlists, account-email shortcut all
|
|
508
|
+
# funnel here). Runs inside approve!'s locked transaction: a veto rolls
|
|
509
|
+
# back the status flip too, so the request stays PENDING — a safe,
|
|
510
|
+
# resumable state (approve again once the host unblocks).
|
|
511
|
+
# ⚠️ Join-code nuance: redemption consumes a use BEFORE approval, so a
|
|
512
|
+
# vetoed redemption still spends a use — max_uses is an anti-abuse cap,
|
|
513
|
+
# not a seat count (see README "Verified joining").
|
|
514
|
+
def dispatch_member_joining_gate!(effective_joined_via)
|
|
515
|
+
Callbacks.dispatch(
|
|
516
|
+
:member_joining,
|
|
517
|
+
strict: true,
|
|
518
|
+
organization: organization,
|
|
519
|
+
user: user,
|
|
520
|
+
role: "member",
|
|
521
|
+
joined_via: effective_joined_via,
|
|
522
|
+
join_request: self
|
|
523
|
+
)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def resolved_membership_metadata
|
|
527
|
+
merged = {}
|
|
528
|
+
merged = merged.merge(hashify(matched_domain&.membership_metadata))
|
|
529
|
+
merged = merged.merge(hashify(matched_allowlist_entry&.membership_metadata))
|
|
530
|
+
merged.merge(hashify(join_code&.membership_metadata))
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def hashify(value)
|
|
534
|
+
value.is_a?(Hash) ? value : {}
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def matched_domain
|
|
538
|
+
domain_id = (metadata || {})["matched_domain_id"]
|
|
539
|
+
return nil unless domain_id
|
|
540
|
+
|
|
541
|
+
@matched_domain ||= organization.domains.find_by(id: domain_id)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def matched_allowlist_entry
|
|
545
|
+
entry_id = (metadata || {})["matched_allowlist_entry_id"]
|
|
546
|
+
return nil unless entry_id
|
|
547
|
+
|
|
548
|
+
@matched_allowlist_entry ||= organization.allowlist_entries.find_by(id: entry_id)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def claim_matched_allowlist_entry!
|
|
552
|
+
matched_allowlist_entry&.claim!(user)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def generate_verification_code
|
|
556
|
+
format("%06d", SecureRandom.random_number(1_000_000))
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def interval_ago(interval)
|
|
560
|
+
Time.current - interval
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def deliver_verification_email(code)
|
|
564
|
+
mailer_class = Organizations.configuration.verification_mailer.constantize
|
|
565
|
+
mailer_class.code_email(self, code).deliver_later
|
|
566
|
+
rescue StandardError => e
|
|
567
|
+
# The challenge row already COMMITTED (throttle stamped, send counted)
|
|
568
|
+
# before delivery was attempted — if we only logged here, the user
|
|
569
|
+
# would sit behind the resend throttle (and burn one of max_sends)
|
|
570
|
+
# waiting for a code that never left the building. Roll the throttle
|
|
571
|
+
# bookkeeping back so an immediate retry is allowed, and give the host
|
|
572
|
+
# a real signal (the on_verification_delivery_failed callback) instead
|
|
573
|
+
# of a log line nobody watches.
|
|
574
|
+
rollback_undelivered_challenge!(code)
|
|
575
|
+
Callbacks.log_error("[Organizations] Failed to send verification email: #{e.message}")
|
|
576
|
+
Callbacks.dispatch(
|
|
577
|
+
:verification_delivery_failed,
|
|
578
|
+
organization: organization,
|
|
579
|
+
user: user,
|
|
580
|
+
join_request: self,
|
|
581
|
+
metadata: { "error_class" => e.class.name, "error_message" => e.message }
|
|
582
|
+
)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Revert the challenge bookkeeping for a code that never got delivered.
|
|
586
|
+
# Digest-guarded under lock: if a concurrent resend already minted a new
|
|
587
|
+
# code (different digest) or a verify burned this one (nil digest), the
|
|
588
|
+
# state belongs to that other operation — leave it alone.
|
|
589
|
+
def rollback_undelivered_challenge!(code)
|
|
590
|
+
with_lock do
|
|
591
|
+
break unless verification_code_digest == self.class.digest_verification_code(code, id)
|
|
592
|
+
|
|
593
|
+
update!(
|
|
594
|
+
verification_code_digest: nil,
|
|
595
|
+
verification_sent_at: nil,
|
|
596
|
+
verification_expires_at: nil,
|
|
597
|
+
verification_attempts: 0,
|
|
598
|
+
verification_sends_count: [verification_sends_count - 1, 0].max
|
|
599
|
+
)
|
|
600
|
+
end
|
|
601
|
+
rescue StandardError => e
|
|
602
|
+
# Never let cleanup failure mask the original delivery failure.
|
|
603
|
+
Callbacks.log_error("[Organizations] Failed to roll back undelivered challenge: #{e.message}")
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def set_expiry
|
|
607
|
+
expiry = Organizations.configuration.join_request_expiry
|
|
608
|
+
self.expires_at = expiry ? Time.current + expiry : nil
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Mirrors the DB partial unique index: one open request per (org, user)
|
|
612
|
+
def unique_pending_request
|
|
613
|
+
return unless organization_id && user_id
|
|
614
|
+
return unless status == "pending"
|
|
615
|
+
|
|
616
|
+
existing = JoinRequest.where(organization_id: organization_id, user_id: user_id, status: "pending")
|
|
617
|
+
.where.not(id: id)
|
|
618
|
+
.exists?
|
|
619
|
+
|
|
620
|
+
errors.add(:user_id, Organizations.t(:"attributes.pending_request_taken")) if existing
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
# rubocop:enable Metrics/ClassLength
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
627
|
+
ActiveSupport.run_load_hooks(:organizations_join_request, Organizations::JoinRequest)
|