scambra/devise_invitable

View on GitHub
lib/devise_invitable/models.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'active_support/deprecation'
require 'devise_invitable/models/authenticatable'

module Devise
  module Models
    # Invitable is responsible for sending invitation emails.
    # When an invitation is sent to an email address, an account is created for it.
    # Invitation email contains a link allowing the user to accept the invitation
    # by setting a password (as reset password from Devise's recoverable module).
    #
    # Configuration:
    #
    #   invite_for: The period the generated invitation token is valid.
    #               After this period, the invited resource won't be able to accept the invitation.
    #               When invite_for is 0 (the default), the invitation won't expire.
    #
    # Examples:
    #
    #   User.find(1).invited_to_sign_up?                    # => true/false
    #   User.invite!(email: 'someone@example.com')          # => send invitation
    #   User.accept_invitation!(invitation_token: '...')    # => accept invitation with a token
    #   User.find(1).accept_invitation!                     # => accept invitation
    #   User.find(1).invite!                                # => reset invitation status and send invitation again
    module Invitable
      extend ActiveSupport::Concern

      attr_accessor :skip_invitation
      attr_accessor :completing_invite
      attr_reader   :raw_invitation_token

      included do
        include ::DeviseInvitable::Inviter
        belongs_to_options = if invited_by_class_name
          { class_name: invited_by_class_name }
        else
          { polymorphic: true }
        end
        if fk = invited_by_foreign_key
          belongs_to_options[:foreign_key] = fk
        end
        if defined?(ActiveRecord) && defined?(ActiveRecord::Base) && self < ActiveRecord::Base
          counter_cache = Devise.invited_by_counter_cache
          belongs_to_options.merge! counter_cache: counter_cache if counter_cache
          belongs_to_options.merge! optional: true if ActiveRecord::VERSION::MAJOR >= 5
        elsif defined?(Mongoid) && defined?(Mongoid::Document) && self < Mongoid::Document && Mongoid::VERSION >= '6.0.0'
          belongs_to_options.merge! optional: true
        end
        belongs_to :invited_by, **belongs_to_options

        extend ActiveModel::Callbacks
        define_model_callbacks :invitation_created
        define_model_callbacks :invitation_accepted

        scope :no_active_invitation, lambda { where(invitation_token: nil) }
        if defined?(Mongoid) && defined?(Mongoid::Document) && self < Mongoid::Document
          scope :created_by_invite, lambda { where(:invitation_created_at.ne => nil) }
          scope :invitation_not_accepted, lambda { where(invitation_accepted_at: nil, :invitation_token.ne => nil) }
          scope :invitation_accepted, lambda { where(:invitation_accepted_at.ne => nil) }
        else
          scope :created_by_invite, lambda { where(arel_table[:invitation_created_at].not_eq(nil)) }
          scope :invitation_not_accepted, lambda { where(arel_table[:invitation_token].not_eq(nil)).where(invitation_accepted_at: nil) }
          scope :invitation_accepted, lambda { where(arel_table[:invitation_accepted_at].not_eq(nil)) }

          callbacks = [
            :before_invitation_created,
            :after_invitation_created,
            :before_invitation_accepted,
            :after_invitation_accepted,
          ]

          callbacks.each do |callback_method|
            send callback_method
          end
        end
      end

      def self.required_fields(klass)
        fields = [:invitation_token, :invitation_created_at, :invitation_sent_at, :invitation_accepted_at,
         :invitation_limit, klass.invited_by_foreign_key || :invited_by_id, :invited_by_type]
        fields << :invitations_count if defined?(ActiveRecord) && self < ActiveRecord::Base
        fields -= [:invited_by_type] if klass.invited_by_class_name
        fields
      end

      # Accept an invitation by clearing invitation token and and setting invitation_accepted_at
      def accept_invitation
        self.invitation_accepted_at = Time.now.utc
        self.invitation_token = nil
      end

      # Accept an invitation by clearing invitation token and and setting invitation_accepted_at
      # Saves the model and confirms it if model is confirmable, running invitation_accepted callbacks
      def accept_invitation!
        if self.invited_to_sign_up?
          @accepting_invitation = true
          run_callbacks :invitation_accepted do
            self.accept_invitation
            self.confirmed_at ||= self.invitation_accepted_at if self.respond_to?(:confirmed_at=)
            self.save
          end.tap do |saved|
            self.rollback_accepted_invitation if !saved
            @accepting_invitation = false
          end
        end
      end

      def rollback_accepted_invitation
        self.invitation_token = self.invitation_token_was
        self.invitation_accepted_at = nil
        self.confirmed_at = nil if self.respond_to?(:confirmed_at=)
      end

      # Verify wheather a user is created by invitation, irrespective to invitation status
      def created_by_invite?
        invitation_created_at.present?
      end

      # Verifies whether a user has been invited or not
      def invited_to_sign_up?
        accepting_invitation? || (persisted? && invitation_token.present?)
      end

      # Returns true if accept_invitation! was called
      def accepting_invitation?
        @accepting_invitation
      end

      # Verifies whether a user accepted an invitation (false when user is accepting it)
      def invitation_accepted?
        !accepting_invitation? && invitation_accepted_at.present?
      end

      # Verifies whether a user has accepted an invitation (false when user is accepting it), or was never invited
      def accepted_or_not_invited?
        invitation_accepted? || !invited_to_sign_up?
      end

      # Reset invitation token and send invitation again
      def invite!(invited_by = nil, options = {})
        # This is an order-dependant assignment, this can't be moved
        was_invited = invited_to_sign_up?

        # Required to workaround confirmable model's confirmation_required? method
        # being implemented to check for non-nil value of confirmed_at
        if new_record_and_responds_to?(:confirmation_required?)
          def self.confirmation_required?; false; end
        end

        yield self if block_given?
        generate_invitation_token if no_token_present_or_skip_invitation?

        run_callbacks :invitation_created do
          self.invitation_created_at = Time.now.utc
          self.invitation_sent_at = self.invitation_created_at unless skip_invitation
          self.invited_by = invited_by if invited_by

          # Call these before_validate methods since we aren't validating on save
          self.downcase_keys if new_record_and_responds_to?(:downcase_keys)
          self.strip_whitespace if new_record_and_responds_to?(:strip_whitespace)

          validate = options.key?(:validate) ? options[:validate] : self.class.validate_on_invite
          if save(validate: validate)
            self.invited_by.decrement_invitation_limit! if !was_invited and self.invited_by.present?
            deliver_invitation(options) unless skip_invitation
          end
        end
      end

      # Verify whether a invitation is active or not. If the user has been
      # invited, we need to calculate if the invitation time has not expired
      # for this user, in other words, if the invitation is still valid.
      def valid_invitation?
        invited_to_sign_up? && invitation_period_valid?
      end

      # Only verify password when is not invited
      def valid_password?(password)
        super unless !accepting_invitation? && block_from_invitation?
      end

      # Prevent password changed email when accepting invitation
      def send_password_change_notification?
        super && !accepting_invitation?
      end

      # Enforce password when invitation is being accepted
      def password_required?
        (accepting_invitation? && self.class.require_password_on_accepting) || super
      end

      def unauthenticated_message
        block_from_invitation? ? :invited : super
      end

      def clear_reset_password_token
        reset_password_token_present = reset_password_token.present?
        super
        accept_invitation! if reset_password_token_present && valid_invitation?
      end

      def clear_errors_on_valid_keys
        self.class.invite_key.each do |key, value|
          self.errors.delete(key) if value === self.send(key)
        end
      end

      # Deliver the invitation email
      def deliver_invitation(options = {})
        generate_invitation_token! unless @raw_invitation_token
        self.update_attribute :invitation_sent_at, Time.now.utc unless self.invitation_sent_at
        send_devise_notification(:invitation_instructions, @raw_invitation_token, options)
      end

      # provide alias to the encrypted invitation_token stored by devise
      def encrypted_invitation_token
        self.invitation_token
      end

      def confirmation_required_for_invited?
        respond_to?(:confirmation_required?, true) && confirmation_required?
      end

      def invitation_due_at
        return nil if (self.class.invite_for == 0 || self.class.invite_for.nil?)
        #return nil unless self.class.invite_for

        time = self.invitation_created_at || self.invitation_sent_at
        time + self.class.invite_for
      end

      def add_taken_error(key)
        errors.add(key, :taken)
      end

      def invitation_taken?
        !invited_to_sign_up?
      end

      protected

        def block_from_invitation?
          invited_to_sign_up?
        end

        # Checks if the invitation for the user is within the limit time.
        # We do this by calculating if the difference between today and the
        # invitation sent date does not exceed the invite for time configured.
        # Invite_for is a model configuration, must always be an integer value.
        #
        # Example:
        #
        #   # invite_for = 1.day and invitation_sent_at = today
        #   invitation_period_valid?   # returns true
        #
        #   # invite_for = 5.days and invitation_sent_at = 4.days.ago
        #   invitation_period_valid?   # returns true
        #
        #   # invite_for = 5.days and invitation_sent_at = 5.days.ago
        #   invitation_period_valid?   # returns false
        #
        #   # invite_for = nil
        #   invitation_period_valid?   # will always return true
        #
        def invitation_period_valid?
          time = invitation_created_at || invitation_sent_at
          self.class.invite_for.to_i.zero? || (time && time.utc >= self.class.invite_for.ago)
        end

        # Generates a new random token for invitation, and stores the time
        # this token is being generated
        def generate_invitation_token
          raw, enc = Devise.token_generator.generate(self.class, :invitation_token)
          @raw_invitation_token = raw
          self.invitation_token = enc
        end

        def generate_invitation_token!
          generate_invitation_token && save(validate: false)
        end

        def new_record_and_responds_to?(method)
          self.new_record? && self.respond_to?(method, true)
        end

        def no_token_present_or_skip_invitation?
          self.invitation_token.nil? || (!skip_invitation || @raw_invitation_token.nil?)
        end

      module ClassMethods
        # Return fields to invite
        def invite_key_fields
          invite_key.keys
        end

        # Attempt to find a user by its email. If a record is not found,
        # create a new user and send an invitation to it. If the user is found,
        # return the user with an email already exists error.
        # If the user is found and still has a pending invitation, invitation
        # email is resent unless resend_invitation is set to false.
        # Attributes must contain the user's email, other attributes will be
        # set in the record
        def _invite(attributes = {}, invited_by = nil, options = {}, &block)
          invite_key_array = invite_key_fields
          attributes_hash = {}
          invite_key_array.each do |k,v|
            attribute = attributes.delete(k)
            attribute = attribute.to_s.strip if strip_whitespace_keys.include?(k)
            attributes_hash[k] = attribute
          end

          invitable = find_or_initialize_with_errors(invite_key_array, attributes_hash)
          invitable.assign_attributes(attributes)
          invitable.invited_by = invited_by
          unless invitable.password || invitable.encrypted_password.present?
            invitable.password = random_password
          end

          invitable.valid? if self.validate_on_invite
          if invitable.new_record?
            invitable.clear_errors_on_valid_keys if !self.validate_on_invite
          elsif invitable.invitation_taken? || !self.resend_invitation
            invite_key_array.each do |key|
              invitable.add_taken_error(key)
            end
          end

          yield invitable if block_given?
          mail = invitable.invite!(nil, options.merge(validate: false)) if invitable.errors.empty?
          [invitable, mail]
        end

        def invite!(attributes = {}, invited_by = nil, options = {}, &block)
          attr_hash = ActiveSupport::HashWithIndifferentAccess.new(attributes.to_h)
          _invite(attr_hash, invited_by, options, &block).first
        end

        def invite_mail!(attributes = {}, invited_by = nil, options = {}, &block)
          _invite(attributes, invited_by, options, &block).last
        end

        # Attempt to find a user by it's invitation_token to set it's password.
        # If a user is found, reset it's password and automatically try saving
        # the record. If not user is found, returns a new user containing an
        # error in invitation_token attribute.
        # Attributes must contain invitation_token, password and confirmation
        def accept_invitation!(attributes = {})
          original_token = attributes.delete(:invitation_token)
          invitable = find_by_invitation_token(original_token, false)
          if invitable.errors.empty?
            invitable.assign_attributes(attributes)
            invitable.accept_invitation!
          end
          invitable
        end

        def find_by_invitation_token(original_token, only_valid)
          invitation_token = Devise.token_generator.digest(self, :invitation_token, original_token)

          invitable = find_or_initialize_with_error_by(:invitation_token, invitation_token)
          invitable.errors.add(:invitation_token, :invalid) if invitable.invitation_token && invitable.persisted? && !invitable.valid_invitation?
          invitable unless only_valid && invitable.errors.present?
        end

        # Callback convenience methods
        def before_invitation_created(*args, &blk)
          set_callback(:invitation_created, :before, *args, &blk)
        end

        def after_invitation_created(*args, &blk)
          set_callback(:invitation_created, :after, *args, &blk)
        end

        def before_invitation_accepted(*args, &blk)
          set_callback(:invitation_accepted, :before, *args, &blk)
        end

        def after_invitation_accepted(*args, &blk)
          set_callback(:invitation_accepted, :after, *args, &blk)
        end


        Devise::Models.config(self, :invite_for)
        Devise::Models.config(self, :validate_on_invite)
        Devise::Models.config(self, :invitation_limit)
        Devise::Models.config(self, :invite_key)
        Devise::Models.config(self, :resend_invitation)
        Devise::Models.config(self, :invited_by_class_name)
        Devise::Models.config(self, :invited_by_foreign_key)
        Devise::Models.config(self, :invited_by_counter_cache)
        Devise::Models.config(self, :allow_insecure_sign_in_after_accept)
        Devise::Models.config(self, :require_password_on_accepting)

        private

          # The random password, as set after an invitation, must conform
          # to any password format validation rules of the application.
          # This default fixes the most common scenarios: Passwords must contain
          # lower + upper case, a digit and a symbol.
          # For more unusual rules, this method can be overridden.
          def random_password
            length = respond_to?(:password_length) ? password_length : Devise.password_length

            prefix = 'aA1!'
            prefix + Devise.friendly_token(length.last - prefix.length)
          end
      end
    end
  end
end