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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# An email domain owned/claimed by an organization, used for verified joining.
|
|
5
|
+
#
|
|
6
|
+
# A user who proves control of an inbox under one of the organization's
|
|
7
|
+
# domains (emailed code, or an already-confirmed account email — see
|
|
8
|
+
# Organization#join_with_account_email!) is considered a verified member.
|
|
9
|
+
#
|
|
10
|
+
# Matching is EXACT and dot-boundary safe: "alumnos.urjc.es" and "urjc.es"
|
|
11
|
+
# are two different domains and must both be enrolled if both should join.
|
|
12
|
+
# This is deliberate — subdomains often carry different member semantics
|
|
13
|
+
# (e.g. students vs employees), and suffix matching would collapse them.
|
|
14
|
+
# It also neutralizes lookalike attacks ("urjc.es.evil.com" never equals
|
|
15
|
+
# "urjc.es").
|
|
16
|
+
#
|
|
17
|
+
# `membership_metadata` is copied onto memberships created through this
|
|
18
|
+
# domain (see JoinRequest#approve!) — hosts use it for cohort tags like
|
|
19
|
+
# { "member_kind" => "student" } without the gem interpreting the contents.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# org.add_domain!("inizio.com")
|
|
23
|
+
# org.add_domain!("alumnos.urjc.es", membership_metadata: { member_kind: "student" })
|
|
24
|
+
# Organizations::Domain.matching_email("j.doe@inizio.com") # => [domain]
|
|
25
|
+
#
|
|
26
|
+
class Domain < ActiveRecord::Base
|
|
27
|
+
self.table_name = "organizations_domains"
|
|
28
|
+
|
|
29
|
+
# === Associations ===
|
|
30
|
+
|
|
31
|
+
belongs_to :organization,
|
|
32
|
+
class_name: "Organizations::Organization",
|
|
33
|
+
inverse_of: :domains
|
|
34
|
+
|
|
35
|
+
# === Validations ===
|
|
36
|
+
|
|
37
|
+
validates :domain, presence: true, uniqueness: { scope: :organization_id }
|
|
38
|
+
validate :domain_shape
|
|
39
|
+
|
|
40
|
+
# === Callbacks ===
|
|
41
|
+
|
|
42
|
+
before_validation :normalize_domain
|
|
43
|
+
|
|
44
|
+
# === Scopes ===
|
|
45
|
+
|
|
46
|
+
# All domain rows (across organizations) matching an email's domain.
|
|
47
|
+
# Returns none for emails whose domain can't be safely extracted
|
|
48
|
+
# (multi-@ evasion shapes, blanks).
|
|
49
|
+
scope :matching_email, lambda { |email|
|
|
50
|
+
extracted = EmailNormalizer.domain_of(email)
|
|
51
|
+
extracted ? where(domain: extracted) : none
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# === Matching ===
|
|
55
|
+
|
|
56
|
+
# Whether an email address belongs to this domain (exact match).
|
|
57
|
+
# @param email [String]
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def matches_email?(email)
|
|
60
|
+
extracted = EmailNormalizer.domain_of(email)
|
|
61
|
+
extracted.present? && extracted == domain
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def normalize_domain
|
|
67
|
+
return if domain.blank?
|
|
68
|
+
|
|
69
|
+
self.domain = domain.to_s.strip.downcase.delete_prefix("@").chomp(".")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Pragmatic hostname shape check: lowercase labels, at least one dot,
|
|
73
|
+
# no whitespace or "@". (IDN domains should be enrolled in punycode form.)
|
|
74
|
+
def domain_shape
|
|
75
|
+
return if domain.blank? # presence validation covers this
|
|
76
|
+
|
|
77
|
+
unless domain.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\z/)
|
|
78
|
+
errors.add(:domain, Organizations.t(:"attributes.domain_invalid"))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
85
|
+
ActiveSupport.run_load_hooks(:organizations_domain, Organizations::Domain)
|
|
@@ -28,7 +28,7 @@ module Organizations
|
|
|
28
28
|
|
|
29
29
|
# Optional because inviter can be deleted (dependent: :nullify on User)
|
|
30
30
|
belongs_to :invited_by,
|
|
31
|
-
class_name:
|
|
31
|
+
class_name: Organizations.user_class_name,
|
|
32
32
|
optional: true
|
|
33
33
|
|
|
34
34
|
# Alias for invited_by (semantic convenience as per README)
|
|
@@ -131,7 +131,7 @@ module Organizations
|
|
|
131
131
|
# Validate email matches at model level (security)
|
|
132
132
|
unless skip_email_validation
|
|
133
133
|
if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email)
|
|
134
|
-
raise EmailMismatch, "
|
|
134
|
+
raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch")
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
@@ -148,16 +148,16 @@ module Organizations
|
|
|
148
148
|
existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
|
|
149
149
|
return existing_membership if existing_membership
|
|
150
150
|
|
|
151
|
-
raise InvitationAlreadyAccepted, "
|
|
151
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted")
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
if expired?
|
|
155
|
-
raise InvitationExpired, "
|
|
155
|
+
raise InvitationExpired, Organizations.t(:"errors.invitation_expired")
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
# Owner role cannot be assigned via invitation (defense in depth)
|
|
159
159
|
if role.to_sym == :owner
|
|
160
|
-
raise CannotAcceptAsOwner, "
|
|
160
|
+
raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner")
|
|
161
161
|
end
|
|
162
162
|
|
|
163
163
|
# Check if user is already a member (race condition from another invitation)
|
|
@@ -167,14 +167,8 @@ module Organizations
|
|
|
167
167
|
return existing_membership
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
-
# Create the membership
|
|
171
|
-
|
|
172
|
-
# (AssociationTypeMismatch when User class is reloaded in development)
|
|
173
|
-
membership = organization.memberships.create!(
|
|
174
|
-
user: accepting_user,
|
|
175
|
-
role: role,
|
|
176
|
-
invited_by_id: invited_by_id
|
|
177
|
-
)
|
|
170
|
+
# Create the membership (with verified-joining provenance)
|
|
171
|
+
membership = create_membership_for!(accepting_user, skip_email_validation)
|
|
178
172
|
|
|
179
173
|
# Mark invitation as accepted
|
|
180
174
|
update!(accepted_at: Time.current)
|
|
@@ -198,7 +192,7 @@ module Organizations
|
|
|
198
192
|
lock!
|
|
199
193
|
|
|
200
194
|
if accepted?
|
|
201
|
-
raise InvitationAlreadyAccepted, "
|
|
195
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted")
|
|
202
196
|
end
|
|
203
197
|
|
|
204
198
|
update!(
|
|
@@ -216,7 +210,10 @@ module Organizations
|
|
|
216
210
|
# @return [String]
|
|
217
211
|
def acceptance_url(base_url: nil)
|
|
218
212
|
base = base_url || default_base_url
|
|
219
|
-
|
|
213
|
+
# Organizations.engine_mount_path keeps this correct for hosts that
|
|
214
|
+
# mount the engine somewhere other than root ("/orgs/invitations/…") —
|
|
215
|
+
# a hardcoded "/invitations/…" silently 404'd for them.
|
|
216
|
+
"#{base}#{Organizations.engine_mount_path}/invitations/#{token}"
|
|
220
217
|
end
|
|
221
218
|
|
|
222
219
|
# Check if invitation matches a specific email
|
|
@@ -228,6 +225,94 @@ module Organizations
|
|
|
228
225
|
|
|
229
226
|
private
|
|
230
227
|
|
|
228
|
+
# Create the membership for an acceptance.
|
|
229
|
+
# Uses invited_by_id instead of invited_by to avoid Rails class reloading
|
|
230
|
+
# issues (AssociationTypeMismatch when User is reloaded in development).
|
|
231
|
+
#
|
|
232
|
+
# Verified-joining provenance (v0.5.0): accepting the emailed token is
|
|
233
|
+
# proof of control of the invited address, so the membership records it
|
|
234
|
+
# as a verified email — UNLESS the acceptance bypassed the email match
|
|
235
|
+
# (skip_email_validation with a different account email), where no inbox
|
|
236
|
+
# proof exists. If the address was already claimed by another membership
|
|
237
|
+
# in this org (rare recycled-address edge), the membership is still
|
|
238
|
+
# created, just without the verified-email stamp.
|
|
239
|
+
def create_membership_for!(accepting_user, skip_email_validation)
|
|
240
|
+
# THE MEMBERSHIP GATE (strict, vetoing, pre-persist) — see
|
|
241
|
+
# Configuration#on_member_joining. Runs inside accept!'s locked
|
|
242
|
+
# transaction: a veto rolls back accepted_at too, so the invitation
|
|
243
|
+
# stays pending and can be accepted again once the host unblocks.
|
|
244
|
+
Callbacks.dispatch(
|
|
245
|
+
:member_joining,
|
|
246
|
+
strict: true,
|
|
247
|
+
organization: organization,
|
|
248
|
+
user: accepting_user,
|
|
249
|
+
role: role,
|
|
250
|
+
joined_via: "invited",
|
|
251
|
+
invitation: self
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# SAVEPOINT (requires_new): rescued unique-violation inside accept!'s
|
|
255
|
+
# transaction — see JoinRequest#create_membership! for the PostgreSQL
|
|
256
|
+
# aborted-transaction rationale (proven by the PG leg of the suite).
|
|
257
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
258
|
+
organization.memberships.create!(
|
|
259
|
+
**base_membership_attributes(accepting_user),
|
|
260
|
+
**verified_email_attributes_for(accepting_user, skip_email_validation)
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
rescue ActiveRecord::RecordNotUnique
|
|
264
|
+
# Two unique indexes can fire here (same disambiguation as
|
|
265
|
+
# JoinRequest#create_membership!):
|
|
266
|
+
# 1. (user, org) — another acceptance/join path just made them a member:
|
|
267
|
+
# reuse it (invitations to one address aren't normalization-aware, so
|
|
268
|
+
# two plus-variant invitations can race).
|
|
269
|
+
# 2. (org, verified_email_normalized) — the address was claimed between
|
|
270
|
+
# our pre-check and the INSERT: degrade gracefully by creating the
|
|
271
|
+
# membership WITHOUT the verified-email stamp. Acceptance never breaks.
|
|
272
|
+
existing = organization.memberships.find_by(user_id: accepting_user.id)
|
|
273
|
+
return existing if existing
|
|
274
|
+
|
|
275
|
+
# Its own savepoint too: this fallback INSERT can itself lose the
|
|
276
|
+
# (user, org) race, and accept!'s rescue must stay reachable on PG.
|
|
277
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
278
|
+
organization.memberships.create!(**base_membership_attributes(accepting_user))
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def base_membership_attributes(accepting_user)
|
|
283
|
+
{
|
|
284
|
+
user: accepting_user,
|
|
285
|
+
role: role,
|
|
286
|
+
invited_by_id: invited_by_id,
|
|
287
|
+
joined_via: "invited",
|
|
288
|
+
metadata: membership_metadata.is_a?(Hash) ? membership_metadata : {}
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Provenance attributes for the membership created by this acceptance.
|
|
293
|
+
# See create_membership_for! for the trust rules.
|
|
294
|
+
def verified_email_attributes_for(accepting_user, skip_email_validation)
|
|
295
|
+
email_proven =
|
|
296
|
+
!skip_email_validation ||
|
|
297
|
+
(accepting_user.respond_to?(:email) && for_email?(accepting_user.email))
|
|
298
|
+
|
|
299
|
+
return {} unless email_proven
|
|
300
|
+
|
|
301
|
+
normalized = Organizations.configuration.normalize_verification_email(email)
|
|
302
|
+
|
|
303
|
+
already_claimed = Membership
|
|
304
|
+
.where(organization_id: organization_id, verified_email_normalized: normalized)
|
|
305
|
+
.exists?
|
|
306
|
+
|
|
307
|
+
return {} if already_claimed
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
verified_email: email,
|
|
311
|
+
verified_email_normalized: normalized,
|
|
312
|
+
verified_at: Time.current
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
231
316
|
def normalize_email
|
|
232
317
|
self.email = email.to_s.downcase.strip if email.present?
|
|
233
318
|
end
|
|
@@ -265,7 +350,7 @@ module Organizations
|
|
|
265
350
|
.exists?
|
|
266
351
|
|
|
267
352
|
if existing
|
|
268
|
-
errors.add(:email, "
|
|
353
|
+
errors.add(:email, Organizations.t(:"attributes.invitation_taken"))
|
|
269
354
|
end
|
|
270
355
|
end
|
|
271
356
|
|
|
@@ -283,7 +368,7 @@ module Organizations
|
|
|
283
368
|
end
|
|
284
369
|
|
|
285
370
|
def default_base_url
|
|
286
|
-
if
|
|
371
|
+
if Organizations.full_rails_app? && Rails.application.routes
|
|
287
372
|
Rails.application.routes.url_helpers.root_url.chomp("/")
|
|
288
373
|
else
|
|
289
374
|
""
|
|
@@ -293,3 +378,6 @@ module Organizations
|
|
|
293
378
|
end
|
|
294
379
|
end
|
|
295
380
|
end
|
|
381
|
+
|
|
382
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
383
|
+
ActiveSupport.run_load_hooks(:organizations_invitation, Organizations::Invitation)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# A shareable join code ("PIN") for an organization — the classroom/Slack
|
|
5
|
+
# style joining mechanism. Codes are globally unique so redemption needs no
|
|
6
|
+
# organization context (QR posters, links, plain typing).
|
|
7
|
+
#
|
|
8
|
+
# Per-code knobs:
|
|
9
|
+
# - `requires_verified_domain_email` — redemption additionally requires the
|
|
10
|
+
# emailed-code challenge against one of the org's domains (the "reinforced"
|
|
11
|
+
# level). Per-code (not per-org) so one org can run both levels at once.
|
|
12
|
+
# - `auto_approve` — false means redemption parks a pending JoinRequest for
|
|
13
|
+
# manual approval instead of granting membership immediately.
|
|
14
|
+
# - `expires_at`, `max_uses`, `revoked_at` — abuse containment. Rotation is
|
|
15
|
+
# revoke + generate a new code (history preserved for audit).
|
|
16
|
+
# - `label` — campaign attribution ("cafeteria poster" vs "newsletter").
|
|
17
|
+
#
|
|
18
|
+
# `membership_metadata` is copied onto memberships created through this code.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# code = org.generate_join_code!(label: "poster", requires_verified_domain_email: true)
|
|
22
|
+
# Organizations::JoinCode.redeem(code.code, user: user)
|
|
23
|
+
# # => Membership (instant join) or JoinRequest (challenge/approval pending)
|
|
24
|
+
#
|
|
25
|
+
class JoinCode < ActiveRecord::Base
|
|
26
|
+
self.table_name = "organizations_join_codes"
|
|
27
|
+
|
|
28
|
+
# Ambiguity-free alphabet (no I/L/O/0/1 lookalikes) — same idea as
|
|
29
|
+
# user-facing referral codes: survives posters, print, and dictation.
|
|
30
|
+
CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
31
|
+
DEFAULT_CODE_LENGTH = 8
|
|
32
|
+
|
|
33
|
+
# === Associations ===
|
|
34
|
+
|
|
35
|
+
belongs_to :organization,
|
|
36
|
+
class_name: "Organizations::Organization",
|
|
37
|
+
inverse_of: :join_codes
|
|
38
|
+
|
|
39
|
+
belongs_to :created_by,
|
|
40
|
+
class_name: Organizations.user_class_name,
|
|
41
|
+
optional: true
|
|
42
|
+
|
|
43
|
+
# Requests OUTLIVE the codes that created them (they are the join audit
|
|
44
|
+
# trail; joined_via/metadata are already snapshotted) — deleting a code
|
|
45
|
+
# nullifies the linkage instead of cascading or violating the FK.
|
|
46
|
+
# Prefer revoke! over destroy for rotation; destroy stays safe regardless.
|
|
47
|
+
has_many :join_requests,
|
|
48
|
+
class_name: "Organizations::JoinRequest",
|
|
49
|
+
inverse_of: :join_code,
|
|
50
|
+
dependent: :nullify
|
|
51
|
+
|
|
52
|
+
# === Validations ===
|
|
53
|
+
|
|
54
|
+
validates :code, presence: true, uniqueness: true
|
|
55
|
+
validates :max_uses, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
|
|
56
|
+
validates :uses_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
57
|
+
|
|
58
|
+
# === Callbacks ===
|
|
59
|
+
|
|
60
|
+
before_validation :normalize_code
|
|
61
|
+
before_validation :generate_code, on: :create, if: -> { code.blank? }
|
|
62
|
+
|
|
63
|
+
# === Scopes ===
|
|
64
|
+
|
|
65
|
+
scope :not_revoked, -> { where(revoked_at: nil) }
|
|
66
|
+
|
|
67
|
+
# Codes that can actually be redeemed right now — the SQL twin of
|
|
68
|
+
# #active? (not revoked, not expired, not exhausted). Powers
|
|
69
|
+
# Organization#accepts_code_joining? without loading rows.
|
|
70
|
+
scope :active, lambda {
|
|
71
|
+
not_revoked
|
|
72
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
73
|
+
.where("max_uses IS NULL OR uses_count < max_uses")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# === Status ===
|
|
77
|
+
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def revoked?
|
|
80
|
+
revoked_at.present?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def expired?
|
|
85
|
+
expires_at.present? && expires_at <= Time.current
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def exhausted?
|
|
90
|
+
max_uses.present? && uses_count >= max_uses
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def active?
|
|
95
|
+
!revoked? && !expired? && !exhausted?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Symbol] :active, :revoked, :expired, or :exhausted
|
|
99
|
+
def status
|
|
100
|
+
return :revoked if revoked?
|
|
101
|
+
return :expired if expired?
|
|
102
|
+
return :exhausted if exhausted?
|
|
103
|
+
|
|
104
|
+
:active
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Revoke this code (rotation = revoke + generate a new one). Idempotent.
|
|
108
|
+
# @return [self]
|
|
109
|
+
def revoke!
|
|
110
|
+
return self if revoked?
|
|
111
|
+
|
|
112
|
+
with_lock do
|
|
113
|
+
break if revoked?
|
|
114
|
+
|
|
115
|
+
update!(revoked_at: Time.current)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Presentation form, grouped for readability: "7FHK2MPX" => "7FHK-2MPX".
|
|
122
|
+
# Storage and matching always use the bare form.
|
|
123
|
+
# @return [String]
|
|
124
|
+
def display_code
|
|
125
|
+
code.to_s.scan(/.{1,4}/).join("-")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# === Redemption ===
|
|
129
|
+
|
|
130
|
+
# Redeem a code for a user.
|
|
131
|
+
# @param code [String] the code as typed (case/hyphen/space insensitive)
|
|
132
|
+
# @param user [User] the redeeming user
|
|
133
|
+
# @return [Membership] when the code grants instant membership (or the
|
|
134
|
+
# user is already a member)
|
|
135
|
+
# @return [JoinRequest] when a challenge or manual approval is still needed
|
|
136
|
+
# @raise [JoinCodeInvalid] for unknown, revoked, or expired codes
|
|
137
|
+
# @raise [JoinCodeExhausted] when max_uses is spent
|
|
138
|
+
def self.redeem(code, user:)
|
|
139
|
+
normalized = normalize(code)
|
|
140
|
+
raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if normalized.blank?
|
|
141
|
+
|
|
142
|
+
join_code = find_by(code: normalized)
|
|
143
|
+
raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") unless join_code
|
|
144
|
+
|
|
145
|
+
join_code.redeem!(user: user)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Instance-level redemption. See .redeem for semantics.
|
|
149
|
+
def redeem!(user:)
|
|
150
|
+
raise ArgumentError, "user is required" unless user
|
|
151
|
+
|
|
152
|
+
outcome = nil
|
|
153
|
+
|
|
154
|
+
ActiveRecord::Base.transaction do
|
|
155
|
+
# Lock the code row: uses_count accounting and the max_uses cap must
|
|
156
|
+
# be race-safe (two concurrent redemptions of a max_uses: 1 code must
|
|
157
|
+
# yield exactly one success).
|
|
158
|
+
lock!
|
|
159
|
+
ensure_redeemable!
|
|
160
|
+
|
|
161
|
+
# Already a member — idempotent no-op that does NOT consume a use.
|
|
162
|
+
existing_membership = organization.memberships.find_by(user_id: user.id)
|
|
163
|
+
if existing_membership
|
|
164
|
+
outcome = existing_membership
|
|
165
|
+
raise ActiveRecord::Rollback # nothing to persist
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
outcome = attach_request!(user)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Instant-join path: no email challenge required and auto-approve on.
|
|
172
|
+
# Approval runs AFTER the redemption transaction commits so the
|
|
173
|
+
# member_joined/join_request_approved callbacks never fire for a
|
|
174
|
+
# transaction that could still roll back. If approval fails, the
|
|
175
|
+
# pending request survives — a safe, resumable state.
|
|
176
|
+
return outcome unless outcome.is_a?(JoinRequest)
|
|
177
|
+
return outcome if requires_verified_domain_email? || !auto_approve?
|
|
178
|
+
|
|
179
|
+
outcome.approve!(decided_by: nil)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Normalize user input to storage form: uppercase, strip separators.
|
|
183
|
+
# @param code [String, nil]
|
|
184
|
+
# @return [String]
|
|
185
|
+
def self.normalize(code)
|
|
186
|
+
code.to_s.upcase.gsub(/[\s-]/, "")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def ensure_redeemable!
|
|
192
|
+
raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if revoked? || expired?
|
|
193
|
+
raise JoinCodeExhausted, Organizations.t(:"errors.join_code_exhausted") if exhausted?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Attach this code to the user's open request (creating one if needed),
|
|
197
|
+
# consuming a use — except for idempotent re-redemptions of the same code.
|
|
198
|
+
def attach_request!(user)
|
|
199
|
+
request = pending_request_for(user)
|
|
200
|
+
|
|
201
|
+
# Same user re-redeeming the same code: idempotent, no extra use.
|
|
202
|
+
return request if request.persisted? && request.join_code_id == id
|
|
203
|
+
|
|
204
|
+
request.join_code = self
|
|
205
|
+
request.joined_via = "code"
|
|
206
|
+
request.save!
|
|
207
|
+
increment!(:uses_count)
|
|
208
|
+
request
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Find or build this user's open request for the organization.
|
|
212
|
+
# Reuses an existing pending request (partial unique index: one pending
|
|
213
|
+
# request per user per org) so re-entry via a different mechanism upgrades
|
|
214
|
+
# the same request instead of exploding.
|
|
215
|
+
def pending_request_for(user)
|
|
216
|
+
organization.join_requests.pending.find_by(user_id: user.id) ||
|
|
217
|
+
organization.join_requests.new(user: user)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def normalize_code
|
|
221
|
+
self.code = self.class.normalize(code) if code.present?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def generate_code
|
|
225
|
+
generator = Organizations.configuration.join_code_generator
|
|
226
|
+
|
|
227
|
+
self.code = loop do
|
|
228
|
+
candidate =
|
|
229
|
+
if generator.respond_to?(:call)
|
|
230
|
+
self.class.normalize(generator.call)
|
|
231
|
+
else
|
|
232
|
+
Array.new(DEFAULT_CODE_LENGTH) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
break candidate unless self.class.exists?(code: candidate)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
242
|
+
ActiveSupport.run_load_hooks(:organizations_join_code, Organizations::JoinCode)
|