mirror of
https://github.com/we-promise/sure.git
synced 2026-07-26 11:42:16 +00:00
* fix: share family accounts with invited members on every sign-up path A user who joined an existing family through an invitation was added to the family but saw none of its accounts, even when the family's default sharing is "share with all members". Only Invitation#accept_for created the AccountShare records; the OIDC just-in-time sign-up, invite-token registration, and mobile SSO onboarding paths all skipped it, so invitees landed in the family with an empty account list. Signups routed into an invite-only default family had the same gap. Extract the sharing into Family#auto_share_existing_accounts_with, the single entry point for "a member just joined, apply the family's sharing policy". It honors default_account_sharing, only shares with a persisted member of the family, grants read_only to guests and read_write to everyone else, excludes accounts the user already owns, and is idempotent. accept_for and every sign-up path call it, so no current or future join path can reintroduce the empty-account bug or share accounts across families. Also wrap the two SSO account-creation paths (OIDC JIT and mobile SSO) in a transaction covering the user save, invitation acceptance, account sharing, and identity creation, so a failure partway through can no longer leave a half-onboarded user with no linked identity. * address review: guest read_only symmetric in auto_share_with_family!, load-bearing guard comment, mobile SSO private-no-op test
133 lines
4.1 KiB
Ruby
133 lines
4.1 KiB
Ruby
class Invitation < ApplicationRecord
|
|
include Encryptable
|
|
|
|
belongs_to :family
|
|
belongs_to :inviter, class_name: "User"
|
|
|
|
# Encrypt sensitive fields if ActiveRecord encryption is configured
|
|
if encryption_ready?
|
|
encrypts :token, deterministic: true
|
|
encrypts :email, deterministic: true, downcase: true
|
|
end
|
|
|
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
validates :role, presence: true, inclusion: { in: %w[admin member guest] }
|
|
validates :token, presence: true, uniqueness: true
|
|
validate :no_duplicate_pending_invitation_in_family
|
|
validate :inviter_is_admin
|
|
validate :no_other_pending_invitation, on: :create
|
|
|
|
before_validation :normalize_email
|
|
before_validation :generate_token, on: :create
|
|
before_create :remove_expired_duplicates_in_family
|
|
before_create :set_expiration
|
|
|
|
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
|
|
scope :accepted, -> { where.not(accepted_at: nil) }
|
|
|
|
def pending?
|
|
accepted_at.nil? && expires_at > Time.current
|
|
end
|
|
|
|
def accept_for(user)
|
|
return false if user.blank?
|
|
return false unless pending?
|
|
return false unless emails_match?(user)
|
|
return false if would_orphan_owned_accounts?(user)
|
|
|
|
transaction do
|
|
user.update!(family_id: family_id, role: role.to_s)
|
|
update!(accepted_at: Time.current)
|
|
family.auto_share_existing_accounts_with(user)
|
|
end
|
|
true
|
|
end
|
|
|
|
def would_orphan_owned_accounts?(user)
|
|
return false if user.blank?
|
|
return false if user.family_id.blank?
|
|
return false if user.family_id == family_id
|
|
|
|
user.owned_accounts.where.not(family_id: family_id).exists?
|
|
end
|
|
|
|
private
|
|
|
|
def emails_match?(user)
|
|
inv_email = email.to_s.strip.downcase
|
|
usr_email = user.email.to_s.strip.downcase
|
|
inv_email.present? && usr_email.present? && inv_email == usr_email
|
|
end
|
|
|
|
def generate_token
|
|
loop do
|
|
self.token = SecureRandom.hex(32)
|
|
break unless self.class.exists?(token: token)
|
|
end
|
|
end
|
|
|
|
def set_expiration
|
|
self.expires_at = 3.days.from_now
|
|
end
|
|
|
|
# An expired, never-accepted invitation still occupies the partial unique
|
|
# index `index_invitations_on_email_and_family_id_pending` (predicate
|
|
# `accepted_at IS NULL`), but the `pending` scope treats it as gone because
|
|
# of the `expires_at > now` clause. The duplicate-guard validation therefore
|
|
# passes, and the INSERT collides with the index, raising RecordNotUnique.
|
|
# Removing the stale row inside the create transaction frees the slot so the
|
|
# re-invite can proceed. It runs only after validations pass, so a still
|
|
# pending (non-expired) invitation can never be removed here.
|
|
def remove_expired_duplicates_in_family
|
|
return if email.blank?
|
|
|
|
scope = self.class.where(family_id: family_id, accepted_at: nil)
|
|
.where("expires_at <= ?", Time.current)
|
|
|
|
scope = if self.class.encryption_ready?
|
|
scope.where(email: email)
|
|
else
|
|
scope.where("LOWER(email) = ?", email.to_s.strip.downcase)
|
|
end
|
|
|
|
scope.delete_all
|
|
end
|
|
|
|
def normalize_email
|
|
self.email = email.to_s.strip.downcase if email.present?
|
|
end
|
|
|
|
def no_other_pending_invitation
|
|
return if email.blank?
|
|
|
|
existing = if self.class.encryption_ready?
|
|
self.class.pending.where(email: email).where.not(family_id: family_id).exists?
|
|
else
|
|
self.class.pending.where("LOWER(email) = ?", email.downcase).where.not(family_id: family_id).exists?
|
|
end
|
|
|
|
if existing
|
|
errors.add(:email, "already has a pending invitation from another family")
|
|
end
|
|
end
|
|
|
|
def no_duplicate_pending_invitation_in_family
|
|
return if email.blank?
|
|
|
|
scope = self.class.pending.where(family_id: family_id)
|
|
scope = scope.where.not(id: id) if persisted?
|
|
|
|
exists = if self.class.encryption_ready?
|
|
scope.where(email: email).exists?
|
|
else
|
|
scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists?
|
|
end
|
|
|
|
errors.add(:email, "has already been invited to this family") if exists
|
|
end
|
|
|
|
def inviter_is_admin
|
|
inviter.admin?
|
|
end
|
|
end
|