Files
sure/app/models/invitation.rb
Josh 97391a27ba Share family accounts with invited members on every sign-up path (#2650)
* 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
2026-07-14 01:39:26 +02:00

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