diff --git a/.rubocop.yml b/.rubocop.yml index 484e424ed..3761d4cb0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -203,13 +203,4 @@ AllCops: - 'config/environments/**/*' - 'tmp/**/*' - 'storage/**/*' - - 'db/migrate/20200225162150_init_schema.rb' - - 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb' - - 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb' - - db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb - - db/migrate/20200331095710_add_identifier_to_contact.rb - - db/migrate/20200429082655_add_medium_to_twilio_sms.rb - - db/migrate/20200503151130_add_account_feature_flag.rb - - db/migrate/20200927135222_add_last_activity_at_to_conversation.rb - - db/migrate/20210306170117_add_last_activity_at_to_contacts.rb - - db/migrate/20220809104508_revert_cascading_indexes.rb + - 'db/migrate/20230426130150_init_schema.rb' diff --git a/Gemfile.lock b/Gemfile.lock index 96881eb30..74b281b43 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -277,7 +277,7 @@ GEM gli (2.21.1) globalid (1.2.1) activesupport (>= 6.1) - gmail_xoauth (0.4.2) + gmail_xoauth (0.4.3) oauth (>= 0.3.6) google-apis-core (0.11.0) addressable (~> 2.5, >= 2.5.1) @@ -488,14 +488,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.0) - nokogiri (1.16.0) + nokogiri (1.16.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.0-arm64-darwin) + nokogiri (1.16.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.0-x86_64-darwin) + nokogiri (1.16.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.0-x86_64-linux) + nokogiri (1.16.2-x86_64-linux) racc (~> 1.4) numo-narray (0.9.2.1) oauth (1.1.0) @@ -794,7 +794,7 @@ GEM valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) - version_gem (1.1.2) + version_gem (1.1.3) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) diff --git a/LICENSE b/LICENSE index f36fc7c53..8f80f35be 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2021 Chatwoot Inc. +Copyright (c) 2017-2024 Chatwoot Inc. Portions of this software are licensed as follows: diff --git a/app/assets/stylesheets/administrate/custom_styles.scss b/app/assets/stylesheets/administrate/custom_styles.scss index 859a35689..5e6d803d8 100644 --- a/app/assets/stylesheets/administrate/custom_styles.scss +++ b/app/assets/stylesheets/administrate/custom_styles.scss @@ -9,7 +9,7 @@ padding: 4px 12px; .icon-container { - margin-right: 4px; + margin-right: 2px; } diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 3e2ac9d6e..677041449 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -2,7 +2,7 @@ class AccountBuilder include CustomExceptions::Account - pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale] + pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale] def perform if @user.nil? @@ -21,6 +21,16 @@ class AccountBuilder private + def user_full_name + # the empty string ensures that not-null constraint is not violated + @user_full_name || '' + end + + def account_name + # the empty string ensures that not-null constraint is not violated + @account_name || '' + end + def validate_email address = ValidEmail2::Address.new(@email) if address.valid? # && !address.disposable? @@ -39,7 +49,7 @@ class AccountBuilder end def create_account - @account = Account.create!(name: @account_name, locale: I18n.locale) + @account = Account.create!(name: account_name, locale: I18n.locale) Current.account = @account end @@ -64,7 +74,7 @@ class AccountBuilder @user = User.new(email: @email, password: user_password, password_confirmation: user_password, - name: @user_full_name) + name: user_full_name) @user.type = 'SuperAdmin' if @super_admin @user.confirm if @confirmed @user.save! diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb new file mode 100644 index 000000000..6ea68821d --- /dev/null +++ b/app/builders/agent_builder.rb @@ -0,0 +1,60 @@ +# The AgentBuilder class is responsible for creating a new agent. +# It initializes with necessary attributes and provides a perform method +# to create a user and account user in a transaction. +class AgentBuilder + # Initializes an AgentBuilder with necessary attributes. + # @param email [String] the email of the user. + # @param name [String] the name of the user. + # @param role [String] the role of the user, defaults to 'agent' if not provided. + # @param inviter [User] the user who is inviting the agent (Current.user in most cases). + # @param availability [String] the availability status of the user, defaults to 'offline' if not provided. + # @param auto_offline [Boolean] the auto offline status of the user. + pattr_initialize [:email, { name: '' }, :inviter, :account, { role: :agent }, { availability: :offline }, { auto_offline: false }] + + # Creates a user and account user in a transaction. + # @return [User] the created user. + def perform + ActiveRecord::Base.transaction do + @user = find_or_create_user + send_confirmation_if_required + create_account_user + end + @user + end + + private + + # Finds a user by email or creates a new one with a temporary password. + # @return [User] the found or created user. + def find_or_create_user + user = User.find_by(email: email) + return user if user + + temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" + User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) + end + + # Sends confirmation instructions if the user is persisted and not confirmed. + def send_confirmation_if_required + @user.send_confirmation_instructions if user_needs_confirmation? + end + + # Checks if the user needs confirmation. + # @return [Boolean] true if the user is persisted and not confirmed, false otherwise. + def user_needs_confirmation? + @user.persisted? && !@user.confirmed? + end + + # Creates an account user linking the user to the current account. + def create_account_user + AccountUser.create!({ + account_id: account.id, + user_id: @user.id, + inviter_id: inviter.id + }.merge({ + role: role, + availability: availability, + auto_offline: auto_offline + }.compact)) + end +end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 9a089f242..768d1a3ff 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -1,16 +1,26 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController - before_action :fetch_agent, except: [:create, :index] + before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :check_authorization - before_action :find_user, only: [:create] before_action :validate_limit, only: [:create] - before_action :create_user, only: [:create] - before_action :save_account_user, only: [:create] + before_action :validate_limit_for_bulk_create, only: [:bulk_create] def index @agents = agents end - def create; end + def create + builder = AgentBuilder.new( + email: new_agent_params['email'], + name: new_agent_params['name'], + role: new_agent_params['role'], + availability: new_agent_params['availability'], + auto_offline: new_agent_params['auto_offline'], + inviter: current_user, + account: Current.account + ) + + builder.perform + end def update @agent.update!(agent_params.slice(:name).compact) @@ -23,6 +33,21 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController head :ok end + def bulk_create + emails = params[:emails] + + emails.each do |email| + builder = AgentBuilder.new( + email: email, + name: email.split('@').first, + inviter: current_user, + account: Current.account + ) + builder.perform + end + head :ok + end + private def check_authorization @@ -33,47 +58,34 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController @agent = agents.find(params[:id]) end - def find_user - @user = User.find_by(email: new_agent_params[:email]) - end - - # TODO: move this to a builder and combine the save account user method into a builder - # ensure the account user association is also created in a single transaction - def create_user - return @user.send_confirmation_instructions if @user - - @user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation)) - end - - def save_account_user - AccountUser.create!({ - account_id: Current.account.id, - user_id: @user.id, - inviter_id: current_user.id - }.merge({ - role: new_agent_params[:role], - availability: new_agent_params[:availability], - auto_offline: new_agent_params[:auto_offline] - }.compact)) - end - def agent_params params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline) end def new_agent_params - # intial string ensures the password requirements are met - temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) - .merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user) end def agents @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end + def validate_limit_for_bulk_create + limit_available = params[:emails].count <= available_agent_count + + render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available + end + def validate_limit - render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents] + render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? + end + + def available_agent_count + Current.account.usage_limits[:agents] - agents.count + end + + def can_add_agent? + available_agent_count.positive? end def delete_user_record(agent) diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index a3d8df27e..0eeff5695 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro RESULTS_PER_PAGE = 15 include DateRangeHelper - before_action :fetch_notification, only: [:update, :destroy, :snooze] + before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread] before_action :set_primary_actor, only: [:read_all] before_action :set_current_page, only: [:index] @@ -29,11 +29,25 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro render json: @notification end + def unread + @notification.update(read_at: nil) + render json: @notification + end + def destroy @notification.destroy head :ok end + def destroy_all + if params[:type] == 'read' + ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read) + else + ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all) + end + head :ok + end + def unread_count @unread_count = notification_finder.unread_count render json: @unread_count diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index ef0e0c777..b1d31cfaf 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -5,11 +5,13 @@ class Api::V1::AccountsController < Api::BaseController skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, only: [:create], raise: false before_action :check_signup_enabled, only: [:create] + before_action :ensure_account_name, only: [:create] before_action :validate_captcha, only: [:create] before_action :fetch_account, except: [:create] before_action :check_authorization, except: [:create] rescue_from CustomExceptions::Account::InvalidEmail, + CustomExceptions::Account::InvalidParams, CustomExceptions::Account::UserExists, CustomExceptions::Account::UserErrors, with: :render_error_response @@ -53,6 +55,17 @@ class Api::V1::AccountsController < Api::BaseController private + def ensure_account_name + # ensure that account_name and user_full_name is present + # this is becuase the account builder and the models validations are not triggered + # this change is to align the behaviour with the v2 accounts controller + # since these values are not required directly there + return if account_params[:account_name].present? + return if account_params[:user_full_name].present? + + raise CustomExceptions::Account::InvalidParams.new({}) + end + def get_cache_keys { label: fetch_value_for_key(params[:id], Label.name.underscore), diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index cbf801e82..a77652ff8 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -31,6 +31,11 @@ class Api::V1::ProfilesController < Api::BaseController head :ok end + def resend_confirmation + @user.send_confirmation_instructions unless @user.confirmed? + head :ok + end + private def set_user diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb new file mode 100644 index 000000000..e38863c07 --- /dev/null +++ b/app/controllers/api/v2/accounts_controller.rb @@ -0,0 +1,48 @@ +class Api::V2::AccountsController < Api::BaseController + include AuthHelper + + skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception, + only: [:create], raise: false + before_action :check_signup_enabled, only: [:create] + before_action :validate_captcha, only: [:create] + before_action :fetch_account, except: [:create] + before_action :check_authorization, except: [:create] + + rescue_from CustomExceptions::Account::InvalidEmail, + CustomExceptions::Account::UserExists, + CustomExceptions::Account::UserErrors, + with: :render_error_response + + def create + @user, @account = AccountBuilder.new( + email: account_params[:email], + user_password: account_params[:password], + user: current_user + ).perform + if @user + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render_error_response(CustomExceptions::Account::SignupFailed.new({})) + end + end + + private + + def fetch_account + @account = current_user.accounts.find(params[:id]) + @current_account_user = @account.account_users.find_by(user_id: current_user.id) + end + + def account_params + params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name) + end + + def check_signup_enabled + raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + end + + def validate_captcha + raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 97caad42c..a31d01675 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -22,19 +22,24 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController i.value = value i.save! end - # rubocop:disable Rails/I18nLocaleTexts - redirect_to super_admin_settings_path, notice: 'App Configs updated successfully' - # rubocop:enable Rails/I18nLocaleTexts + redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" end private def set_config - @config = params[:config] + @config = params[:config] || 'general' end def allowed_configs - @allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET] + @allowed_configs = case @config + when 'facebook' + %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] + when 'email' + ['MAILER_INBOUND_EMAIL_DOMAIN'] + else + %w[ENABLE_ACCOUNT_SIGNUP] + end end end diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb index 7559d6ef9..7e5789372 100644 --- a/app/finders/notification_finder.rb +++ b/app/finders/notification_finder.rb @@ -26,6 +26,7 @@ class NotificationFinder def set_up find_all_notifications + filter_by_read_status filter_by_status end @@ -37,11 +38,15 @@ class NotificationFinder @notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed' end + def filter_by_read_status + @notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read' + end + def current_page params[:page] || 1 end def notifications - @notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: :desc) + @notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc) end end diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb new file mode 100644 index 000000000..759aec134 --- /dev/null +++ b/app/helpers/super_admin/account_features_helper.rb @@ -0,0 +1,9 @@ +module SuperAdmin::AccountFeaturesHelper + def self.account_features + YAML.safe_load(Rails.root.join('config/features.yml').read).freeze + end + + def self.account_premium_features + account_features.filter { |feature| feature['premium'] }.pluck('name') + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 6fd470fcf..49fe325f6 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -2,12 +2,13 @@
@@ -32,6 +33,7 @@ import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; import UpgradeBanner from './components/app/UpgradeBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; +import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import vueActionCable from './helper/actionCable'; import WootSnackbarBox from './components/SnackbarContainer.vue'; import rtlMixin from 'shared/mixins/rtlMixin'; @@ -52,6 +54,7 @@ export default { PaymentPendingBanner, WootSnackbarBox, UpgradeBanner, + PendingEmailVerificationBanner, }, mixins: [rtlMixin], diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 229e4f309..dde817866 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -98,4 +98,8 @@ export default { }, }); }, + resendConfirmation() { + const urlData = endPoints('resendConfirmation'); + return axios.post(urlData.url); + }, }; diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 678386d50..31337b7fc 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -47,6 +47,10 @@ const endPoints = { setActiveAccount: { url: '/api/v1/profile/set_active_account', }, + + resendConfirmation: { + url: '/api/v1/profile/resend_confirmation', + }, }; export default page => { diff --git a/app/javascript/dashboard/api/notifications.js b/app/javascript/dashboard/api/notifications.js index e13bc78a6..183642742 100644 --- a/app/javascript/dashboard/api/notifications.js +++ b/app/javascript/dashboard/api/notifications.js @@ -25,9 +25,29 @@ class NotificationsAPI extends ApiClient { }); } + unRead(id) { + return axios.post(`${this.url}/${id}/unread`); + } + readAll() { return axios.post(`${this.url}/read_all`); } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } + + deleteAll({ type = 'all' }) { + return axios.post(`${this.url}/destroy_all`, { + type, + }); + } + + snooze({ id, snoozedUntil = null }) { + return axios.post(`${this.url}/${id}/snooze`, { + snoozed_until: snoozedUntil, + }); + } } export default new NotificationsAPI(); diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 31fe6110d..7fcc58127 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components/ui/Banner.vue b/app/javascript/dashboard/components/ui/Banner.vue index 5d680fe48..3473111ae 100644 --- a/app/javascript/dashboard/components/ui/Banner.vue +++ b/app/javascript/dashboard/components/ui/Banner.vue @@ -1,6 +1,6 @@