mirror of
https://github.com/discourse/discourse.git
synced 2024-11-28 16:03:59 +08:00
355d51afde
This PR allows invitations to be used when the DiscourseConnect SSO is enabled for a site (`enable_discourse_connect`) and local logins are disabled. Previously invites could not be accepted with SSO enabled simply because we did not have the code paths to handle that logic. The invitation methods that are supported include: * Inviting people to groups via email address * Inviting people to topics via email address * Using invitation links generated by the Invite Users UI in the /my/invited/pending route The flow works like this: 1. User visits an invite URL 2. The normal invitation validations (redemptions/expiry) happen at that point 3. We store the invite key in a secure session 4. The user clicks "Accept Invitation and Continue" (see below) 5. The user is redirected to /session/sso then to the SSO provider URL then back to /session/sso_login 6. We retrieve the invite based on the invite key in secure session. We revalidate the invitation. We show an error to the user if it is not valid. An additional check here for invites with an email specified is to check the SSO email matches the invite email 7. If the invite is OK we create the user via the normal SSO methods 8. We redeem the invite and activate the user. We clear the invite key in secure session. 9. If the invite had a topic we redirect the user there, otherwise we redirect to / Note that we decided for SSO-based invites the `must_approve_users` site setting is ignored, because the invite is a form of pre-approval, and because regular non-staff users cannot send out email invites or generally invite to the forum in this case. Also deletes some group invite checks as per https://github.com/discourse/discourse/pull/12353
294 lines
8.4 KiB
Ruby
294 lines
8.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Invite < ActiveRecord::Base
|
|
class UserExists < StandardError; end
|
|
class RedemptionFailed < StandardError; end
|
|
class ValidationFailed < StandardError; end
|
|
|
|
include RateLimiter::OnCreateRecord
|
|
include Trashable
|
|
|
|
# TODO(2021-05-22): remove
|
|
self.ignored_columns = %w{
|
|
user_id
|
|
redeemed_at
|
|
}
|
|
|
|
BULK_INVITE_EMAIL_LIMIT = 200
|
|
|
|
rate_limit :limit_invites_per_day
|
|
|
|
belongs_to :user
|
|
belongs_to :topic
|
|
belongs_to :invited_by, class_name: 'User'
|
|
|
|
has_many :invited_users
|
|
has_many :users, through: :invited_users
|
|
has_many :invited_groups
|
|
has_many :groups, through: :invited_groups
|
|
has_many :topic_invites
|
|
has_many :topics, through: :topic_invites, source: :topic
|
|
|
|
validates_presence_of :invited_by_id
|
|
validates :email, email: true, allow_blank: true
|
|
validate :ensure_max_redemptions_allowed
|
|
validate :user_doesnt_already_exist
|
|
|
|
before_create do
|
|
self.invite_key ||= SecureRandom.hex
|
|
self.expires_at ||= SiteSetting.invite_expiry_days.days.from_now
|
|
end
|
|
|
|
before_validation do
|
|
self.email = Email.downcase(email) unless email.nil?
|
|
end
|
|
|
|
attr_accessor :email_already_exists
|
|
|
|
def self.emailed_status_types
|
|
@emailed_status_types ||= Enum.new(not_required: 0, pending: 1, bulk_pending: 2, sending: 3, sent: 4)
|
|
end
|
|
|
|
def user_doesnt_already_exist
|
|
@email_already_exists = false
|
|
return if email.blank?
|
|
user = Invite.find_user_by_email(email)
|
|
|
|
if user && user.id != self.invited_users&.first&.user_id
|
|
@email_already_exists = true
|
|
errors.add(:email, I18n.t(
|
|
"invite.user_exists",
|
|
email: email,
|
|
username: user.username,
|
|
base_path: Discourse.base_path
|
|
))
|
|
end
|
|
end
|
|
|
|
def is_invite_link?
|
|
email.blank?
|
|
end
|
|
|
|
def redeemable?
|
|
!redeemed? && !expired? && !destroyed? && link_valid?
|
|
end
|
|
|
|
def redeemed?
|
|
if is_invite_link?
|
|
redemption_count >= max_redemptions_allowed
|
|
else
|
|
self.invited_users.count > 0
|
|
end
|
|
end
|
|
|
|
def expired?
|
|
expires_at < Time.zone.now
|
|
end
|
|
|
|
def link
|
|
"#{Discourse.base_url}/invites/#{invite_key}"
|
|
end
|
|
|
|
def link_valid?
|
|
invalidated_at.nil?
|
|
end
|
|
|
|
def self.generate(invited_by, opts = nil)
|
|
opts ||= {}
|
|
|
|
email = Email.downcase(opts[:email]) if opts[:email].present?
|
|
|
|
if user = find_user_by_email(email)
|
|
raise UserExists.new(I18n.t(
|
|
"invite.user_exists",
|
|
email: email,
|
|
username: user.username,
|
|
base_path: Discourse.base_path
|
|
))
|
|
end
|
|
|
|
if email.present?
|
|
invite = Invite
|
|
.with_deleted
|
|
.where(email: email, invited_by_id: invited_by.id)
|
|
.order('created_at DESC')
|
|
.first
|
|
|
|
if invite && (invite.expired? || invite.deleted_at)
|
|
invite.destroy
|
|
invite = nil
|
|
end
|
|
end
|
|
|
|
emailed_status = if opts[:skip_email] || invite&.emailed_status == emailed_status_types[:not_required]
|
|
emailed_status_types[:not_required]
|
|
elsif opts[:emailed_status].present?
|
|
opts[:emailed_status]
|
|
elsif email.present?
|
|
emailed_status_types[:pending]
|
|
else
|
|
emailed_status_types[:not_required]
|
|
end
|
|
|
|
if invite
|
|
invite.update_columns(
|
|
created_at: Time.zone.now,
|
|
updated_at: Time.zone.now,
|
|
expires_at: opts[:expires_at] || SiteSetting.invite_expiry_days.days.from_now,
|
|
emailed_status: emailed_status
|
|
)
|
|
else
|
|
create_args = opts.slice(:email, :moderator, :custom_message, :max_redemptions_allowed)
|
|
create_args[:invited_by] = invited_by
|
|
create_args[:email] = email
|
|
create_args[:emailed_status] = emailed_status
|
|
create_args[:expires_at] = opts[:expires_at] || SiteSetting.invite_expiry_days.days.from_now
|
|
|
|
invite = Invite.create!(create_args)
|
|
end
|
|
|
|
topic_id = opts[:topic]&.id || opts[:topic_id]
|
|
if topic_id.present?
|
|
invite.topic_invites.find_or_create_by!(topic_id: topic_id)
|
|
end
|
|
|
|
group_ids = opts[:group_ids]
|
|
if group_ids.present?
|
|
group_ids.each do |group_id|
|
|
invite.invited_groups.find_or_create_by!(group_id: group_id)
|
|
end
|
|
end
|
|
|
|
if emailed_status == emailed_status_types[:pending]
|
|
invite.update_column(:emailed_status, emailed_status_types[:sending])
|
|
Jobs.enqueue(:invite_email, invite_id: invite.id, invite_to_topic: opts[:invite_to_topic])
|
|
end
|
|
|
|
invite.reload
|
|
end
|
|
|
|
def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil)
|
|
return if !redeemable?
|
|
|
|
if is_invite_link? && UserEmail.exists?(email: email)
|
|
raise UserExists.new I18n.t("invite_link.email_taken")
|
|
end
|
|
|
|
email = self.email if email.blank? && !is_invite_link?
|
|
InviteRedeemer.new(
|
|
invite: self,
|
|
email: email,
|
|
username: username,
|
|
name: name,
|
|
password: password,
|
|
user_custom_fields: user_custom_fields,
|
|
ip_address: ip_address,
|
|
session: session
|
|
).redeem
|
|
end
|
|
|
|
def self.redeem_from_email(email)
|
|
invite = Invite.find_by(email: Email.downcase(email))
|
|
InviteRedeemer.new(invite: invite, email: invite.email).redeem if invite
|
|
invite
|
|
end
|
|
|
|
def self.find_user_by_email(email)
|
|
User.with_email(Email.downcase(email)).where(staged: false).first
|
|
end
|
|
|
|
def self.pending(inviter)
|
|
Invite.distinct
|
|
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
|
|
.joins("LEFT JOIN users ON invited_users.user_id = users.id")
|
|
.where(invited_by_id: inviter.id)
|
|
.where('redemption_count < max_redemptions_allowed')
|
|
.where('expires_at > ?', Time.zone.now)
|
|
.order('invites.updated_at DESC')
|
|
end
|
|
|
|
def self.expired(inviter)
|
|
Invite.distinct
|
|
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
|
|
.joins("LEFT JOIN users ON invited_users.user_id = users.id")
|
|
.where(invited_by_id: inviter.id)
|
|
.where('redemption_count > max_redemptions_allowed OR expires_at < ?', Time.zone.now)
|
|
.order('invites.expires_at ASC')
|
|
end
|
|
|
|
def self.redeemed_users(inviter)
|
|
InvitedUser
|
|
.joins("LEFT JOIN invites ON invites.id = invited_users.invite_id")
|
|
.includes(user: :user_stat)
|
|
.where('invited_users.user_id IS NOT NULL')
|
|
.where('invites.invited_by_id = ?', inviter.id)
|
|
.order('invited_users.redeemed_at DESC')
|
|
.references('invite')
|
|
.references('user')
|
|
.references('user_stat')
|
|
end
|
|
|
|
def self.invalidate_for_email(email)
|
|
i = Invite.find_by(email: Email.downcase(email))
|
|
if i
|
|
i.invalidated_at = Time.zone.now
|
|
i.save
|
|
end
|
|
i
|
|
end
|
|
|
|
def resend_invite
|
|
self.update_columns(updated_at: Time.zone.now, invalidated_at: nil, expires_at: SiteSetting.invite_expiry_days.days.from_now)
|
|
Jobs.enqueue(:invite_email, invite_id: self.id)
|
|
end
|
|
|
|
def limit_invites_per_day
|
|
RateLimiter.new(invited_by, "invites-per-day", SiteSetting.max_invites_per_day, 1.day.to_i)
|
|
end
|
|
|
|
def self.base_directory
|
|
File.join(Rails.root, "public", "uploads", "csv", RailsMultisite::ConnectionManagement.current_db)
|
|
end
|
|
|
|
def ensure_max_redemptions_allowed
|
|
if self.max_redemptions_allowed.nil?
|
|
self.max_redemptions_allowed = 1
|
|
else
|
|
limit = invited_by&.staff? ? SiteSetting.invite_link_max_redemptions_limit
|
|
: SiteSetting.invite_link_max_redemptions_limit_users
|
|
|
|
if !self.max_redemptions_allowed.between?(1, limit)
|
|
errors.add(:max_redemptions_allowed, I18n.t("invite_link.max_redemptions_limit", max_limit: limit))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: invites
|
|
#
|
|
# id :integer not null, primary key
|
|
# invite_key :string(32) not null
|
|
# email :string
|
|
# invited_by_id :integer not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# deleted_at :datetime
|
|
# deleted_by_id :integer
|
|
# invalidated_at :datetime
|
|
# moderator :boolean default(FALSE), not null
|
|
# custom_message :text
|
|
# emailed_status :integer
|
|
# max_redemptions_allowed :integer default(1), not null
|
|
# redemption_count :integer default(0), not null
|
|
# expires_at :datetime not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_invites_on_email_and_invited_by_id (email,invited_by_id)
|
|
# index_invites_on_emailed_status (emailed_status)
|
|
# index_invites_on_invite_key (invite_key) UNIQUE
|
|
# index_invites_on_invited_by_id (invited_by_id)
|
|
#
|