organizations 0.4.2 → 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 +53 -0
- data/README.md +414 -46
- 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 +27 -19
- data/lib/organizations.rb +165 -0
- metadata +34 -2
|
@@ -15,9 +15,23 @@ module Organizations
|
|
|
15
15
|
# membership.promote_to!(:admin)
|
|
16
16
|
# membership.demote_to!(:member)
|
|
17
17
|
#
|
|
18
|
+
# ⚠️ THE MEMBERSHIP GATE IS NOT ON THIS MODEL. `on_member_joining` (the
|
|
19
|
+
# strict, vetoing host callback — seat limits, member caps) is dispatched
|
|
20
|
+
# by the SANCTIONED join paths: Organization#add_member!,
|
|
21
|
+
# Invitation#accept!, and JoinRequest#approve! (which covers codes,
|
|
22
|
+
# domains, allowlists, and the account-email shortcut). A direct
|
|
23
|
+
# `Membership.create!` bypasses the gate entirely — that's deliberate for
|
|
24
|
+
# ops/console/test usage (Organization.create_with_owner!, TestHelpers),
|
|
25
|
+
# but NEVER create memberships directly from request-cycle product code,
|
|
26
|
+
# and any NEW join path added to the gem must dispatch the gate itself
|
|
27
|
+
# (see Configuration#on_member_joining).
|
|
18
28
|
class Membership < ActiveRecord::Base
|
|
19
29
|
self.table_name = "organizations_memberships"
|
|
20
30
|
|
|
31
|
+
# metadata_flag macro for typed boolean toggles over the metadata bag —
|
|
32
|
+
# see Organizations::MetadataFlags.
|
|
33
|
+
extend Organizations::MetadataFlags
|
|
34
|
+
|
|
21
35
|
# Error raised when trying to demote below current role
|
|
22
36
|
class CannotDemoteOwner < Organizations::Error; end
|
|
23
37
|
class CannotPromoteToOwner < Organizations::Error; end
|
|
@@ -25,20 +39,25 @@ module Organizations
|
|
|
25
39
|
|
|
26
40
|
# === Associations ===
|
|
27
41
|
|
|
28
|
-
|
|
42
|
+
# Explicit class_name (NOT inferred from the association name) so hosts
|
|
43
|
+
# with a differently-named account model work: config.user_class.
|
|
44
|
+
belongs_to :user, class_name: Organizations.user_class_name
|
|
29
45
|
belongs_to :organization,
|
|
30
46
|
class_name: "Organizations::Organization",
|
|
31
47
|
inverse_of: :memberships,
|
|
32
48
|
counter_cache: :memberships_count
|
|
33
49
|
|
|
34
50
|
belongs_to :invited_by,
|
|
35
|
-
class_name:
|
|
51
|
+
class_name: Organizations.user_class_name,
|
|
36
52
|
optional: true
|
|
37
53
|
|
|
38
54
|
# === Validations ===
|
|
39
55
|
|
|
40
56
|
validates :role, presence: true, inclusion: { in: ->(_) { Roles::HIERARCHY.map(&:to_s) } }
|
|
41
|
-
|
|
57
|
+
# Proc message: resolved at VALIDATION time so it follows I18n.locale —
|
|
58
|
+
# a literal string here would be frozen in whatever locale loaded first.
|
|
59
|
+
validates :user_id, uniqueness: { scope: :organization_id,
|
|
60
|
+
message: ->(*) { Organizations.t(:"attributes.membership_taken") } }
|
|
42
61
|
validate :single_owner_per_organization, if: :owner?
|
|
43
62
|
|
|
44
63
|
# === Scopes ===
|
|
@@ -71,6 +90,15 @@ module Organizations
|
|
|
71
90
|
SQL
|
|
72
91
|
}
|
|
73
92
|
|
|
93
|
+
# === Verified Joining (v0.5.0) ===
|
|
94
|
+
|
|
95
|
+
# Whether this membership was created with a proven email address
|
|
96
|
+
# (emailed-code challenge, confirmed account email, or accepted invitation)
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def verified?
|
|
99
|
+
verified_at.present?
|
|
100
|
+
end
|
|
101
|
+
|
|
74
102
|
# === Role Methods ===
|
|
75
103
|
|
|
76
104
|
# Get the role as a symbol
|
|
@@ -152,11 +180,11 @@ module Organizations
|
|
|
152
180
|
|
|
153
181
|
# Owner role is only assignable via transfer_ownership_to!
|
|
154
182
|
if new_role_sym == :owner
|
|
155
|
-
raise CannotPromoteToOwner, "
|
|
183
|
+
raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner")
|
|
156
184
|
end
|
|
157
185
|
|
|
158
186
|
unless Roles.at_least?(new_role_sym, role_sym)
|
|
159
|
-
raise InvalidRoleChange, "
|
|
187
|
+
raise InvalidRoleChange, Organizations.t(:"errors.promote_not_higher", new_role: new_role, role: role)
|
|
160
188
|
end
|
|
161
189
|
|
|
162
190
|
change_role_to!(new_role_sym, changed_by: changed_by)
|
|
@@ -173,11 +201,11 @@ module Organizations
|
|
|
173
201
|
validate_role!(new_role_sym)
|
|
174
202
|
|
|
175
203
|
if owner?
|
|
176
|
-
raise CannotDemoteOwner, "
|
|
204
|
+
raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner")
|
|
177
205
|
end
|
|
178
206
|
|
|
179
207
|
unless Roles.at_least?(role_sym, new_role_sym)
|
|
180
|
-
raise InvalidRoleChange, "
|
|
208
|
+
raise InvalidRoleChange, Organizations.t(:"errors.demote_not_lower", new_role: new_role, role: role)
|
|
181
209
|
end
|
|
182
210
|
|
|
183
211
|
change_role_to!(new_role_sym, changed_by: changed_by)
|
|
@@ -193,7 +221,7 @@ module Organizations
|
|
|
193
221
|
|
|
194
222
|
return unless existing_owner.exists?
|
|
195
223
|
|
|
196
|
-
errors.add(:role, "
|
|
224
|
+
errors.add(:role, Organizations.t(:"attributes.owner_taken"))
|
|
197
225
|
end
|
|
198
226
|
|
|
199
227
|
def validate_role!(role)
|
|
@@ -226,3 +254,6 @@ module Organizations
|
|
|
226
254
|
end
|
|
227
255
|
end
|
|
228
256
|
end
|
|
257
|
+
|
|
258
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
259
|
+
ActiveSupport.run_load_hooks(:organizations_membership, Organizations::Membership)
|
|
@@ -18,6 +18,10 @@ module Organizations
|
|
|
18
18
|
class Organization < ActiveRecord::Base
|
|
19
19
|
self.table_name = "organizations_organizations"
|
|
20
20
|
|
|
21
|
+
# metadata_flag macro for typed boolean toggles over the metadata bag —
|
|
22
|
+
# see Organizations::MetadataFlags.
|
|
23
|
+
extend Organizations::MetadataFlags
|
|
24
|
+
|
|
21
25
|
# Error raised when trying to perform invalid operations on organization
|
|
22
26
|
class CannotRemoveOwner < Organizations::Error; end
|
|
23
27
|
class CannotDemoteOwner < Organizations::Error; end
|
|
@@ -43,6 +47,32 @@ module Organizations
|
|
|
43
47
|
inverse_of: :organization,
|
|
44
48
|
dependent: :destroy
|
|
45
49
|
|
|
50
|
+
# === Verified joining (v0.5.0) ===
|
|
51
|
+
|
|
52
|
+
has_many :domains,
|
|
53
|
+
class_name: "Organizations::Domain",
|
|
54
|
+
inverse_of: :organization,
|
|
55
|
+
dependent: :destroy
|
|
56
|
+
|
|
57
|
+
# NOTE: join_requests are declared BEFORE join_codes on purpose — Rails
|
|
58
|
+
# runs dependent: :destroy cascades in DECLARATION order, and requests
|
|
59
|
+
# hold an FK to the code that created them. Requests must die first (and
|
|
60
|
+
# JoinCode#join_requests additionally nullifies for standalone deletes).
|
|
61
|
+
has_many :join_requests,
|
|
62
|
+
class_name: "Organizations::JoinRequest",
|
|
63
|
+
inverse_of: :organization,
|
|
64
|
+
dependent: :destroy
|
|
65
|
+
|
|
66
|
+
has_many :join_codes,
|
|
67
|
+
class_name: "Organizations::JoinCode",
|
|
68
|
+
inverse_of: :organization,
|
|
69
|
+
dependent: :destroy
|
|
70
|
+
|
|
71
|
+
has_many :allowlist_entries,
|
|
72
|
+
class_name: "Organizations::AllowlistEntry",
|
|
73
|
+
inverse_of: :organization,
|
|
74
|
+
dependent: :destroy
|
|
75
|
+
|
|
46
76
|
# === Validations ===
|
|
47
77
|
|
|
48
78
|
validates :name, presence: true
|
|
@@ -57,6 +87,44 @@ module Organizations
|
|
|
57
87
|
joins(:memberships).where(organizations_memberships: { user_id: user.id })
|
|
58
88
|
}
|
|
59
89
|
|
|
90
|
+
# === Creation ===
|
|
91
|
+
|
|
92
|
+
# Create an organization WITH its owner membership in one transaction —
|
|
93
|
+
# the ops/provisioning primitive. This is what consoles, seed scripts,
|
|
94
|
+
# and admin provisioning services should call instead of hand-rolling
|
|
95
|
+
# `create! + Membership.create!(role: "owner")` (which one production
|
|
96
|
+
# host did, accidentally skipping the organization_created callback).
|
|
97
|
+
#
|
|
98
|
+
# Differences from user.create_organization! (the SELF-SERVE path):
|
|
99
|
+
# - no session/current-organization switching (there is no session)
|
|
100
|
+
# - not subject to max_organizations_per_user (an ops admin may own
|
|
101
|
+
# hundreds of provisioned orgs)
|
|
102
|
+
# - does NOT fire on_member_joining (owner-at-creation is not
|
|
103
|
+
# "joining" — same contract as the rest of the gate)
|
|
104
|
+
# - DOES fire on_organization_created, like every creation path.
|
|
105
|
+
#
|
|
106
|
+
# ⚠️ Because it skips the per-user cap, do NOT expose this from
|
|
107
|
+
# request-cycle self-serve code — users creating their own organizations
|
|
108
|
+
# go through user.create_organization!. This is for consoles, seeds, and
|
|
109
|
+
# admin-vetted provisioning services.
|
|
110
|
+
#
|
|
111
|
+
# @param owner [User] becomes the owner (single-owner invariant holds)
|
|
112
|
+
# @param attributes [Hash] organization attributes (name:, plus any host columns)
|
|
113
|
+
# @return [Organizations::Organization]
|
|
114
|
+
def self.create_with_owner!(owner:, **attributes)
|
|
115
|
+
raise ArgumentError, "owner is required" unless owner
|
|
116
|
+
|
|
117
|
+
organization = nil
|
|
118
|
+
ActiveRecord::Base.transaction do
|
|
119
|
+
organization = create!(**attributes)
|
|
120
|
+
Membership.create!(user: owner, organization: organization, role: "owner")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Callbacks.dispatch(:organization_created, organization: organization, user: owner)
|
|
124
|
+
|
|
125
|
+
organization
|
|
126
|
+
end
|
|
127
|
+
|
|
60
128
|
# === Member Query Methods ===
|
|
61
129
|
|
|
62
130
|
# Get the owner of this organization
|
|
@@ -128,7 +196,7 @@ module Organizations
|
|
|
128
196
|
|
|
129
197
|
# Owner role is only assignable via transfer_ownership_to! or initial creation
|
|
130
198
|
if role_sym == :owner
|
|
131
|
-
raise CannotHaveMultipleOwners, "
|
|
199
|
+
raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner")
|
|
132
200
|
end
|
|
133
201
|
|
|
134
202
|
# Check if already a member (idempotent operation)
|
|
@@ -136,19 +204,51 @@ module Organizations
|
|
|
136
204
|
return existing if existing
|
|
137
205
|
|
|
138
206
|
membership = nil
|
|
139
|
-
|
|
207
|
+
created = false
|
|
208
|
+
# requires_new: the rescued unique-violation below must roll back only
|
|
209
|
+
# a SAVEPOINT — if a HOST wraps add_member! in its own transaction, a
|
|
210
|
+
# plain nested block joins it, and on PostgreSQL the violation would
|
|
211
|
+
# poison the host's whole transaction before the rescue could run the
|
|
212
|
+
# idempotent lookup (see JoinRequest#create_membership!).
|
|
213
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
214
|
+
# Re-check under the transaction (review finding): a concurrent
|
|
215
|
+
# add_member! that already inserted makes this call an idempotent
|
|
216
|
+
# no-op — resolving here (instead of falling through to the gate)
|
|
217
|
+
# keeps the gate from firing/vetoing a join that already happened,
|
|
218
|
+
# and member_joined is NOT re-dispatched for it (the concurrent
|
|
219
|
+
# creator already fired it). The RecordNotUnique rescue below stays
|
|
220
|
+
# as the backstop for the window this narrows but cannot close.
|
|
221
|
+
existing = memberships.find_by(user_id: user.id)
|
|
222
|
+
next (membership = existing) if existing
|
|
223
|
+
|
|
224
|
+
# THE MEMBERSHIP GATE (strict, vetoing, pre-persist): the one place a
|
|
225
|
+
# host can abort ANY membership creation — seat limits, member caps.
|
|
226
|
+
# Raising here rolls back cleanly (nothing persisted yet). Runs after
|
|
227
|
+
# the idempotency check on purpose: an existing member isn't joining.
|
|
228
|
+
Callbacks.dispatch(
|
|
229
|
+
:member_joining,
|
|
230
|
+
strict: true,
|
|
231
|
+
organization: self,
|
|
232
|
+
user: user,
|
|
233
|
+
role: role_sym.to_s,
|
|
234
|
+
joined_via: "manual"
|
|
235
|
+
)
|
|
236
|
+
|
|
140
237
|
membership = memberships.create!(
|
|
141
238
|
user: user,
|
|
142
239
|
role: role_sym.to_s
|
|
143
240
|
)
|
|
241
|
+
created = true
|
|
144
242
|
end
|
|
145
243
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
244
|
+
if created
|
|
245
|
+
Callbacks.dispatch(
|
|
246
|
+
:member_joined,
|
|
247
|
+
organization: self,
|
|
248
|
+
membership: membership,
|
|
249
|
+
user: user
|
|
250
|
+
)
|
|
251
|
+
end
|
|
152
252
|
|
|
153
253
|
membership
|
|
154
254
|
rescue ActiveRecord::RecordNotUnique
|
|
@@ -165,7 +265,7 @@ module Organizations
|
|
|
165
265
|
return unless membership
|
|
166
266
|
|
|
167
267
|
if membership.role.to_sym == :owner
|
|
168
|
-
raise CannotRemoveOwner, "
|
|
268
|
+
raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner")
|
|
169
269
|
end
|
|
170
270
|
|
|
171
271
|
ActiveRecord::Base.transaction do
|
|
@@ -210,12 +310,12 @@ module Organizations
|
|
|
210
310
|
if new_role == :owner && old_role != :owner
|
|
211
311
|
# Promoting to owner - this is only allowed via transfer_ownership_to!
|
|
212
312
|
# Direct role change to owner is not permitted
|
|
213
|
-
raise CannotHaveMultipleOwners, "
|
|
313
|
+
raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner")
|
|
214
314
|
end
|
|
215
315
|
|
|
216
316
|
if old_role == :owner && new_role != :owner
|
|
217
317
|
# Demoting owner - not allowed directly
|
|
218
|
-
raise CannotDemoteOwner, "
|
|
318
|
+
raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly")
|
|
219
319
|
end
|
|
220
320
|
|
|
221
321
|
membership.update!(role: new_role.to_s)
|
|
@@ -250,16 +350,16 @@ module Organizations
|
|
|
250
350
|
new_owner_membership = memberships.find_by(user_id: new_owner.id)
|
|
251
351
|
|
|
252
352
|
unless old_owner_membership
|
|
253
|
-
raise NoOwnerPresent, "
|
|
353
|
+
raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner")
|
|
254
354
|
end
|
|
255
355
|
|
|
256
356
|
unless new_owner_membership
|
|
257
|
-
raise CannotTransferToNonMember, "
|
|
357
|
+
raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member")
|
|
258
358
|
end
|
|
259
359
|
|
|
260
360
|
# New owner must be at least an admin (per README: "Ownership can be transferred to any admin")
|
|
261
361
|
unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
|
|
262
|
-
raise CannotTransferToNonAdmin, "
|
|
362
|
+
raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin")
|
|
263
363
|
end
|
|
264
364
|
|
|
265
365
|
# No-op transfer to the current owner.
|
|
@@ -307,7 +407,7 @@ module Organizations
|
|
|
307
407
|
|
|
308
408
|
# Owner role cannot be assigned via invitation - only via transfer_ownership_to!
|
|
309
409
|
if role_sym == :owner
|
|
310
|
-
raise CannotInviteAsOwner, "
|
|
410
|
+
raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner")
|
|
311
411
|
end
|
|
312
412
|
|
|
313
413
|
normalized_email = email.downcase.strip
|
|
@@ -318,7 +418,7 @@ module Organizations
|
|
|
318
418
|
|
|
319
419
|
# Check if already a member (case-insensitive)
|
|
320
420
|
if users.where("LOWER(email) = ?", normalized_email).exists?
|
|
321
|
-
raise Organizations::InvitationError, "
|
|
421
|
+
raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member")
|
|
322
422
|
end
|
|
323
423
|
|
|
324
424
|
# Allow callback hooks to veto invitations (e.g., plan seat limits) before write.
|
|
@@ -338,7 +438,11 @@ module Organizations
|
|
|
338
438
|
)
|
|
339
439
|
|
|
340
440
|
invitation = nil
|
|
341
|
-
|
|
441
|
+
# requires_new: the rescued unique-violation must roll back only a
|
|
442
|
+
# SAVEPOINT so the method-level rescue's lookup still works when a HOST
|
|
443
|
+
# wraps this call in its own transaction (PostgreSQL poisons the whole
|
|
444
|
+
# transaction otherwise — see JoinRequest#create_membership!).
|
|
445
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
342
446
|
# Check for expired invitation and refresh it instead of creating duplicate
|
|
343
447
|
expired_invitation = invitations.expired.for_email(normalized_email).first
|
|
344
448
|
if expired_invitation
|
|
@@ -370,8 +474,186 @@ module Organizations
|
|
|
370
474
|
invitations.pending.for_email(normalized_email).first!
|
|
371
475
|
end
|
|
372
476
|
|
|
477
|
+
# === Verified Joining Methods (v0.5.0) ===
|
|
478
|
+
|
|
479
|
+
# Enroll an email domain for domain-verified joining.
|
|
480
|
+
# @param domain [String] e.g. "inizio.com" (exact match — subdomains must be enrolled separately)
|
|
481
|
+
# @param membership_metadata [Hash] copied onto memberships created through this domain
|
|
482
|
+
# @return [Domain]
|
|
483
|
+
def add_domain!(domain, membership_metadata: {})
|
|
484
|
+
domains.create!(domain: domain, membership_metadata: membership_metadata)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Generate a shareable join code (PIN).
|
|
488
|
+
# @param label [String, nil] campaign attribution ("cafeteria poster")
|
|
489
|
+
# @param requires_verified_domain_email [Boolean] chain the emailed-code challenge ("reinforced" level)
|
|
490
|
+
# @param auto_approve [Boolean] false parks redemptions as pending requests for manual approval
|
|
491
|
+
# @param expires_at [Time, nil]
|
|
492
|
+
# @param max_uses [Integer, nil]
|
|
493
|
+
# @param created_by [User, nil]
|
|
494
|
+
# @param membership_metadata [Hash] copied onto memberships created through this code
|
|
495
|
+
# @return [JoinCode]
|
|
496
|
+
# Every parameter is an optional keyword with a safe default — this IS
|
|
497
|
+
# the public API surface, not incidental complexity.
|
|
498
|
+
# rubocop:disable Metrics/ParameterLists
|
|
499
|
+
def generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true,
|
|
500
|
+
expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {})
|
|
501
|
+
join_codes.create!(
|
|
502
|
+
label: label,
|
|
503
|
+
requires_verified_domain_email: requires_verified_domain_email,
|
|
504
|
+
auto_approve: auto_approve,
|
|
505
|
+
expires_at: expires_at,
|
|
506
|
+
max_uses: max_uses,
|
|
507
|
+
created_by_id: created_by&.id,
|
|
508
|
+
membership_metadata: membership_metadata
|
|
509
|
+
)
|
|
510
|
+
end
|
|
511
|
+
# rubocop:enable Metrics/ParameterLists
|
|
512
|
+
|
|
513
|
+
# Bulk-import roster emails as allowlist entries (idempotent per address:
|
|
514
|
+
# already-enrolled addresses are skipped, not duplicated).
|
|
515
|
+
# @param emails [Enumerable<String>]
|
|
516
|
+
# @param source [String, nil] provenance tag ("csv_2026-07")
|
|
517
|
+
# @param membership_metadata [Hash] copied onto memberships created through these entries
|
|
518
|
+
# @return [Array<AllowlistEntry>] the newly created entries
|
|
519
|
+
def import_allowlist!(emails, source: nil, membership_metadata: {})
|
|
520
|
+
Array(emails).filter_map do |email|
|
|
521
|
+
# SAVEPOINT per entry: the duplicate-skip rescue below must survive
|
|
522
|
+
# under a CALLER's transaction (real hosts wrap provisioning in one)
|
|
523
|
+
# — on PostgreSQL the first duplicate would otherwise poison the
|
|
524
|
+
# whole wrapping transaction and abort the import mid-list.
|
|
525
|
+
entry = ActiveRecord::Base.transaction(requires_new: true) do
|
|
526
|
+
allowlist_entries.create!(
|
|
527
|
+
email: email,
|
|
528
|
+
source: source,
|
|
529
|
+
membership_metadata: membership_metadata
|
|
530
|
+
)
|
|
531
|
+
end
|
|
532
|
+
entry
|
|
533
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
|
|
534
|
+
# Skip duplicates silently (idempotent import); re-raise anything else.
|
|
535
|
+
raise e unless duplicate_allowlist_error?(e)
|
|
536
|
+
|
|
537
|
+
nil
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Open join requests awaiting a decision
|
|
542
|
+
# @return [ActiveRecord::Relation<JoinRequest>]
|
|
543
|
+
def pending_join_requests
|
|
544
|
+
join_requests.pending
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Whether this organization accepts EMAIL-PROOF joining: an enrolled
|
|
548
|
+
# domain or an unclaimed roster entry (both run the same emailed-code
|
|
549
|
+
# challenge). Drives whether a join screen shows the email form.
|
|
550
|
+
# @return [Boolean]
|
|
551
|
+
def accepts_domain_joining?
|
|
552
|
+
domains.exists? || allowlist_entries.unclaimed.exists?
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Whether this organization has at least one code that can actually be
|
|
556
|
+
# redeemed right now (not revoked/expired/exhausted). Drives whether a
|
|
557
|
+
# join screen shows the code form.
|
|
558
|
+
# @return [Boolean]
|
|
559
|
+
def accepts_code_joining?
|
|
560
|
+
join_codes.active.exists?
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Whether this organization exposes any self-serve joining mechanism.
|
|
564
|
+
# NOTE (0.5.0 refinement): codes now count only while actually
|
|
565
|
+
# redeemable — an org whose every code expired or ran out no longer
|
|
566
|
+
# reads as joinable.
|
|
567
|
+
# @return [Boolean]
|
|
568
|
+
def accepts_join_requests?
|
|
569
|
+
accepts_domain_joining? || accepts_code_joining?
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Approve a join request (creates the membership). See JoinRequest#approve!.
|
|
573
|
+
# @param join_request [JoinRequest]
|
|
574
|
+
# @param approved_by [User, nil]
|
|
575
|
+
# @return [Membership]
|
|
576
|
+
def approve_join_request!(join_request, approved_by: nil)
|
|
577
|
+
ensure_join_request_belongs_here!(join_request)
|
|
578
|
+
join_request.approve!(decided_by: approved_by)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Reject a join request. See JoinRequest#reject!.
|
|
582
|
+
# @param join_request [JoinRequest]
|
|
583
|
+
# @param rejected_by [User, nil]
|
|
584
|
+
# @param reason [String, nil]
|
|
585
|
+
# @return [JoinRequest]
|
|
586
|
+
def reject_join_request!(join_request, rejected_by: nil, reason: nil)
|
|
587
|
+
ensure_join_request_belongs_here!(join_request)
|
|
588
|
+
join_request.reject!(rejected_by: rejected_by, reason: reason)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Zero-friction domain join for hosts with confirmed account emails
|
|
592
|
+
# (e.g. Devise :confirmable): if the user's own account email is confirmed
|
|
593
|
+
# and its domain is enrolled, the inbox was already proven at signup — no
|
|
594
|
+
# emailed code needed.
|
|
595
|
+
#
|
|
596
|
+
# @param user [User] must respond to #email; #confirmed_at gates trust
|
|
597
|
+
# @return [Membership]
|
|
598
|
+
# @raise [VerificationEmailNotEligible] when the account email's domain isn't enrolled,
|
|
599
|
+
# the email is unconfirmed, or the feature is disabled
|
|
600
|
+
def join_with_account_email!(user)
|
|
601
|
+
unless Organizations.configuration.trust_confirmed_account_email
|
|
602
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled")
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
email = user.respond_to?(:email) ? user.email.to_s : ""
|
|
606
|
+
confirmed = user.respond_to?(:confirmed_at) && user.confirmed_at.present?
|
|
607
|
+
|
|
608
|
+
unless confirmed
|
|
609
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed")
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
matched_domain = domains.matching_email(email).first
|
|
613
|
+
unless matched_domain
|
|
614
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Uniform funnel: every self-serve join goes through a JoinRequest so
|
|
618
|
+
# provenance and audit trail come for free.
|
|
619
|
+
request = join_requests.pending.find_by(user_id: user.id) || join_requests.new(user: user)
|
|
620
|
+
normalized = Organizations.configuration.normalize_verification_email(email)
|
|
621
|
+
|
|
622
|
+
ActiveRecord::Base.transaction do
|
|
623
|
+
if Membership.where(organization_id: id, verified_email_normalized: normalized).exists?
|
|
624
|
+
raise VerificationEmailAlreadyClaimed,
|
|
625
|
+
Organizations.t(:"errors.verification_email_already_claimed")
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
request.assign_attributes(
|
|
629
|
+
joined_via: "domain_email",
|
|
630
|
+
verification_email: email,
|
|
631
|
+
verification_email_normalized: normalized,
|
|
632
|
+
verified_at: Time.current,
|
|
633
|
+
metadata: (request.metadata || {}).merge("matched_domain_id" => matched_domain.id)
|
|
634
|
+
)
|
|
635
|
+
request.save!
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
request.approve!(decided_by: nil)
|
|
639
|
+
end
|
|
640
|
+
|
|
373
641
|
private
|
|
374
642
|
|
|
643
|
+
def ensure_join_request_belongs_here!(join_request)
|
|
644
|
+
return if join_request.organization_id == id
|
|
645
|
+
|
|
646
|
+
raise ArgumentError, "Join request does not belong to this organization"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def duplicate_allowlist_error?(error)
|
|
650
|
+
return true if error.is_a?(ActiveRecord::RecordNotUnique)
|
|
651
|
+
|
|
652
|
+
error.is_a?(ActiveRecord::RecordInvalid) &&
|
|
653
|
+
error.record.is_a?(Organizations::AllowlistEntry) &&
|
|
654
|
+
error.record.errors.of_kind?(:email_normalized, :taken)
|
|
655
|
+
end
|
|
656
|
+
|
|
375
657
|
# Defense in depth for organization-centric API usage.
|
|
376
658
|
# The user-level API already checks this, but direct calls to `org.send_invite_to!`
|
|
377
659
|
# must enforce membership and invite permission as well.
|
|
@@ -380,7 +662,7 @@ module Organizations
|
|
|
380
662
|
|
|
381
663
|
unless inviter_membership
|
|
382
664
|
raise Organizations::NotAMember.new(
|
|
383
|
-
"
|
|
665
|
+
Organizations.t(:"errors.invite_not_a_member"),
|
|
384
666
|
organization: self,
|
|
385
667
|
user: inviter
|
|
386
668
|
)
|
|
@@ -389,7 +671,7 @@ module Organizations
|
|
|
389
671
|
return if Roles.has_permission?(inviter_membership.role.to_sym, :invite_members)
|
|
390
672
|
|
|
391
673
|
raise Organizations::NotAuthorized.new(
|
|
392
|
-
|
|
674
|
+
Organizations.t(:"errors.invite_not_authorized"),
|
|
393
675
|
permission: :invite_members,
|
|
394
676
|
organization: self,
|
|
395
677
|
user: inviter
|
|
@@ -432,3 +714,19 @@ module Organizations
|
|
|
432
714
|
end
|
|
433
715
|
end
|
|
434
716
|
end
|
|
717
|
+
|
|
718
|
+
# HOST EXTENSION SEAM (pay-gem style load hooks): fires on EVERY load of this
|
|
719
|
+
# file — including Zeitwerk reloads via the app/models shims — so extensions
|
|
720
|
+
# registered with ActiveSupport.on_load survive code reloading in development.
|
|
721
|
+
# A bare `Organizations::Organization.class_eval` in an initializer runs ONCE
|
|
722
|
+
# and evaporates on the first reload (the shim `load`s a fresh class object);
|
|
723
|
+
# a `to_prepare` block works but is one shared basket — one bad constant in
|
|
724
|
+
# it silently kills every extension after it. Load hooks are per-model and
|
|
725
|
+
# re-run automatically. Register in an initializer:
|
|
726
|
+
#
|
|
727
|
+
# ActiveSupport.on_load(:organizations_organization) do
|
|
728
|
+
# include OrganizationExtensions # `self` is Organizations::Organization
|
|
729
|
+
# end
|
|
730
|
+
#
|
|
731
|
+
# Docs: https://api.rubyonrails.org/classes/ActiveSupport/LazyLoadHooks.html
|
|
732
|
+
ActiveSupport.run_load_hooks(:organizations_organization, Organizations::Organization)
|