diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6..83b3dc8b1 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_conversation - @conversation = create_conversation if conversation.nil? + return unless conversation.nil? + + @conversation = create_conversation + apply_labels if permitted_params[:labels].present? + end + + def apply_labels + valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title) + @conversation.update_labels(valid_labels) if valid_labels.present? end def message_finder_params @@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + # custom_attributes and labels are applied when a new conversation is created alongside the first message + params.permit( + :id, :before, :after, :website_token, + contact: [:name, :email], + message: [:content, :referer_url, :timestamp, :echo_id, :reply_to], + custom_attributes: {}, + labels: [] + ) end def set_message diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index af759af54..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js index d0818d941..471c2846b 100644 --- a/app/javascript/dashboard/api/captain/customTools.js +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient { delete(id) { return axios.delete(`${this.url}/${id}`); } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } } export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue index d1d1dd011..d5f1e3e52 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue @@ -101,12 +101,9 @@ const authTypeLabel = computed(() => { -
-
- +
+
+ {{ description }} -import { reactive, computed, useTemplateRef, watch } from 'vue'; +import { reactive, computed, ref, useTemplateRef, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useVuelidate } from '@vuelidate/core'; -import { required } from '@vuelidate/validators'; +import { required, maxLength } from '@vuelidate/validators'; import { useMapGetter } from 'dashboard/composables/store'; +import CustomToolsAPI from 'dashboard/api/captain/customTools'; import Input from 'dashboard/components-next/input/Input.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; @@ -72,8 +73,12 @@ const DEFAULT_PARAM = { required: false, }; +// OpenAI enforces a 64-char limit on function names. The backend slug is +// "custom_" (7 chars) + parameterized title, so cap the title conservatively. +const MAX_TOOL_NAME_LENGTH = 55; + const validationRules = { - title: { required }, + title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) }, endpoint_url: { required }, http_method: { required }, auth_type: { required }, @@ -103,9 +108,15 @@ const isLoading = computed(() => ); const getErrorMessage = (field, errorKey) => { - return v$.value[field].$error - ? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`) - : ''; + if (!v$.value[field].$error) return ''; + + const failedRule = v$.value[field].$errors[0]?.$validator; + if (failedRule === 'maxLength') { + return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, { + max: MAX_TOOL_NAME_LENGTH, + }); + } + return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`); }; const formErrors = computed(() => ({ @@ -140,6 +151,30 @@ const handleSubmit = async () => { emit('submit', state); }; + +const isTesting = ref(false); +const testResult = ref(null); +const isTestDisabled = computed( + () => state.endpoint_url.includes('{{') || !!state.request_template +); + +const handleTest = async () => { + if (!state.endpoint_url) return; + + isTesting.value = true; + testResult.value = null; + try { + const { data } = await CustomToolsAPI.test(state); + const isOk = data.status >= 200 && data.status < 300; + testResult.value = { success: isOk, status: data.status }; + } catch (e) { + const message = + e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR'); + testResult.value = { success: false, message }; + } finally { + isTesting.value = false; + } +};