diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 5af4857e0..45ff25203 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -10,7 +10,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index a01ba1093..beef5727c 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -2,7 +2,7 @@ # # # # Linux nightly installer action # # This action will try to install and setup -# # chatwoot on an Ubuntu 20.04 machine using +# # chatwoot on an Ubuntu 22.04 machine using # # the linux installer script. # # # # This is set to run daily at midnight. diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 5a9d35d0b..385feddfc 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgres: image: pgvector/pgvector:pg15 diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 724f69ec3..9be79da05 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -7,7 +7,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 77c4a4740..53deb62a8 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ yarn-debug.log* # Vite uses dotenv and suggests to ignore local-only env files. See # https://vitejs.dev/guide/env-and-mode.html#env-files *.local + +# Claude.ai config file +CLAUDE.md diff --git a/Gemfile.lock b/Gemfile.lock index bfe4b1970..9f1ee5eec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -501,14 +501,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.18.3) + nokogiri (1.18.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.3-arm64-darwin) + nokogiri (1.18.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-darwin) + nokogiri (1.18.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-gnu) + nokogiri (1.18.4-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 4d51700a8..ffa45db2e 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -12,11 +12,50 @@ class ContactInboxBuilder private def generate_source_id - ContactInbox::SourceIdService.new( - contact: @contact, - channel_type: @inbox.channel_type, - medium: @inbox.channel.try(:medium) - ).generate + case @inbox.channel_type + when 'Channel::TwilioSms' + twilio_source_id + when 'Channel::Whatsapp' + wa_source_id + when 'Channel::Email' + email_source_id + when 'Channel::Sms' + phone_source_id + when 'Channel::Api', 'Channel::WebWidget' + SecureRandom.uuid + else + raise "Unsupported operation for this channel: #{@inbox.channel_type}" + end + end + + def email_source_id + raise ActionController::ParameterMissing, 'contact email' unless @contact.email + + @contact.email + end + + def phone_source_id + raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number + + @contact.phone_number + end + + def wa_source_id + raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number + + # whatsapp doesn't want the + in e164 format + @contact.phone_number.delete('+').to_s + end + + def twilio_source_id + raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number + + case @inbox.channel.medium + when 'sms' + @contact.phone_number + when 'whatsapp' + "whatsapp:#{@contact.phone_number}" + end end def create_contact_inbox @@ -52,7 +91,7 @@ class ContactInboxBuilder def new_source_id if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio? - "#{@source_id}#{rand(100)}" + "whatsapp:#{@source_id}#{rand(100)}" else "#{rand(10)}#{@source_id}" end diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2f30093db..2c0e6087e 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder contact = find_contact_by_identifier(contact_attributes[:identifier]) contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) + contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel? + contact end + def instagram_channel? + inbox.channel_type == 'Channel::Instagram' + end + + # There might be existing contact_inboxes created through Channel::FacebookPage + # with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes + # while still reusing contacts if found in Facebook channels so that we can create + # new conversations with the same contact. + def find_contact_by_instagram_source_id(instagram_id) + return if instagram_id.blank? + + existing_contact_inbox = ContactInbox.joins(:inbox) + .where(source_id: instagram_id) + .where( + 'inboxes.channel_type = ? AND inboxes.account_id = ?', + 'Channel::FacebookPage', + account.id + ).first + + existing_contact_inbox&.contact + end + def find_contact_by_identifier(identifier) return if identifier.blank? diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb new file mode 100644 index 000000000..767115bc6 --- /dev/null +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -0,0 +1,178 @@ +class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder + attr_reader :messaging + + def initialize(messaging, inbox, outgoing_echo: false) + super() + @messaging = messaging + @inbox = inbox + @outgoing_echo = outgoing_echo + end + + def perform + return if @inbox.channel.reauthorization_required? + + ActiveRecord::Base.transaction do + build_message + end + rescue StandardError => e + handle_error(e) + end + + private + + def attachments + @messaging[:message][:attachments] || {} + end + + def message_type + @outgoing_echo ? :outgoing : :incoming + end + + def message_identifier + message[:mid] + end + + def message_source_id + @outgoing_echo ? recipient_id : sender_id + end + + def message_is_unsupported? + message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true + end + + def sender_id + @messaging[:sender][:id] + end + + def recipient_id + @messaging[:recipient][:id] + end + + def message + @messaging[:message] + end + + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact + end + + def conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + find_conversation_scope.order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_conversation_scope + Conversation.where(conversation_params) + end + + def find_or_build_for_multiple_conversations + last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first + return build_conversation if last_conversation.nil? + + last_conversation + end + + def message_content + @messaging[:message][:text] + end + + def story_reply_attributes + message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? + end + + def message_reply_attributes + message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? + end + + def build_message + # Duplicate webhook events may be sent for the same message + # when a user is connected to the Instagram account through both Messenger and Instagram login. + # There is chance for echo events to be sent for the same message. + # Therefore, we need to check if the message already exists before creating it. + return if message_already_exists? + + return if message_content.blank? && all_unsupported_files? + + @message = conversation.messages.create!(message_params) + save_story_id + + attachments.each do |attachment| + process_attachment(attachment) + end + end + + def save_story_id + return if story_reply_attributes.blank? + + @message.save_story_info(story_reply_attributes) + end + + def build_conversation + @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) + Conversation.create!(conversation_params.merge( + contact_inbox_id: @contact_inbox.id, + additional_attributes: additional_conversation_attributes + )) + end + + def additional_conversation_attributes + {} + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id + } + end + + def message_params + params = { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: message_type, + source_id: message_identifier, + content: message_content, + sender: @outgoing_echo ? nil : contact, + content_attributes: { + in_reply_to_external_id: message_reply_attributes + } + } + + params[:content_attributes][:is_unsupported] = true if message_is_unsupported? + params + end + + def message_already_exists? + cw_message = conversation.messages.where( + source_id: @messaging[:message][:mid] + ).first + + cw_message.present? + end + + def all_unsupported_files? + return if attachments.empty? + + attachments_type = attachments.pluck(:type).uniq.first + unsupported_file_type?(attachments_type) + end + + def handle_error(error) + ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception + true + end + + # Abstract methods to be implemented by subclasses + def get_story_object_from_source_id(source_id) + raise NotImplementedError + end +end diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 7f1b9cab2..4e7150894 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -1,200 +1,42 @@ -# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` -# Assumptions -# 1. Incase of an outgoing message which is echo, source_id will NOT be nil, -# based on this we are showing "not sent from chatwoot" message in frontend -# Hence there is no need to set user_id in message for outgoing echo messages. - -class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder - attr_reader :messaging - +class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder def initialize(messaging, inbox, outgoing_echo: false) - super() - @messaging = messaging - @inbox = inbox - @outgoing_echo = outgoing_echo - end - - def perform - return if @inbox.channel.reauthorization_required? - - ActiveRecord::Base.transaction do - build_message - end - rescue Koala::Facebook::AuthenticationError => e - Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}") - Rails.logger.error e - @inbox.channel.authorization_error! - raise - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception - true + super(messaging, inbox, outgoing_echo: outgoing_echo) end private - def attachments - @messaging[:message][:attachments] || {} + def get_story_object_from_source_id(source_id) + url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" + + response = HTTParty.get(url) + + return JSON.parse(response.body).with_indifferent_access if response.success? + + # Create message first if it doesn't exist + @message ||= conversation.messages.create!(message_params) + handle_error_response(response) + nil end - def message_type - @outgoing_echo ? :outgoing : :incoming - end + def handle_error_response(response) + parsed_response = JSON.parse(response.body) + error_code = parsed_response.dig('error', 'code') - def message_identifier - message[:mid] - end + # https://developers.facebook.com/docs/messenger-platform/error-codes + # Access token has expired or become invalid. + channel.authorization_error! if error_code == 190 - def message_source_id - @outgoing_echo ? recipient_id : sender_id - end - - def message_is_unsupported? - message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true - end - - def sender_id - @messaging[:sender][:id] - end - - def recipient_id - @messaging[:recipient][:id] - end - - def message - @messaging[:message] - end - - def contact - @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact - end - - def conversation - @conversation ||= set_conversation_based_on_inbox_config - end - - def instagram_direct_message_conversation - Conversation.where(conversation_params) - .where("additional_attributes ->> 'type' = 'instagram_direct_message'") - end - - def set_conversation_based_on_inbox_config - if @inbox.lock_to_single_conversation - instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation - else - find_or_build_for_multiple_conversations + # There was a problem scraping data from the provided link. + # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 + if error_code == 1_609_005 + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) end + + Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") end - def find_or_build_for_multiple_conversations - last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first - - return build_conversation if last_conversation.nil? - - last_conversation + def base_uri + "https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" end - - def message_content - @messaging[:message][:text] - end - - def story_reply_attributes - message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? - end - - def message_reply_attributes - message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? - end - - def build_message - return if @outgoing_echo && already_sent_from_chatwoot? - return if message_content.blank? && all_unsupported_files? - - @message = conversation.messages.create!(message_params) - save_story_id - - attachments.each do |attachment| - process_attachment(attachment) - end - end - - def save_story_id - return if story_reply_attributes.blank? - - @message.save_story_info(story_reply_attributes) - end - - def build_conversation - @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) - - Conversation.create!(conversation_params.merge( - contact_inbox_id: @contact_inbox.id, - additional_attributes: { type: 'instagram_direct_message' } - )) - end - - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: contact.id - } - end - - def message_params - params = { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: message_type, - source_id: message_identifier, - content: message_content, - sender: @outgoing_echo ? nil : contact, - content_attributes: { - in_reply_to_external_id: message_reply_attributes - } - } - - params[:content_attributes][:is_unsupported] = true if message_is_unsupported? - params - end - - def already_sent_from_chatwoot? - cw_message = conversation.messages.where( - source_id: @messaging[:message][:mid] - ).first - - cw_message.present? - end - - def all_unsupported_files? - return if attachments.empty? - - attachments_type = attachments.pluck(:type).uniq.first - unsupported_file_type?(attachments_type) - end - - ### Sample response - # { - # "object": "instagram", - # "entry": [ - # { - # "id": "",// ig id of the business - # "time": 1569262486134, - # "messaging": [ - # { - # "sender": { - # "id": "" - # }, - # "recipient": { - # "id": "" - # }, - # "timestamp": 1569262485349, - # "message": { - # "mid": "", - # "text": "" - # } - # } - # ] - # } - # ], - # } end diff --git a/app/builders/messages/instagram/messenger/message_builder.rb b/app/builders/messages/instagram/messenger/message_builder.rb new file mode 100644 index 000000000..1263dee90 --- /dev/null +++ b/app/builders/messages/instagram/messenger/message_builder.rb @@ -0,0 +1,33 @@ +class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder + def initialize(messaging, inbox, outgoing_echo: false) + super(messaging, inbox, outgoing_echo: outgoing_echo) + end + + private + + def get_story_object_from_source_id(source_id) + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + k.get_object(source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue Koala::Facebook::ClientError => e + # The exception occurs when we are trying fetch the deleted story or blocked story. + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) + Rails.logger.error e + {} + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + {} + end + + def find_conversation_scope + Conversation.where(conversation_params) + .where("additional_attributes ->> 'type' = 'instagram_direct_message'") + end + + def additional_conversation_attributes + { type: 'instagram_direct_message' } + end +end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index f01a236c9..4e7f2849d 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder message.save! end - def get_story_object_from_source_id(source_id) - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - k.get_object(source_id, fields: %w[story from]) || {} - rescue Koala::Facebook::AuthenticationError - @inbox.channel.authorization_error! - raise - rescue Koala::Facebook::ClientError => e - # The exception occurs when we are trying fetch the deleted story or blocked story. - @message.attachments.destroy_all - @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) - Rails.logger.error e - {} - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + # This is a placeholder method to be overridden by child classes + def get_story_object_from_source_id(_source_id) {} end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 43bce17bc..1422beea1 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content]) + params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) end def process_avatar_from_url diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 01aa42620..438944f04 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def allowed_agent_params - [:name, :email, :name, :role, :availability, :auto_offline] + [:name, :email, :role, :availability, :auto_offline] end def agent_params diff --git a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb index bde9a4f0c..d985c8a73 100644 --- a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb @@ -9,8 +9,6 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts: source_id: params[:source_id], hmac_verified: hmac_verified? ).perform - rescue ArgumentError => e - render json: { error: e.message }, status: :unprocessable_entity end private diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index de0ac4db9..fda19b8c2 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -1,17 +1,21 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController def index - @conversations = Current.account.conversations.includes( + # Start with all conversations for this contact + conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20) - end + ).where(contact_id: @contact.id) - private + # Apply permission-based filtering using the existing service + conversations = Conversations::PermissionFilterService.new( + conversations, + Current.user, + Current.account + ).perform - def inbox_ids - if Current.user.administrator? || Current.user.agent? - Current.user.assigned_inboxes.pluck(:id) - else - [] - end + # Only allow conversations from inboxes the user has access to + inbox_ids = Current.user.assigned_inboxes.pluck(:id) + conversations = conversations.where(inbox_id: inbox_ids) + + @conversations = conversations.order(last_activity_at: :desc).limit(20) end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 138c2bd68..8753918fc 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def filter - result = ::Conversations::FilterService.new(params.permit!, current_user).perform + result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform @conversations = result[:conversations] @conversations_count = result[:count] rescue CustomExceptions::CustomFilter::InvalidAttribute, diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb new file mode 100644 index 000000000..eace4411a --- /dev/null +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController + include InstagramConcern + include Instagram::IntegrationHelper + before_action :check_authorization + + def create + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization + redirect_url = instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/instagram/callback", + scope: REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: generate_instagram_token(Current.account.id) + } + ) + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index fe9a03ef5..6cfed161f 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portals = Current.account.portals end - def add_members - agents = Current.account.agents.where(id: portal_member_params[:member_ids]) - @portal.members << agents - end - def show @all_articles = @portal.articles @articles = @all_articles.search(locale: params[:locale]) @@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController { channel_web_widget_id: inbox.channel.id } end - def portal_member_params - params.require(:portal).permit(:account_id, member_ids: []) - end - def set_current_page @current_page = params[:page] || 1 end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index af3655e59..6e2d0ff4c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController end def check_authorization - return if Current.account_user.administrator? - - raise Pundit::NotAuthorizedError + authorize :report, :view? end def common_params @@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics end end - -Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController') diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb new file mode 100644 index 000000000..04de9e63c --- /dev/null +++ b/app/controllers/concerns/instagram_concern.rb @@ -0,0 +1,74 @@ +module InstagramConcern + extend ActiveSupport::Concern + + def instagram_client + ::OAuth2::Client.new( + client_id, + client_secret, + { + site: 'https://api.instagram.com', + authorize_url: 'https://api.instagram.com/oauth/authorize', + token_url: 'https://api.instagram.com/oauth/access_token', + auth_scheme: :request_body, + token_method: :post + } + ) + end + + private + + def client_id + GlobalConfigService.load('INSTAGRAM_APP_ID', nil) + end + + def client_secret + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def exchange_for_long_lived_token(short_lived_token) + endpoint = 'https://graph.instagram.com/access_token' + params = { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + } + + make_api_request(endpoint, params, 'Failed to exchange token') + end + + def fetch_instagram_user_details(access_token) + endpoint = 'https://graph.instagram.com/v22.0/me' + params = { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + } + + make_api_request(endpoint, params, 'Failed to fetch Instagram user details') + end + + def make_api_request(endpoint, params, error_prefix) + response = HTTParty.get( + endpoint, + query: params, + headers: { 'Accept' => 'application/json' } + ) + + unless response.success? + Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" + raise "#{error_prefix}: #{response.body}" + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError => e + ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Invalid JSON response: #{response.body}" + raise e + end + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0f915ab8a..a2cb466f1 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -36,7 +36,7 @@ class DashboardController < ActionController::Base 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', 'DEPLOYMENT_ENV', - 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN' + 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end @@ -65,6 +65,7 @@ class DashboardController < ActionController::Base VAPID_PUBLIC_KEY: VapidService.public_key, ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), + INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index e1cf76d6b..2b3ea9067 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa def validate_business_account? # return true if the user is a business account, false if it is a gmail account - auth_hash['info']['email'].exclude?('@gmail.com') + auth_hash['info']['email'].downcase.exclude?('@gmail.com') end def create_account_for_user diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb new file mode 100644 index 000000000..4dc8ece1c --- /dev/null +++ b/app/controllers/instagram/callbacks_controller.rb @@ -0,0 +1,163 @@ +class Instagram::CallbacksController < ApplicationController + include InstagramConcern + include Instagram::IntegrationHelper + + def show + # Check if Instagram redirected with an error (user canceled authorization) + # See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + if params[:error].present? + handle_authorization_error + return + end + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + # Process the authorization code and create inbox + def process_successful_authorization + @response = instagram_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback", + grant_type: 'authorization_code' + ) + + @long_lived_token_response = exchange_for_long_lived_token(@response.token) + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + # Handle all errors that might occur during authorization + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response + def handle_error(error) + Rails.logger.error("Instagram Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + error_info = extract_error_info(error) + redirect_to_error_page(error_info) + end + + # Extract error details from the exception + def extract_error_info(error) + if error.is_a?(OAuth2::Error) + begin + # Instagram returns JSON error response which we parse to extract error details + JSON.parse(error.message) + rescue JSON::ParseError + # Fall back to a generic OAuth error if JSON parsing fails + { 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message } + end + else + # For other unexpected errors + { 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message } + end + end + + # Handles the case when a user denies permissions or cancels the authorization flow + # Error parameters are documented at: + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + def handle_authorization_error + error_info = { + 'error_type' => params[:error] || 'authorization_error', + 'code' => 400, + 'error_message' => params[:error_description] || 'Authorization was denied' + } + + Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}") + redirect_to_error_page(error_info) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_info) + redirect_to app_new_instagram_inbox_url( + account_id: account_id, + error_type: error_info['error_type'], + code: error_info['code'], + error_message: error_info['error_message'] + ) + end + + def find_or_create_inbox + user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) + channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s) + channel_exists = channel_instagram.present? + + if channel_instagram + update_channel(channel_instagram, user_details) + else + channel_instagram = create_channel_with_inbox(user_details) + end + + # reauthorize channel, this code path only triggers when instagram auth is successful + # reauthorized will also update cache keys for the associated inbox + channel_instagram.reauthorized! + + [channel_instagram.inbox, channel_exists] + end + + def find_channel_by_instagram_id(instagram_id) + Channel::Instagram.find_by(instagram_id: instagram_id, account: account) + end + + def update_channel(channel_instagram, user_details) + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram.update!( + access_token: @long_lived_token_response['access_token'], + expires_at: expires_at + ) + + # Update inbox name if username changed + channel_instagram.inbox.update!(name: user_details['username']) + channel_instagram + end + + def create_channel_with_inbox(user_details) + ActiveRecord::Base.transaction do + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram = Channel::Instagram.create!( + access_token: @long_lived_token_response['access_token'], + instagram_id: user_details['user_id'].to_s, + account: account, + expires_at: expires_at + ) + + account.inboxes.create!( + account: account, + channel: channel_instagram, + name: user_details['username'] + ) + + channel_instagram + end + end + + def account_id + return unless params[:state] + + verify_instagram_token(params[:state]) + end + + def oauth_code + params[:code] + end + + def account + @account ||= Account.find(account_id) + end + + def provider_name + 'instagram' + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 3e17a7369..550b6c893 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -43,6 +43,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController ['MAILER_INBOUND_EMAIL_DOMAIN'] when 'linear' %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] + when 'instagram' + %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index b658915ed..3d46334ca 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API private def valid_token?(token) - token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') + # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and + # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) + token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || + token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index f7b04a167..0bf4e44ca 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard COLLECTION_FILTERS = { active: ->(resources) { resources.where(status: :active) }, suspended: ->(resources) { resources.where(status: :suspended) }, - recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) } + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }, + marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") } }.freeze # Overwrite this method to customize how accounts are displayed diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 44592c201..1694199ba 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -32,6 +32,7 @@ class ConversationFinder def initialize(current_user, params) @current_user = current_user @current_account = current_user.account + @is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator? @params = params end @@ -85,8 +86,19 @@ class ConversationFinder @team = current_account.teams.find(params[:team_id]) if params[:team_id] end + def find_conversation_by_inbox + @conversations = current_account.conversations + @conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin + end + def find_all_conversations - @conversations = current_account.conversations.where(inbox_id: @inbox_ids) + find_conversation_by_inbox + # Apply permission-based filtering + @conversations = Conversations::PermissionFilterService.new( + @conversations, + current_user, + current_account + ).perform filter_by_conversation_type if params[:conversation_type] @conversations end diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index 26669e38d..e2ada7e86 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -18,4 +18,8 @@ module BillingHelper def non_web_inboxes(account) account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count end + + def agents(account) + account.users.count + end end diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb new file mode 100644 index 000000000..8ba57bf95 --- /dev/null +++ b/app/helpers/instagram/integration_helper.rb @@ -0,0 +1,49 @@ +module Instagram::IntegrationHelper + REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze + + # Generates a signed JWT token for Instagram integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_instagram_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Instagram token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Instagram JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_instagram_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index ae50055fb..e51958e9e 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad import LoadingState from './components/widgets/LoadingState.vue'; 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'; @@ -31,7 +30,6 @@ export default { UpdateBanner, PaymentPendingBanner, WootSnackbarBox, - UpgradeBanner, PendingEmailVerificationBanner, }, setup() { @@ -146,7 +144,6 @@ export default { diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index 4de6fcee0..6e59f38d3 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -1,9 +1,26 @@ +/* global axios */ import ApiClient from './ApiClient'; class AgentBotsAPI extends ApiClient { constructor() { super('agent_bots', { accountScoped: true }); } + + create(data) { + return axios.post(this.url, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + deleteAgentBotAvatar(botId) { + return axios.delete(`${this.url}/${botId}/avatar`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index bb95335ad..3f12dc007 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient { getLimits() { return axios.get(`${this.url}limits`); } + + toggleDeletion(action) { + return axios.post(`${this.url}toggle_deletion`, { + action_type: action, + }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 4fb1bd0ee..9c65b0b67 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); + expect(accountAPI).toHaveProperty('toggleDeletion'); }); describe('API calls', () => { @@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => { '/enterprise/api/v1/subscription' ); }); + + it('#toggleDeletion with delete action', () => { + accountAPI.toggleDeletion('delete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'delete' } + ); + }); + + it('#toggleDeletion with undelete action', () => { + accountAPI.toggleDeletion('undelete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'undelete' } + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index 48cfce921..f23c01d42 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -29,6 +29,19 @@ --iris-11: 87 83 198; --iris-12: 39 41 98; + --blue-1: 251 253 255; + --blue-2: 245 249 255; + --blue-3: 233 243 255; + --blue-4: 218 236 255; + --blue-5: 201 226 255; + --blue-6: 181 213 255; + --blue-7: 155 195 252; + --blue-8: 117 171 247; + --blue-9: 39 129 246; + --blue-10: 16 115 233; + --blue-11: 8 109 224; + --blue-12: 11 50 101; + --ruby-1: 255 252 253; --ruby-2: 255 247 248; --ruby-3: 254 234 237; @@ -131,6 +144,19 @@ --iris-11: 158 177 255; --iris-12: 224 223 254; + --blue-1: 10 17 28; + --blue-2: 15 24 38; + --blue-3: 15 39 72; + --blue-4: 10 49 99; + --blue-5: 18 61 117; + --blue-6: 29 84 134; + --blue-7: 40 89 156; + --blue-8: 48 106 186; + --blue-9: 39 129 246; + --blue-10: 21 116 231; + --blue-11: 126 182 255; + --blue-12: 205 227 255; + --ruby-1: 25 17 19; --ruby-2: 30 21 23; --ruby-3: 58 20 30; diff --git a/app/javascript/dashboard/assets/scss/_rtl.scss b/app/javascript/dashboard/assets/scss/_rtl.scss index 36679fc60..4f5f4b843 100644 --- a/app/javascript/dashboard/assets/scss/_rtl.scss +++ b/app/javascript/dashboard/assets/scss/_rtl.scss @@ -81,28 +81,6 @@ margin-left: var(--space-small); } } - - // Conversation sidebar close button - .close-button--rtl { - transform: rotate(180deg); - } - - // Resolve actions button - .resolve-actions { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } } // Conversation list @@ -177,71 +155,6 @@ } } - // Help center - .article-container .row--article-block { - td:last-child { - direction: initial; - } - } - - .portal-popover__container .portal { - .actions-container { - margin-left: unset; - margin-right: var(--space-one); - } - } - - .edit-article--container { - .header-right--wrap { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } - - .header-left--wrap { - .back-button { - direction: initial; - } - } - - .article--buttons { - .dropdown-pane { - left: 0; - position: absolute; - right: unset; - } - } - - .sidebar-button { - transform: rotate(180deg); - } - } - - .article-settings--container { - border-left: 0; - border-right: 1px solid var(--color-border-light); - flex-direction: row-reverse; - margin-left: 0; - margin-right: var(--space-normal); - padding-left: 0; - padding-right: var(--space-normal); - } - - .category-list--container .header-left--wrap { - direction: initial; - justify-content: flex-end; - } - // Toggle switch .toggle-button { &.small { @@ -264,11 +177,6 @@ } } - // Widget builder - .widget-builder-container .widget-preview { - direction: initial; - } - // Modal .modal-container { text-align: right; @@ -282,7 +190,6 @@ } // Other changes - .colorpicker--chrome { direction: initial; } @@ -291,14 +198,6 @@ direction: initial; } - .contact--details .contact--bio { - direction: ltr; - } - - .merge-contacts .child-contact-wrap { - direction: ltr; - } - .contact--form .input-group { direction: initial; } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 004d8bf59..622eb4085 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -29,7 +29,6 @@ @import 'rtl'; @import 'widgets/base'; -@import 'widgets/buttons'; @import 'widgets/conversation-view'; @import 'widgets/tabs'; @import 'widgets/woot-tables'; diff --git a/app/javascript/dashboard/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss index b2c271173..9c4ccf126 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_base.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss @@ -40,6 +40,12 @@ dl:not(.reset-base) { @apply mb-0; } +// Button base +button { + font-family: inherit; + @apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50; +} + // Form elements // ------------------------- label { diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss deleted file mode 100644 index 133e07bd1..000000000 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ /dev/null @@ -1,228 +0,0 @@ -// scss-lint:disable SpaceAfterPropertyColon -// scss-lint:disable MergeableSelector -button { - font-family: inherit; - transition: - background-color 0.25s ease-out, - color 0.25s ease-out; - @apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem]; - - &:disabled, - &.disabled { - @apply opacity-40 cursor-not-allowed; - } -} - -.button-group { - @apply mb-0 flex flex-nowrap items-stretch; - - .button { - flex: 0 0 auto; - @apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse; - } - - .button--only-icon { - @apply w-10 justify-center pl-0 pr-0; - } -} - -.back-button { - @apply m-0; -} - -.button { - @apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium; - - .button__content { - @apply w-full whitespace-nowrap overflow-hidden text-ellipsis; - - img, - svg { - @apply inline-block; - } - } - - &:hover:not(:disabled):not(.success):not(.alert):not(.warning):not( - .clear - ):not(.smooth):not(.hollow) { - @apply bg-n-brand/80 dark:bg-n-brand/80; - } - - &:disabled, - &.disabled { - @apply opacity-40 cursor-not-allowed; - } - - &.success { - @apply bg-n-teal-9 text-white dark:text-white; - } - - &.secondary { - @apply bg-n-solid-3 text-white dark:text-white; - } - - &.primary { - @apply bg-n-brand text-white dark:text-white; - } - - &.clear { - @apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent; - } - - &.alert { - @apply bg-n-ruby-9 text-white dark:text-white; - - &.clear { - @apply bg-transparent dark:bg-transparent; - } - } - - &.warning { - @apply bg-n-amber-9 text-white dark:text-white; - - &.clear { - @apply bg-transparent dark:bg-transparent; - } - } - - &.tiny { - @apply h-6 text-[10px]; - } - - &.small { - @apply h-8 text-xs; - } - - .spinner { - @apply px-2 py-0; - } - - // @TODDO - Remove after moving all buttons to woot-button - .icon + .button__content { - @apply w-auto; - } - - &.expanded { - @apply flex justify-center text-center; - } - - &.round { - @apply rounded-full; - } - - // @TODO Use with link - - &.compact { - @apply pb-0 pt-0; - } - - &.hollow { - @apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20; - - &.secondary { - @apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5; - } - - &.success { - @apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5; - } - - &.alert { - @apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5; - } - - &.warning { - @apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5; - } - } - - // Smooth style - &.smooth { - @apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40; - - &.secondary { - @apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5; - } - - &.success { - @apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5; - } - - &.alert { - @apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5; - } - - &.warning { - @apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5; - } - } - - &.clear { - @apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30; - - &.secondary { - @apply text-n-slate-12 hover:enabled:bg-n-slate-4; - } - - &.success { - @apply text-n-teal-10 hover:enabled:bg-n-teal-4; - } - - &.alert { - @apply text-n-ruby-11 hover:enabled:bg-n-ruby-4; - } - - &.warning { - @apply text-n-amber-11 hover:enabled:bg-n-amber-4; - } - - &:active { - &.secondary { - @apply active:bg-n-slate-3 dark:active:bg-n-slate-7; - } - } - - &:focus { - &.secondary { - @apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6; - } - } - } - - // Sizes - &.tiny { - @apply h-6; - } - - &.small { - @apply h-8 pb-1 pt-1; - } - - &.large { - @apply h-12; - } - - &.button--only-icon { - @apply justify-center pl-0 pr-0 w-10; - - &.tiny { - @apply w-6; - } - - &.small { - @apply w-8; - } - - &.large { - @apply w-12; - } - } - - &.link { - @apply h-auto m-0 p-0; - - &:hover { - @apply underline; - } - } -} diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue index 14d944b3e..75400692e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue @@ -51,6 +51,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });