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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +405 -41
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +8 -12
  60. data/lib/organizations.rb +165 -0
  61. 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
- # Error raised when org limits are exceeded
29
- class OrganizationLimitReached < Organizations::Error; end
30
- class CannotLeaveLastOrganization < Organizations::Error; end
31
- class CannotLeaveAsLastOwner < Organizations::Error; end
32
- class CannotDeleteAsOrganizationOwner < Organizations::Error; end
33
- class NoCurrentOrganization < Organizations::Error; end
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, "Maximum number of organizations (#{max}) reached"
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, "Cannot leave organization as the only owner. Transfer ownership first."
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, "Cannot leave your only organization"
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, "No current organization to leave"
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
- "You don't have permission to invite members",
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, "Cannot delete a user who still owns organizations. Transfer ownership or delete those organizations first.")
644
+ errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
557
645
  throw(:abort)
558
646
  end
559
647
  end