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/App.vue b/app/javascript/dashboard/App.vue index 8912c03d1..a706e2df5 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -98,7 +98,9 @@ export default { mql.onchange = e => setColorTheme(e.matches); }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, async initializeAccount() { await this.$store.dispatch('accounts/get'); 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; + } +};