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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# The controller-facing facade over verified joining: ONE call that says
|
|
5
|
+
# "this user is trying to join this organization with whatever they gave
|
|
6
|
+
# us", returning a Result instead of raising — so host controllers render
|
|
7
|
+
# states instead of writing ten-branch rescue ladders.
|
|
8
|
+
#
|
|
9
|
+
# The model APIs (JoinCode.redeem, JoinRequest#start_email_verification!,
|
|
10
|
+
# …) remain the exception-raising programmatic layer; JoinFlow is the skin
|
|
11
|
+
# every join UI ends up needing. Before this existed, the first production
|
|
12
|
+
# host's "JoinService" was ~60% this exact translation, rewritten by hand.
|
|
13
|
+
#
|
|
14
|
+
# Dispatch order (first present input wins):
|
|
15
|
+
# verification_code → verify the emailed 6-digit code
|
|
16
|
+
# code → redeem a join code (PIN)
|
|
17
|
+
# email → start/restart the emailed challenge for an address
|
|
18
|
+
# (none) → confirmed-account-email shortcut when eligible,
|
|
19
|
+
# else a plain request-to-join
|
|
20
|
+
#
|
|
21
|
+
# @example A join endpoint in three lines
|
|
22
|
+
# result = Organizations::JoinFlow.attempt(
|
|
23
|
+
# user: current_user, organization: @org,
|
|
24
|
+
# code: params[:code], email: params[:email], message: params[:message]
|
|
25
|
+
# )
|
|
26
|
+
# result.member? ? redirect_to(@org) : render_state(result)
|
|
27
|
+
#
|
|
28
|
+
# Result contract:
|
|
29
|
+
# outcome — :member | :challenge_sent | :pending | :vetoed | :error
|
|
30
|
+
# reason — nil on success; on :vetoed/:error a stable symbol from
|
|
31
|
+
# REASONS (build your copy off this, never off the message)
|
|
32
|
+
# message — localized human string (the caught error's message, already
|
|
33
|
+
# resolved through the gem's i18n catalog — override per key
|
|
34
|
+
# in your locale files, or ignore it and map reason yourself)
|
|
35
|
+
# membership / join_request — the record backing the outcome
|
|
36
|
+
#
|
|
37
|
+
# ⚠️ SECURITY (host responsibilities that CANNOT live in the gem):
|
|
38
|
+
# - Rate-limit the endpoints that call this (code redemption and
|
|
39
|
+
# verification are enumeration surfaces) — see README "Rate limiting
|
|
40
|
+
# your join endpoints".
|
|
41
|
+
# - :join_code_invalid deliberately covers unknown, revoked, expired AND
|
|
42
|
+
# foreign-organization codes with one reason — never tell users which
|
|
43
|
+
# codes exist. (Known narrow residual: response SHAPE is identical, but
|
|
44
|
+
# an expired org-scoped code does slightly more work than the
|
|
45
|
+
# fast-reject paths — it reaches redeem!'s row lock before raising — a
|
|
46
|
+
# timing side-channel your endpoint rate limits should render moot.)
|
|
47
|
+
class JoinFlow
|
|
48
|
+
# Stable machine-readable reasons a host can switch on.
|
|
49
|
+
REASONS = %i[
|
|
50
|
+
join_code_invalid
|
|
51
|
+
join_code_exhausted
|
|
52
|
+
email_not_eligible
|
|
53
|
+
email_already_claimed
|
|
54
|
+
throttled
|
|
55
|
+
verification_code_invalid
|
|
56
|
+
verification_code_expired
|
|
57
|
+
verification_code_missing
|
|
58
|
+
verification_attempts_exceeded
|
|
59
|
+
request_closed
|
|
60
|
+
not_accepting_requests
|
|
61
|
+
membership_vetoed
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
Result = Struct.new(:outcome, :reason, :membership, :join_request, :message, keyword_init: true) do
|
|
65
|
+
def member? = outcome == :member
|
|
66
|
+
def challenge_sent? = outcome == :challenge_sent
|
|
67
|
+
def pending? = outcome == :pending
|
|
68
|
+
def vetoed? = outcome == :vetoed
|
|
69
|
+
def error? = outcome == :error
|
|
70
|
+
# Anything that should render as a problem state (veto included).
|
|
71
|
+
def failed? = error? || vetoed?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param user [User] the joining user (required)
|
|
75
|
+
# @param organization [Organizations::Organization] the org being joined
|
|
76
|
+
# (required — codes from OTHER organizations resolve to
|
|
77
|
+
# :join_code_invalid on purpose; for organization-less global
|
|
78
|
+
# redemption call Organizations::JoinCode.redeem directly)
|
|
79
|
+
# @param code [String, nil] a join code (PIN) as typed
|
|
80
|
+
# @param email [String, nil] address for the emailed challenge
|
|
81
|
+
# @param verification_code [String, nil] the emailed 6-digit code as typed
|
|
82
|
+
# @param message [String, nil] optional note for manual approval requests
|
|
83
|
+
# @return [Result]
|
|
84
|
+
# Every parameter is an optional keyword with a safe default — this IS
|
|
85
|
+
# the public API surface, not incidental complexity (same posture as
|
|
86
|
+
# Organization#generate_join_code!).
|
|
87
|
+
# rubocop:disable Metrics/ParameterLists
|
|
88
|
+
def self.attempt(user:, organization:, code: nil, email: nil, verification_code: nil, message: nil)
|
|
89
|
+
new(user: user, organization: organization)
|
|
90
|
+
.attempt(code: code, email: email, verification_code: verification_code, message: message)
|
|
91
|
+
end
|
|
92
|
+
# rubocop:enable Metrics/ParameterLists
|
|
93
|
+
|
|
94
|
+
def initialize(user:, organization:)
|
|
95
|
+
@user = user
|
|
96
|
+
@organization = organization
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def attempt(code: nil, email: nil, verification_code: nil, message: nil)
|
|
100
|
+
# Already a member: every input resolves to the same quiet success —
|
|
101
|
+
# never consume a code use or mint a request for someone who's in.
|
|
102
|
+
if (existing = organization.memberships.find_by(user_id: user.id))
|
|
103
|
+
return Result.new(outcome: :member, membership: existing)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return verify_emailed_code(verification_code) if verification_code.present?
|
|
107
|
+
return redeem_code(code) if code.present?
|
|
108
|
+
return start_challenge(email) if email.present?
|
|
109
|
+
return account_email_shortcut if account_email_eligible?
|
|
110
|
+
|
|
111
|
+
plain_request(message)
|
|
112
|
+
rescue Organizations::MembershipVetoed => e
|
|
113
|
+
# The host's on_member_joining gate said no — a first-class outcome,
|
|
114
|
+
# not a generic error (hosts usually show cap-specific copy + support).
|
|
115
|
+
Result.new(outcome: :vetoed, reason: :membership_vetoed, message: e.message)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
attr_reader :user, :organization
|
|
121
|
+
|
|
122
|
+
def verify_emailed_code(typed_code)
|
|
123
|
+
request = user.pending_join_request_for(organization)
|
|
124
|
+
return error(:verification_code_missing, Organizations.t(:"errors.verification_code_missing")) unless request
|
|
125
|
+
|
|
126
|
+
from_outcome(request.verify_email_code!(typed_code))
|
|
127
|
+
rescue Organizations::VerificationAttemptsExceeded => e
|
|
128
|
+
error(:verification_attempts_exceeded, e.message)
|
|
129
|
+
rescue Organizations::VerificationCodeExpired => e
|
|
130
|
+
error(:verification_code_expired, e.message)
|
|
131
|
+
rescue Organizations::VerificationCodeInvalid => e
|
|
132
|
+
error(:verification_code_invalid, e.message)
|
|
133
|
+
rescue Organizations::VerificationEmailAlreadyClaimed => e
|
|
134
|
+
error(:email_already_claimed, e.message)
|
|
135
|
+
rescue Organizations::JoinRequestError => e
|
|
136
|
+
error(:request_closed, e.message)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def redeem_code(raw_code)
|
|
140
|
+
normalized = Organizations::JoinCode.normalize(raw_code)
|
|
141
|
+
join_code = normalized.present? && Organizations::JoinCode.not_revoked.find_by(code: normalized)
|
|
142
|
+
|
|
143
|
+
# One reason for unknown/revoked/expired/foreign codes — the code
|
|
144
|
+
# endpoint is the enumeration surface; never reveal which codes exist
|
|
145
|
+
# or whose they are.
|
|
146
|
+
unless join_code && join_code.organization_id == organization.id
|
|
147
|
+
return error(:join_code_invalid, Organizations.t(:"errors.join_code_invalid"))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
from_outcome(join_code.redeem!(user: user))
|
|
151
|
+
rescue Organizations::JoinCodeExhausted => e
|
|
152
|
+
# NOTE: rescued before JoinCodeInvalid — it's a subclass.
|
|
153
|
+
error(:join_code_exhausted, e.message)
|
|
154
|
+
rescue Organizations::JoinCodeInvalid => e
|
|
155
|
+
error(:join_code_invalid, e.message)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def start_challenge(email)
|
|
159
|
+
request = user.pending_join_request_for(organization) || user.request_to_join!(organization)
|
|
160
|
+
request.start_email_verification!(email: email)
|
|
161
|
+
|
|
162
|
+
Result.new(outcome: :challenge_sent, join_request: request)
|
|
163
|
+
rescue Organizations::VerificationEmailNotEligible => e
|
|
164
|
+
error(:email_not_eligible, e.message)
|
|
165
|
+
rescue Organizations::VerificationEmailAlreadyClaimed => e
|
|
166
|
+
error(:email_already_claimed, e.message)
|
|
167
|
+
rescue Organizations::VerificationThrottled => e
|
|
168
|
+
error(:throttled, e.message)
|
|
169
|
+
rescue Organizations::JoinRequestError => e
|
|
170
|
+
error(:request_closed, e.message)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def account_email_shortcut
|
|
174
|
+
from_outcome(organization.join_with_account_email!(user))
|
|
175
|
+
rescue Organizations::VerificationEmailAlreadyClaimed => e
|
|
176
|
+
error(:email_already_claimed, e.message)
|
|
177
|
+
rescue Organizations::VerificationEmailNotEligible
|
|
178
|
+
# Eligibility changed between the pre-check and the call (domain
|
|
179
|
+
# removed, trust flipped) — degrade to the plain request path.
|
|
180
|
+
plain_request(nil)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def plain_request(message)
|
|
184
|
+
unless organization.accepts_join_requests?
|
|
185
|
+
return error(:not_accepting_requests, Organizations.t(:"errors.not_accepting_requests"))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
from_outcome(user.request_to_join!(organization, message: message))
|
|
189
|
+
rescue Organizations::JoinRequestError => e
|
|
190
|
+
error(:request_closed, e.message)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def account_email_eligible?
|
|
194
|
+
Organizations.configuration.trust_confirmed_account_email &&
|
|
195
|
+
user.respond_to?(:confirmed_at) && user.confirmed_at.present? &&
|
|
196
|
+
user.respond_to?(:email) &&
|
|
197
|
+
organization.domains.matching_email(user.email).exists?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def from_outcome(outcome)
|
|
201
|
+
case outcome
|
|
202
|
+
when Organizations::Membership
|
|
203
|
+
Result.new(outcome: :member, membership: outcome)
|
|
204
|
+
when Organizations::JoinRequest
|
|
205
|
+
if outcome.verification_sent_at.present? && !outcome.email_verified?
|
|
206
|
+
Result.new(outcome: :challenge_sent, join_request: outcome)
|
|
207
|
+
else
|
|
208
|
+
Result.new(outcome: :pending, join_request: outcome)
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
error(:request_closed, Organizations.t(:"errors.join_request_create_failed"))
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def error(reason, message)
|
|
216
|
+
# Developer guard (review finding): REASONS is a published contract
|
|
217
|
+
# hosts switch on — a typo'd symbol here would silently ship a reason
|
|
218
|
+
# nobody's copy mapping knows. (Hosts building their OWN Results may
|
|
219
|
+
# use their own symbols; this guards the gem's internal emissions.)
|
|
220
|
+
unless REASONS.include?(reason)
|
|
221
|
+
raise ArgumentError, "unknown JoinFlow reason #{reason.inspect} — add it to JoinFlow::REASONS"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
Result.new(outcome: :error, reason: reason, message: message)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# The headless state machine behind any "join this organization" screen —
|
|
5
|
+
# BYO-UI in its truest form: the gem ships the STATE, the host ships the
|
|
6
|
+
# pixels. One object answers "which state is this screen in" so a page
|
|
7
|
+
# body, a pinned CTA, and a Turbo Stream response can never disagree
|
|
8
|
+
# (splitting these predicates across partials is exactly how a submit
|
|
9
|
+
# button ends up pointing at a form that no longer exists — a bug the
|
|
10
|
+
# first production host paid for before extracting this).
|
|
11
|
+
#
|
|
12
|
+
# :member — the viewer belongs; show the success/hub state
|
|
13
|
+
# :verifying — an emailed-code challenge is in flight; show the 6-digit
|
|
14
|
+
# input (+ resend with #resend_seconds cooldown)
|
|
15
|
+
# :pending — a request awaits manual approval; show the waiting state
|
|
16
|
+
# :entry — no relationship yet; show the join form(s) the org's
|
|
17
|
+
# instruments allow (organization.accepts_domain_joining? /
|
|
18
|
+
# accepts_code_joining? / accepts_join_requests?)
|
|
19
|
+
#
|
|
20
|
+
# @example In a controller
|
|
21
|
+
# @result = Organizations::JoinFlow.attempt(user: current_user, organization: @org, **join_params)
|
|
22
|
+
# @state = Organizations::JoinState.for(user: current_user, organization: @org, result: @result)
|
|
23
|
+
#
|
|
24
|
+
# @example In the view
|
|
25
|
+
# case @state.status
|
|
26
|
+
# when :member then render "joined"
|
|
27
|
+
# when :verifying then render "code_input", seconds: @state.resend_seconds
|
|
28
|
+
# when :pending then render "waiting"
|
|
29
|
+
# when :entry then render "join_form"
|
|
30
|
+
# end
|
|
31
|
+
class JoinState
|
|
32
|
+
STATUSES = %i[member verifying pending entry].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :organization, :membership, :join_request, :result
|
|
35
|
+
|
|
36
|
+
# Build the state for a viewer. Pass the JoinFlow::Result of the action
|
|
37
|
+
# that JUST ran (nil on a plain GET): its records are fresher than any
|
|
38
|
+
# association cache — right after a successful verify, the new membership
|
|
39
|
+
# lives on the result, not necessarily in a reloaded association.
|
|
40
|
+
#
|
|
41
|
+
# @param user [User]
|
|
42
|
+
# @param organization [Organizations::Organization]
|
|
43
|
+
# @param result [Organizations::JoinFlow::Result, nil]
|
|
44
|
+
# @return [JoinState]
|
|
45
|
+
def self.for(user:, organization:, result: nil)
|
|
46
|
+
new(
|
|
47
|
+
organization: organization,
|
|
48
|
+
membership: result&.membership || organization.memberships.find_by(user_id: user.id),
|
|
49
|
+
join_request: result&.join_request || user.pending_join_request_for(organization),
|
|
50
|
+
result: result
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(organization:, membership:, join_request:, result: nil)
|
|
55
|
+
@organization = organization
|
|
56
|
+
@membership = membership
|
|
57
|
+
@join_request = join_request
|
|
58
|
+
@result = result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Symbol] one of STATUSES
|
|
62
|
+
def status
|
|
63
|
+
return :member if member?
|
|
64
|
+
return :verifying if verifying?
|
|
65
|
+
return :pending if pending?
|
|
66
|
+
|
|
67
|
+
:entry
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def member?
|
|
71
|
+
membership.present? || result&.member?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def verifying?
|
|
75
|
+
return false if member?
|
|
76
|
+
return true if result&.challenge_sent?
|
|
77
|
+
|
|
78
|
+
challenge.present? && challenge.verification_sent_at.present? && !challenge.email_verified?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pending?
|
|
82
|
+
return false if member? || verifying?
|
|
83
|
+
|
|
84
|
+
join_request.present? || result&.pending?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def entry?
|
|
88
|
+
status == :entry
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The join request whose emailed challenge is being answered (feeds the
|
|
92
|
+
# verify form and #resend_seconds).
|
|
93
|
+
# @return [Organizations::JoinRequest, nil]
|
|
94
|
+
def challenge
|
|
95
|
+
result&.join_request || join_request
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Seconds until the gem will accept another code send for this challenge
|
|
99
|
+
# — drive a client-side resend cooldown from this instead of mirroring
|
|
100
|
+
# config.verification_resend_interval as a magic number in the host
|
|
101
|
+
# (mirrored constants desync the day someone tunes the config).
|
|
102
|
+
# @return [Integer] 0 when a resend is allowed now
|
|
103
|
+
def resend_seconds
|
|
104
|
+
sent_at = challenge&.verification_sent_at
|
|
105
|
+
return 0 if sent_at.blank?
|
|
106
|
+
|
|
107
|
+
interval = Organizations.configuration.verification_resend_interval.to_i
|
|
108
|
+
[interval - (Time.current - sent_at).to_i, 0].max
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Localized message of a just-failed action, nil otherwise.
|
|
112
|
+
# @return [String, nil]
|
|
113
|
+
def error_message
|
|
114
|
+
result&.message if result&.failed?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Typed boolean accessors over a json(b) metadata bag, with a DEFAULT for
|
|
5
|
+
# missing keys — the pattern every host grows around the gem's metadata
|
|
6
|
+
# channel ("show this membership on the profile?", "receive digests?").
|
|
7
|
+
#
|
|
8
|
+
# Why not store_accessor: store_accessor gives raw reads — a nil (never
|
|
9
|
+
# set) is indistinguishable from false, so hosts hand-roll the same
|
|
10
|
+
# `value.nil? ? default : Boolean.cast(value)` predicate for every flag
|
|
11
|
+
# (one production host had three identical copies). metadata_flag bakes
|
|
12
|
+
# the default + cast in.
|
|
13
|
+
#
|
|
14
|
+
# Organizations::Organization and Organizations::Membership are already
|
|
15
|
+
# extended with this — call the macro from your extension concern:
|
|
16
|
+
#
|
|
17
|
+
# ActiveSupport.on_load(:organizations_membership) do
|
|
18
|
+
# metadata_flag :show_on_profile, default: true
|
|
19
|
+
# metadata_flag :show_on_leaderboard, default: true
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# membership.show_on_profile? # => true (unset ⇒ the default)
|
|
23
|
+
# membership.show_on_profile = false # writes into metadata
|
|
24
|
+
# membership.toggle_show_on_profile! # flip + save!
|
|
25
|
+
#
|
|
26
|
+
# ⚠️ Don't ALSO declare the same key via store_accessor — you'd get two
|
|
27
|
+
# writers with different semantics for one key. Pick one mechanism per key.
|
|
28
|
+
#
|
|
29
|
+
# @example A flag over a different bag column
|
|
30
|
+
# metadata_flag :beta_features, default: false, column: :settings
|
|
31
|
+
module MetadataFlags
|
|
32
|
+
# @param name [Symbol] flag name (becomes name?, name=, toggle_name!)
|
|
33
|
+
# @param default [Boolean] value when the key is absent from the bag
|
|
34
|
+
# @param column [Symbol] the json(b) attribute holding the bag
|
|
35
|
+
def metadata_flag(name, default:, column: :metadata)
|
|
36
|
+
define_metadata_flag_reader(name, default, column)
|
|
37
|
+
define_metadata_flag_writer(name, column)
|
|
38
|
+
define_metadata_flag_toggle(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def define_metadata_flag_reader(name, default, column)
|
|
44
|
+
key = name.to_s
|
|
45
|
+
define_method("#{name}?") do
|
|
46
|
+
bag = public_send(column)
|
|
47
|
+
# Defensive: a non-Hash bag (text column, corrupted value) reads as
|
|
48
|
+
# empty → the default. The WRITER requires a real json(b)/serialized
|
|
49
|
+
# Hash column — see the module docs.
|
|
50
|
+
raw = bag.is_a?(Hash) ? bag[key] : nil
|
|
51
|
+
raw.nil? ? default : ActiveModel::Type::Boolean.new.cast(raw)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def define_metadata_flag_writer(name, column)
|
|
56
|
+
key = name.to_s
|
|
57
|
+
define_method("#{name}=") do |value|
|
|
58
|
+
bag = (public_send(column) || {}).merge(key => value)
|
|
59
|
+
public_send("#{column}=", bag)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def define_metadata_flag_toggle(name)
|
|
64
|
+
define_method("toggle_#{name}!") do
|
|
65
|
+
# with_lock (review finding): a bare read-negate-save! races under
|
|
66
|
+
# concurrent toggles (classic read-modify-write on a JSON column).
|
|
67
|
+
# The lock reloads, so the negation applies to the CURRENT database
|
|
68
|
+
# value — two racing toggles serialize instead of both writing the
|
|
69
|
+
# same flip. New (unpersisted) records just assign.
|
|
70
|
+
flip = lambda do
|
|
71
|
+
public_send("#{name}=", !public_send("#{name}?"))
|
|
72
|
+
save!
|
|
73
|
+
end
|
|
74
|
+
persisted? ? with_lock(&flip) : flip.call
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# A pre-approved email address on an organization's roster/allowlist,
|
|
5
|
+
# used for verified joining when the organization has no email domain
|
|
6
|
+
# of its own (clubs, associations, mixed-provider orgs).
|
|
7
|
+
#
|
|
8
|
+
# A user who proves control of a rostered inbox (emailed code) becomes a
|
|
9
|
+
# verified member and the entry is marked claimed. Proof-of-control is
|
|
10
|
+
# still required — a leaked roster must never grant membership without
|
|
11
|
+
# inbox access.
|
|
12
|
+
#
|
|
13
|
+
# Entries are typically bulk-imported: see Organization#import_allowlist!.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# org.import_allowlist!(["ana@gmail.com", "luis@yahoo.es"], source: "csv_2026-07")
|
|
17
|
+
#
|
|
18
|
+
class AllowlistEntry < ActiveRecord::Base
|
|
19
|
+
self.table_name = "organizations_allowlist_entries"
|
|
20
|
+
|
|
21
|
+
# === Associations ===
|
|
22
|
+
|
|
23
|
+
belongs_to :organization,
|
|
24
|
+
class_name: "Organizations::Organization",
|
|
25
|
+
inverse_of: :allowlist_entries
|
|
26
|
+
|
|
27
|
+
belongs_to :claimed_by,
|
|
28
|
+
class_name: Organizations.user_class_name,
|
|
29
|
+
optional: true
|
|
30
|
+
|
|
31
|
+
# === Validations ===
|
|
32
|
+
|
|
33
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
34
|
+
# Proc message: resolved at VALIDATION time so it follows I18n.locale.
|
|
35
|
+
validates :email_normalized, presence: true,
|
|
36
|
+
uniqueness: { scope: :organization_id,
|
|
37
|
+
message: ->(*) { Organizations.t(:"attributes.allowlist_taken") } }
|
|
38
|
+
|
|
39
|
+
# === Callbacks ===
|
|
40
|
+
|
|
41
|
+
before_validation :normalize_email
|
|
42
|
+
|
|
43
|
+
# === Scopes ===
|
|
44
|
+
|
|
45
|
+
# Entries not yet consumed by a membership
|
|
46
|
+
scope :unclaimed, -> { where(claimed_at: nil) }
|
|
47
|
+
|
|
48
|
+
# Entries matching an email (through the configured normalizer)
|
|
49
|
+
scope :for_email, lambda { |email|
|
|
50
|
+
where(email_normalized: Organizations.configuration.normalize_verification_email(email))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# === Status ===
|
|
54
|
+
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def claimed?
|
|
57
|
+
claimed_at.present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Mark this entry as consumed by a user's membership.
|
|
61
|
+
# Idempotent: claiming an already-claimed entry is a no-op.
|
|
62
|
+
# @param user [User]
|
|
63
|
+
# @return [self]
|
|
64
|
+
def claim!(user)
|
|
65
|
+
return self if claimed?
|
|
66
|
+
|
|
67
|
+
with_lock do
|
|
68
|
+
break if claimed?
|
|
69
|
+
|
|
70
|
+
update!(claimed_at: Time.current, claimed_by_id: user.id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def normalize_email
|
|
79
|
+
return if email.blank?
|
|
80
|
+
|
|
81
|
+
self.email = email.to_s.strip
|
|
82
|
+
self.email_normalized = Organizations.configuration.normalize_verification_email(email)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
88
|
+
ActiveSupport.run_load_hooks(:organizations_allowlist_entry, Organizations::AllowlistEntry)
|
|
@@ -25,12 +25,15 @@ module Organizations
|
|
|
25
25
|
module HasOrganizations
|
|
26
26
|
extend ActiveSupport::Concern
|
|
27
27
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
class
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
# Historical aliases — the CANONICAL constants live at Organizations::
|
|
29
|
+
# top level since 0.5.0 (rescue Organizations::CannotLeaveAsLastOwner,
|
|
30
|
+
# not this four-modules-deep path). Same class objects, so existing
|
|
31
|
+
# rescues of the nested paths keep working.
|
|
32
|
+
OrganizationLimitReached = Organizations::OrganizationLimitReached
|
|
33
|
+
CannotLeaveLastOrganization = Organizations::CannotLeaveLastOrganization
|
|
34
|
+
CannotLeaveAsLastOwner = Organizations::CannotLeaveAsLastOwner
|
|
35
|
+
CannotDeleteAsOrganizationOwner = Organizations::CannotDeleteAsOrganizationOwner
|
|
36
|
+
NoCurrentOrganization = Organizations::NoCurrentOrganization
|
|
34
37
|
|
|
35
38
|
# Module containing class methods to be extended onto ActiveRecord::Base
|
|
36
39
|
module ClassMethods
|
|
@@ -86,6 +89,38 @@ module Organizations
|
|
|
86
89
|
foreign_key: :invited_by_id,
|
|
87
90
|
inverse_of: :invited_by,
|
|
88
91
|
dependent: :nullify
|
|
92
|
+
|
|
93
|
+
define_verified_joining_associations
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Verified-joining (v0.5.0) associations, split out for readability
|
|
97
|
+
def define_verified_joining_associations
|
|
98
|
+
# This user's join requests (request-to-join workflow)
|
|
99
|
+
has_many :organization_join_requests,
|
|
100
|
+
class_name: "Organizations::JoinRequest",
|
|
101
|
+
foreign_key: :user_id,
|
|
102
|
+
inverse_of: :user,
|
|
103
|
+
dependent: :destroy
|
|
104
|
+
|
|
105
|
+
# Trails this user leaves as an actor on other people's records —
|
|
106
|
+
# nullified on deletion, same as sent_organization_invitations
|
|
107
|
+
has_many :decided_organization_join_requests,
|
|
108
|
+
class_name: "Organizations::JoinRequest",
|
|
109
|
+
foreign_key: :decided_by_id,
|
|
110
|
+
inverse_of: :decided_by,
|
|
111
|
+
dependent: :nullify
|
|
112
|
+
|
|
113
|
+
has_many :created_organization_join_codes,
|
|
114
|
+
class_name: "Organizations::JoinCode",
|
|
115
|
+
foreign_key: :created_by_id,
|
|
116
|
+
inverse_of: :created_by,
|
|
117
|
+
dependent: :nullify
|
|
118
|
+
|
|
119
|
+
has_many :claimed_organization_allowlist_entries,
|
|
120
|
+
class_name: "Organizations::AllowlistEntry",
|
|
121
|
+
foreign_key: :claimed_by_id,
|
|
122
|
+
inverse_of: :claimed_by,
|
|
123
|
+
dependent: :nullify
|
|
89
124
|
end
|
|
90
125
|
|
|
91
126
|
def define_organization_settings(options, &block)
|
|
@@ -401,7 +436,7 @@ module Organizations
|
|
|
401
436
|
settings = self.class.organization_settings
|
|
402
437
|
max = settings[:max_organizations]
|
|
403
438
|
if max && owned_organizations.count >= max
|
|
404
|
-
raise OrganizationLimitReached, "
|
|
439
|
+
raise OrganizationLimitReached, Organizations.t(:"errors.organization_limit_reached", max: max)
|
|
405
440
|
end
|
|
406
441
|
|
|
407
442
|
org = nil
|
|
@@ -445,14 +480,14 @@ module Organizations
|
|
|
445
480
|
if membership.role.to_sym == :owner
|
|
446
481
|
owner_count = org.memberships.where(role: "owner").count
|
|
447
482
|
if owner_count == 1
|
|
448
|
-
raise CannotLeaveAsLastOwner, "
|
|
483
|
+
raise CannotLeaveAsLastOwner, Organizations.t(:"errors.cannot_leave_as_last_owner")
|
|
449
484
|
end
|
|
450
485
|
end
|
|
451
486
|
|
|
452
487
|
# Check require_organization setting
|
|
453
488
|
settings = self.class.organization_settings
|
|
454
489
|
if settings[:require_organization] && organizations.count == 1
|
|
455
|
-
raise CannotLeaveLastOrganization, "
|
|
490
|
+
raise CannotLeaveLastOrganization, Organizations.t(:"errors.cannot_leave_last_organization")
|
|
456
491
|
end
|
|
457
492
|
|
|
458
493
|
membership.destroy!
|
|
@@ -474,7 +509,7 @@ module Organizations
|
|
|
474
509
|
# @raise [NoCurrentOrganization] if no current organization
|
|
475
510
|
def leave_current_organization!
|
|
476
511
|
unless current_organization
|
|
477
|
-
raise NoCurrentOrganization, "
|
|
512
|
+
raise NoCurrentOrganization, Organizations.t(:"errors.no_current_organization_to_leave")
|
|
478
513
|
end
|
|
479
514
|
|
|
480
515
|
leave_organization!(current_organization)
|
|
@@ -498,7 +533,7 @@ module Organizations
|
|
|
498
533
|
user_role = role_in(org)
|
|
499
534
|
unless user_role && Roles.has_permission?(user_role, :invite_members)
|
|
500
535
|
raise Organizations::NotAuthorized.new(
|
|
501
|
-
|
|
536
|
+
Organizations.t(:"errors.invite_not_authorized"),
|
|
502
537
|
permission: :invite_members,
|
|
503
538
|
organization: org,
|
|
504
539
|
user: self
|
|
@@ -508,6 +543,59 @@ module Organizations
|
|
|
508
543
|
org.send_invite_to!(email, invited_by: self, role: role)
|
|
509
544
|
end
|
|
510
545
|
|
|
546
|
+
# === Verified Joining (v0.5.0) ===
|
|
547
|
+
|
|
548
|
+
# Petition to join an organization (request-to-join workflow).
|
|
549
|
+
# Idempotent: returns the existing open request when one exists.
|
|
550
|
+
# Fires the :join_request_created callback for new requests.
|
|
551
|
+
#
|
|
552
|
+
# @param organization [Organizations::Organization]
|
|
553
|
+
# @param message [String, nil] optional note shown to approvers
|
|
554
|
+
# @return [Organizations::JoinRequest]
|
|
555
|
+
#
|
|
556
|
+
# NOTE: `max_organizations` deliberately does NOT apply here — that
|
|
557
|
+
# setting caps orgs a user can OWN. Hosts wanting to cap plain
|
|
558
|
+
# memberships should check before approving (see README).
|
|
559
|
+
def request_to_join!(organization, message: nil)
|
|
560
|
+
existing = pending_join_request_for(organization)
|
|
561
|
+
return existing if existing
|
|
562
|
+
|
|
563
|
+
# A member doesn't need to request — surface their (approved)
|
|
564
|
+
# state instead of creating a nonsense pending row.
|
|
565
|
+
existing_membership = memberships.find_by(organization_id: organization.id)
|
|
566
|
+
if existing_membership
|
|
567
|
+
raise Organizations::JoinRequestAlreadyDecided,
|
|
568
|
+
Organizations.t(:"errors.join_request_already_member")
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# SAVEPOINT: the RecordNotUnique rescue below must stay reachable
|
|
572
|
+
# when a HOST wraps this call in its own transaction (PostgreSQL
|
|
573
|
+
# aborted-transaction semantics — see JoinRequest#create_membership!).
|
|
574
|
+
request = ActiveRecord::Base.transaction(requires_new: true) do
|
|
575
|
+
organization.join_requests.create!(user: self, message: message)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
Callbacks.dispatch(
|
|
579
|
+
:join_request_created,
|
|
580
|
+
organization: organization,
|
|
581
|
+
user: self,
|
|
582
|
+
join_request: request
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
request
|
|
586
|
+
rescue ActiveRecord::RecordNotUnique
|
|
587
|
+
# Race: a concurrent request slipped past the pre-check.
|
|
588
|
+
pending_join_request_for(organization) ||
|
|
589
|
+
raise(Organizations::JoinRequestError, Organizations.t(:"errors.join_request_create_failed"))
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# This user's open request for an organization, if any
|
|
593
|
+
# @param organization [Organizations::Organization]
|
|
594
|
+
# @return [Organizations::JoinRequest, nil]
|
|
595
|
+
def pending_join_request_for(organization)
|
|
596
|
+
organization_join_requests.pending.find_by(organization_id: organization.id)
|
|
597
|
+
end
|
|
598
|
+
|
|
511
599
|
# Extension seam for personal organization creation.
|
|
512
600
|
# Override this method in your User model to implement custom logic.
|
|
513
601
|
#
|
|
@@ -553,7 +641,7 @@ module Organizations
|
|
|
553
641
|
def prevent_deletion_while_owning_organizations
|
|
554
642
|
return unless memberships.where(role: "owner").exists?
|
|
555
643
|
|
|
556
|
-
errors.add(:base, "
|
|
644
|
+
errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
|
|
557
645
|
throw(:abort)
|
|
558
646
|
end
|
|
559
647
|
end
|