diff --git a/app/javascript/dashboard/i18n/locale/en/integrationApps.json b/app/javascript/dashboard/i18n/locale/en/integrationApps.json index 828f4ea67..a922473c6 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrationApps.json +++ b/app/javascript/dashboard/i18n/locale/en/integrationApps.json @@ -46,6 +46,7 @@ "PLACEHOLDER": "Select Inbox" }, "SUBMIT": "Create", + "VALIDATING_OPENAI": "Validating with OpenAI...", "CANCEL": "Cancel" }, "API": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue index 1a4d36fee..893a08532 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue @@ -63,6 +63,13 @@ export default { isIntegrationDialogflow() { return this.integration.id === 'dialogflow'; }, + submitButtonLabel() { + if (this.integration.id === 'openai' && this.uiFlags.isCreatingHook) { + return this.$t('INTEGRATION_APPS.ADD.FORM.VALIDATING_OPENAI'); + } + + return this.$t('INTEGRATION_APPS.ADD.FORM.SUBMIT'); + }, }, methods: { onClose() { @@ -154,7 +161,7 @@ export default { /> diff --git a/app/javascript/dashboard/store/modules/integrations.js b/app/javascript/dashboard/store/modules/integrations.js index 5377006b7..26f211759 100644 --- a/app/javascript/dashboard/store/modules/integrations.js +++ b/app/javascript/dashboard/store/modules/integrations.js @@ -121,7 +121,7 @@ export const actions = { commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }); } catch (error) { commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }); - throw new Error(error); + throw error; } }, deleteHook: async ({ commit }, { appId, hookId }) => { diff --git a/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js index 561ebb4fe..0bf5fcd44 100644 --- a/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js @@ -105,7 +105,9 @@ describe('#actions', () => { }); it('sends correct actions if API is error', async () => { axios.post.mockRejectedValue(errorMessage); - await expect(actions.createHook({ commit })).rejects.toThrow(Error); + await expect(actions.createHook({ commit })).rejects.toEqual( + errorMessage + ); expect(commit.mock.calls).toEqual([ [types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }], [types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }], diff --git a/app/jobs/migration/validate_openai_hooks_job.rb b/app/jobs/migration/validate_openai_hooks_job.rb new file mode 100644 index 000000000..b2ad9108a --- /dev/null +++ b/app/jobs/migration/validate_openai_hooks_job.rb @@ -0,0 +1,23 @@ +class Migration::ValidateOpenaiHooksJob < ApplicationJob + queue_as :async_database_migration + + def perform(account: nil) + scope = Integrations::Hook.where(app_id: 'openai', status: 'enabled') + scope = scope.where(account_id: account.id) if account + + stats = { checked: 0, destroyed: 0 } + + scope.find_each do |hook| + stats[:checked] += 1 + next if Integrations::Openai::KeyValidator.valid?(hook.settings&.dig('api_key')) + + AdministratorNotifications::IntegrationsNotificationMailer.with(account: hook.account).openai_disconnect.deliver_later + hook.destroy! + stats[:destroyed] += 1 + Rails.logger.warn("[openai-backfill] destroyed hook_id=#{hook.id} account_id=#{hook.account_id}") + end + + Rails.logger.info("[openai-backfill] completed #{stats.inspect}") + stats + end +end diff --git a/app/mailers/administrator_notifications/integrations_notification_mailer.rb b/app/mailers/administrator_notifications/integrations_notification_mailer.rb index 05477eca1..006208a88 100644 --- a/app/mailers/administrator_notifications/integrations_notification_mailer.rb +++ b/app/mailers/administrator_notifications/integrations_notification_mailer.rb @@ -9,4 +9,10 @@ class AdministratorNotifications::IntegrationsNotificationMailer < Administrator subject = 'Your Dialogflow integration was disconnected' send_notification(subject) end + + def openai_disconnect + subject = 'Your OpenAI integration was disconnected' + action_url = settings_url('integrations/openai') + send_notification(subject, action_url: action_url) + end end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 518b405da..a3396951f 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -29,6 +29,7 @@ class Integrations::Hook < ApplicationRecord validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' } validate :validate_settings_json_schema validate :ensure_feature_enabled + validate :validate_openai_api_key, if: :validate_openai_api_key? validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } # TODO: This seems to be only used for slack at the moment @@ -56,6 +57,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'dialogflow' end + def openai? + app_id == 'openai' + end + def notion? app_id == 'notion' end @@ -95,6 +100,26 @@ class Integrations::Hook < ApplicationRecord errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings) end + # TODO: When adding credential validation for other integrations (dialogflow, dyte, etc.), + # extract this into an app-level config flag in apps.yml instead of hardcoding app_id checks. + def validate_openai_api_key? + openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?) + end + + def openai_api_key_changed? + settings_api_key(settings) != settings_api_key(settings_in_database) + end + + def validate_openai_api_key + return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings)) + + errors.add(:base, I18n.t('errors.openai.invalid_api_key')) + end + + def settings_api_key(value) + value&.dig('api_key') || value&.dig(:api_key) + end + def trigger_setup_if_crm # we need setup services to create data prerequisite to functioning of the integration # in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts diff --git a/app/views/mailers/administrator_notifications/integrations_notification_mailer/openai_disconnect.liquid b/app/views/mailers/administrator_notifications/integrations_notification_mailer/openai_disconnect.liquid new file mode 100644 index 000000000..2847a389d --- /dev/null +++ b/app/views/mailers/administrator_notifications/integrations_notification_mailer/openai_disconnect.liquid @@ -0,0 +1,7 @@ +

Hello,

+ +

Your OpenAI integration was disconnected because the configured API key is invalid or revoked. To continue using OpenAI-powered features, reconnect the integration with a valid API key.

+ +

+Click here to reconnect. +

diff --git a/config/locales/en.yml b/config/locales/en.yml index c590f8be9..dc2b4d3fe 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -123,6 +123,8 @@ en: sdp_offer_required: 'sdp_offer is required' contact_phone_required: 'Contact phone number is required' permission_request_failed: 'Failed to send call permission request' + openai: + invalid_api_key: 'OpenAI API key is invalid or revoked. Please check your key in your OpenAI dashboard.' inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. diff --git a/db/migrate/20260515000000_enqueue_validate_openai_hooks_job.rb b/db/migrate/20260515000000_enqueue_validate_openai_hooks_job.rb new file mode 100644 index 000000000..16abe908d --- /dev/null +++ b/db/migrate/20260515000000_enqueue_validate_openai_hooks_job.rb @@ -0,0 +1,7 @@ +class EnqueueValidateOpenaiHooksJob < ActiveRecord::Migration[7.1] + def up + Migration::ValidateOpenaiHooksJob.perform_later + end + + def down; end +end diff --git a/db/schema.rb b/db/schema.rb index 1948330e6..9d9fe3cbc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_07_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" diff --git a/lib/integrations/openai/key_validator.rb b/lib/integrations/openai/key_validator.rb new file mode 100644 index 000000000..dad464f7d --- /dev/null +++ b/lib/integrations/openai/key_validator.rb @@ -0,0 +1,26 @@ +module Integrations::Openai::KeyValidator + TIMEOUT_SECONDS = 5 + + def self.valid?(api_key) + return false if api_key.blank? + + connection = Faraday.new do |f| + f.options.timeout = TIMEOUT_SECONDS + f.options.open_timeout = TIMEOUT_SECONDS + end + + response = connection.get("#{api_base}/models") do |req| + req.headers['Authorization'] = "Bearer #{api_key}" + end + + response.status != 401 + rescue Faraday::Error => e + Rails.logger.warn("[openai-key-validator] #{e.class}: #{e.message}") + true + end + + def self.api_base + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + "#{endpoint.chomp('/')}/v1" + end +end diff --git a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb index 8662a5006..bf95c9826 100644 --- a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' RSpec.describe 'Integration Apps API', type: :request do let(:account) { create(:account) } + before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) } + describe 'GET /api/v1/integrations/apps' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 5c2000a84..8b059acc3 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -15,6 +15,7 @@ RSpec.describe Captain::ConversationCompletionService do allow(mock_chat).to receive(:with_schema).and_return(mock_chat) allow(account).to receive(:feature_enabled?).and_call_original allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) end describe '#perform' do diff --git a/spec/jobs/migration/validate_openai_hooks_job_spec.rb b/spec/jobs/migration/validate_openai_hooks_job_spec.rb new file mode 100644 index 000000000..d06d02142 --- /dev/null +++ b/spec/jobs/migration/validate_openai_hooks_job_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe Migration::ValidateOpenaiHooksJob do + let(:integrations_mailer) { instance_double(AdministratorNotifications::IntegrationsNotificationMailer) } + let(:mailer_response) { instance_double(ActionMailer::MessageDelivery, deliver_later: true) } + + before do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer) + allow(integrations_mailer).to receive(:openai_disconnect).and_return(mailer_response) + end + + def create_openai_hook(account:, api_key: 'sk-good') + create(:integrations_hook, :openai, account: account, settings: { 'api_key' => api_key }) + end + + it 'destroys invalid hooks, preserves valid ones, sends disconnect email, and reports stats' do + account_a = create(:account) + account_b = create(:account) + valid_hook = create_openai_hook(account: account_a, api_key: 'sk-good') + invalid_hook = create_openai_hook(account: account_b, api_key: 'sk-bad') + allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false) + + result = described_class.perform_now + + expect(valid_hook.reload).to be_enabled + expect { invalid_hook.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: account_b) + expect(result).to eq(checked: 2, destroyed: 1) + end + + it 'scopes to a specific account when provided' do + account_a = create(:account) + account_b = create(:account) + hook_a = create_openai_hook(account: account_a, api_key: 'sk-bad') + hook_b = create_openai_hook(account: account_b, api_key: 'sk-bad') + allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false) + + described_class.perform_now(account: account_a) + + expect { hook_a.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(hook_b.reload).to be_enabled + end + + it 'only checks enabled OpenAI hooks' do + account = create(:account) + slack_hook = create(:integrations_hook, account: account, app_id: 'slack') + disabled_hook = create_openai_hook(account: account) + disabled_hook.disable + + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + + described_class.perform_now + + expect(slack_hook.reload).to be_enabled + expect(disabled_hook.reload).to be_disabled # still disabled, not re-checked + end +end diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index cb8a2ae2c..5112e47ed 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -26,6 +26,7 @@ RSpec.describe Captain::BaseTaskService do # without enterprise module interference allow(account).to receive(:feature_enabled?).and_call_original allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) end describe '#perform' do diff --git a/spec/lib/integrations/llm_base_service_spec.rb b/spec/lib/integrations/llm_base_service_spec.rb index fc23d18ba..8f5015650 100644 --- a/spec/lib/integrations/llm_base_service_spec.rb +++ b/spec/lib/integrations/llm_base_service_spec.rb @@ -10,6 +10,8 @@ RSpec.describe Integrations::LlmBaseService do let(:error) { StandardError.new('API Error') } let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json } + before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) } + describe '#make_api_call' do before do allow(service).to receive(:instrument_llm_call).and_yield diff --git a/spec/lib/integrations/openai/key_validator_spec.rb b/spec/lib/integrations/openai/key_validator_spec.rb new file mode 100644 index 000000000..f2b6d5421 --- /dev/null +++ b/spec/lib/integrations/openai/key_validator_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Integrations::Openai::KeyValidator do + let(:api_key) { 'sk-test-valid-key-123456789' } + let(:probe_url) { 'https://api.openai.com/v1/models' } + + it 'accepts keys that OpenAI recognizes' do + stub_request(:get, probe_url).to_return(status: 200) + expect(described_class.valid?(api_key)).to be true + end + + it 'rejects keys that OpenAI does not recognize' do + stub_request(:get, probe_url).to_return(status: 401) + expect(described_class.valid?(api_key)).to be false + end + + it 'rejects blank keys without making a network call' do + expect(described_class.valid?(nil)).to be false + expect(described_class.valid?('')).to be false + end + + it 'treats transient failures as valid to avoid blocking saves' do + stub_request(:get, probe_url).to_return(status: 500) + expect(described_class.valid?(api_key)).to be true + + stub_request(:get, probe_url).to_timeout + expect(described_class.valid?(api_key)).to be true + end + + it 'routes the probe through the configured endpoint' do + custom_url = 'https://proxy.example.com/v1/models' + allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_ENDPOINT') + .and_return(instance_double(InstallationConfig, value: 'https://proxy.example.com/')) + stub_request(:get, custom_url).to_return(status: 200) + + described_class.valid?(api_key) + + expect(WebMock).to have_requested(:get, custom_url) + expect(WebMock).not_to have_requested(:get, probe_url) + end +end diff --git a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb index 04bc405f4..20fac65a3 100644 --- a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb @@ -39,4 +39,20 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end end + + describe 'openai_disconnect' do + let(:mail) { described_class.with(account: account).openai_disconnect.deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq('Your OpenAI integration was disconnected') + end + + it 'renders the content' do + expect(mail.body.encoded).to include('the configured API key is invalid or revoked') + end + + it 'renders the receiver email' do + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) + end + end end diff --git a/spec/models/integrations/app_spec.rb b/spec/models/integrations/app_spec.rb index fc64e1cc6..f9e7c7532 100644 --- a/spec/models/integrations/app_spec.rb +++ b/spec/models/integrations/app_spec.rb @@ -5,6 +5,8 @@ RSpec.describe Integrations::App do let(:app) { apps.find(id: app_name) } let(:account) { create(:account) } + before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) } + describe '#name' do let(:app_name) { 'slack' } diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index fd6e69bbc..369ea8ca8 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -111,4 +111,70 @@ RSpec.describe Integrations::Hook do end end end + + describe 'openai api key validation' do + let(:account) { create(:account) } + + it 'prevents saving an openai hook with an invalid key' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + + hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' }) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key')) + end + + it 'prevents saving an openai hook with a blank key' do + hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => '' }) + + expect(hook).not_to be_valid + end + + it 'allows saving an openai hook with a valid key' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + + hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' }) + + expect(hook).to be_valid + end + + it 'skips validation when an enabled openai hook is saved without changing the api key' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good', 'label_suggestion' => false }) + + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + hook.settings['label_suggestion'] = true + + expect(hook.save).to be true + end + + it 'validates when a disabled openai hook is re-enabled' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' }) + hook.update!(status: :disabled) + + allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false) + + expect(hook.update(status: :enabled)).to be false + expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key')) + end + + it 'skips validation for disabled hooks' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) + hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' }) + + # Even with validator returning false, disable succeeds because disabled hooks skip validation + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + hook.disable + expect(hook.reload).to be_disabled + end + + it 'does not validate keys for non-openai hooks' do + allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false) + + hook = build(:integrations_hook, account: account, app_id: 'slack') + + expect(hook).to be_valid + end + end end