diff --git a/Gemfile b/Gemfile index 01c7a9f83..a5068e765 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,8 @@ gem 'json_refs' gem 'rack-attack', '>= 6.7.0' # a utility tool for streaming, flexible and safe downloading of remote files gem 'down' +# SSRF-safe URL fetching +gem 'ssrf_filter', '~> 1.5' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 diff --git a/Gemfile.lock b/Gemfile.lock index 74ea4d82d..b77e5880f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -942,6 +942,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) squasher (0.7.2) + ssrf_filter (1.5.0) stackprof (0.2.25) statsd-ruby (1.5.0) stripe (18.0.1) @@ -1158,6 +1159,7 @@ DEPENDENCIES spring spring-watcher-listen squasher + ssrf_filter (~> 1.5) stackprof stripe (~> 18.0) telephone_number diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 479d8ae1b..bf20bc6ff 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController elsif params[:external_url].present? create_from_url else - render_error('No file or URL provided', :unprocessable_entity) + render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity) end render_success(result) if result.is_a?(ActiveStorage::Blob) @@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def create_from_url - uri = parse_uri(params[:external_url]) - return if performed? - - fetch_and_process_file_from_uri(uri) - end - - def parse_uri(url) - uri = URI.parse(url) - validate_uri(uri) - uri - rescue URI::InvalidURIError, SocketError - render_error('Invalid URL provided', :unprocessable_entity) - nil - end - - def validate_uri(uri) - raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - end - - def fetch_and_process_file_from_uri(uri) - uri.open do |file| - create_and_save_blob(file, File.basename(uri.path), file.content_type) + SafeFetch.fetch(params[:external_url].to_s) do |result| + create_and_save_blob(result.tempfile, result.filename, result.content_type) end - rescue OpenURI::HTTPError => e - render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity) - rescue SocketError - render_error('Invalid URL provided', :unprocessable_entity) + rescue SafeFetch::HttpError => e + render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity) + rescue SafeFetch::FetchError + render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity) + rescue SafeFetch::FileTooLargeError + render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity) + rescue SafeFetch::UnsupportedContentTypeError + render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity) + rescue SafeFetch::Error + render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity) rescue StandardError - render_error('An unexpected error occurred', :internal_server_error) + render_error(I18n.t('errors.upload.unexpected'), :internal_server_error) end def create_and_save_blob(io, filename, content_type) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 3e513a4b2..7176d6e1b 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController locale: account_params[:locale], user: current_user ).perform + enqueue_branding_enrichment if @user - send_auth_headers(@user) - render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + # Authenticated users (dashboard "add account") and api_only signups + # need the full response with account_id. API-only deployments have no + # frontend to handle the email confirmation flow, so they need auth + # tokens to proceed. + # Unauthenticated web signup returns only the email — no session is + # created until the user confirms via the email link. + if current_user || api_only_signup? + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render json: { email: @user.email } + end else render_error_response(CustomExceptions::Account::SignupFailed.new({})) end @@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController private + def enqueue_branding_enrichment + return if account_params[:email].blank? + + Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email]) + Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30) + rescue StandardError => e + # Enrichment is optional — never let queue/Redis failures abort signup + ChatwootExceptionTracker.new(e).capture_exception + end + 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 @@ -103,6 +124,15 @@ class Api::V1::AccountsController < Api::BaseController raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end + def api_only_signup? + # CW_API_ONLY_SERVER is the canonical flag for API-only deployments. + # ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose. + # Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get + # typecasts it to boolean, coercing 'api_only' to true. + ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || + InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only' + end + def validate_captcha raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? end diff --git a/app/controllers/auth/resend_confirmations_controller.rb b/app/controllers/auth/resend_confirmations_controller.rb new file mode 100644 index 000000000..b2c778c46 --- /dev/null +++ b/app/controllers/auth/resend_confirmations_controller.rb @@ -0,0 +1,18 @@ +# Unauthenticated endpoint for resending confirmation emails during signup. +# This is a standalone controller (not on DeviseOverrides::ConfirmationsController) +# because OmniAuth middleware intercepts all POST /auth/* routes as provider +# callbacks, and Devise controller filters cause 307 redirects for custom actions. +# Inherits from ActionController::API to avoid both issues entirely. +# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha. +class Auth::ResendConfirmationsController < ActionController::API + def create + return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + + email = params[:email] + return head(:ok) unless email.is_a?(String) + + user = User.from_email(email.strip.downcase) + user&.send_confirmation_instructions unless user&.confirmed? + head :ok + end +end diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/Paywall.vue b/app/javascript/dashboard/components-next/captain/pageComponents/Paywall.vue index 2b896a905..4da3be357 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/Paywall.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/Paywall.vue @@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount'; import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue'; +defineProps({ + featurePrefix: { + type: String, + default: 'CAPTAIN', + }, +}); + const router = useRouter(); const currentUser = useMapGetter('getCurrentUser'); @@ -31,7 +38,7 @@ const openBilling = () => { > { ); }); -const hasCustomTools = computed(() => { - return ( - isFeatureEnabledonAccount.value( - accountId.value, - FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS - ) || - isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2) - ); -}); - const toggleShortcutModalFn = show => { if (show) { emit('openKeyShortcutModal'); @@ -374,18 +364,14 @@ const menuItems = computed(() => { navigationPath: 'captain_assistants_inboxes_index', }), }, - ...(hasCustomTools.value - ? [ - { - name: 'Tools', - label: t('SIDEBAR.CAPTAIN_TOOLS'), - activeOn: ['captain_tools_index'], - to: accountScopedRoute('captain_assistants_index', { - navigationPath: 'captain_tools_index', - }), - }, - ] - : []), + { + name: 'Tools', + label: t('SIDEBAR.CAPTAIN_TOOLS'), + activeOn: ['captain_tools_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_tools_index', + }), + }, { name: 'Settings', label: t('SIDEBAR.CAPTAIN_SETTINGS'), diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 2a9577644..1bf08d169 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -313,7 +313,12 @@ const plugins = computed(() => { const sendWithSignature = computed(() => { // this is considered the source of truth, we watch this property // on change, we toggle the signature in the editor - if (props.allowSignature && !props.isPrivate && props.channelType) { + if ( + props.allowSignature && + !props.isPrivate && + props.channelType && + !props.disabled + ) { return fetchSignatureFlagFromUISettings(props.channelType); } @@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) { } function addSignature() { + if (props.disabled) return; let content = props.modelValue; // see if the content is empty, if it is before appending the signature // we need to add a paragraph node and move the cursor at the start of the editor @@ -454,6 +460,7 @@ function addSignature() { } function removeSignature() { + if (props.disabled) return; if (!props.signature) return; let content = props.modelValue; content = removeSignatureHelper( @@ -806,7 +813,7 @@ watch( watch(sendWithSignature, newValue => { // see if the allowSignature flag is true - if (props.allowSignature) { + if (props.allowSignature && !props.disabled) { toggleSignatureInEditor(newValue); } }); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 5f76041dc..ff569d763 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -128,7 +128,6 @@ export default { }, }, emits: [ - 'replaceText', 'toggleInsertArticle', 'selectWhatsappTemplate', 'selectContentTemplate', @@ -277,9 +276,6 @@ export default { toggleMessageSignature() { this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); }, - replaceText(text) { - this.$emit('replaceText', text); - }, toggleInsertArticle() { this.$emit('toggleInsertArticle'); }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index ef6fa03d6..a5122094e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events'; import { getMessageVariables, getUndefinedVariablesInMessage, - replaceVariablesInMessage, } from '@chatwoot/utils'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue'; @@ -636,10 +635,17 @@ export default { return message; } + // Even when editor is disabled (e.g. WhatsApp/API can't reply), we must + // still normalize stale signatures out of drafts when signature is off. + if (this.isEditorDisabled && this.sendWithSignature) { + return message; + } + const effectiveChannelType = getEffectiveChannelType( this.channelType, this.inbox?.medium || '' ); + return this.sendWithSignature ? appendSignature(message, this.messageSignature, effectiveChannelType) : removeSignature(message, this.messageSignature, effectiveChannelType); @@ -911,32 +917,6 @@ export default { }); this.hideContentTemplatesModal(); }, - replaceText(message) { - if (this.sendWithSignature && !this.private) { - // if signature is enabled, append it to the message - // appendSignature ensures that the signature is not duplicated - // so we don't need to check if the signature is already present - const effectiveChannelType = getEffectiveChannelType( - this.channelType, - this.inbox?.medium || '' - ); - message = appendSignature( - message, - this.messageSignature, - effectiveChannelType - ); - } - - const updatedMessage = replaceVariablesInMessage({ - message, - variables: this.messageVariables, - }); - - setTimeout(() => { - useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE); - this.message = updatedMessage; - }, 100); - }, setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { // Clear attachments when switching between private note and reply modes // This is to prevent from breaking the upload rules @@ -1435,7 +1415,6 @@ export default { :new-conversation-modal-active="newConversationModalActive" @select-whatsapp-template="openWhatsappTemplateModal" @select-content-template="openContentTemplateModal" - @replace-text="replaceText" @toggle-insert-article="toggleInsertArticle" @toggle-quoted-reply="toggleQuotedReply" /> diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index e6f0587e9..1f9632425 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -51,6 +51,7 @@ export const FEATURE_FLAGS = { export const PREMIUM_FEATURES = [ FEATURE_FLAGS.SLA, FEATURE_FLAGS.CAPTAIN, + FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS, FEATURE_FLAGS.CUSTOM_ROLES, FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.HELP_CENTER, diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 25d062650..b3d071ccd 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) { .trim(); // Trim any extra space } +/** + * Removes inline base64 markdown images from signature content. + * + * @param {string} content + * @returns {{ sanitizedContent: string, hasInlineImages: boolean }} + */ +export function stripInlineBase64Images(content) { + if (!content || typeof content !== 'string') { + return { sanitizedContent: content || '', hasInlineImages: false }; + } + + const markdownInlineBase64ImageRegex = + /!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi; + const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, ''); + const hasInlineImages = sanitizedContent !== content; + + return { sanitizedContent, hasInlineImages }; +} + /** * Strip unsupported markdown formatting based on channel capabilities. * Uses MARKDOWN_PATTERNS from editor constants. diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index ca61c1bab..f558dd213 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -15,6 +15,7 @@ import { getMenuAnchor, calculateMenuPosition, stripUnsupportedFormatting, + stripInlineBase64Images, } from '../editorHelper'; import { FORMATTING } from 'dashboard/constants/editor'; import { EditorState } from '@chatwoot/prosemirror-schema'; @@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => { }); }); +describe('stripInlineBase64Images', () => { + it('removes markdown data:image base64 images and sets hasInlineImages', () => { + const content = + 'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld'; + const { sanitizedContent, hasInlineImages } = + stripInlineBase64Images(content); + + expect(hasInlineImages).toBe(true); + expect(sanitizedContent).not.toContain('data:image/png;base64'); + expect(sanitizedContent).toContain('Hello'); + expect(sanitizedContent).toContain('World'); + }); + + it('leaves hosted image markdown unchanged', () => { + const content = '![](https://example.com/logo.png)'; + const { sanitizedContent, hasInlineImages } = + stripInlineBase64Images(content); + + expect(hasInlineImages).toBe(false); + expect(sanitizedContent).toBe(content); + }); + + it('returns empty hasInlineImages for empty input', () => { + expect(stripInlineBase64Images('')).toEqual({ + sanitizedContent: '', + hasInlineImages: false, + }); + }); +}); + describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 1b9caf02f..ad89755e1 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -838,6 +838,18 @@ "SUCCESS_MESSAGE": "Custom tool deleted successfully", "ERROR_MESSAGE": "Failed to delete custom tool" }, + "PAYWALL": { + "TITLE": "Upgrade to use tools with Captain", + "AVAILABLE_ON": "Captain Tools are only available in Business and Enterprise plans. Please upgrade to Business plan to use the feature.", + "UPGRADE_PROMPT": "", + "UPGRADE_NOW": "Open billing", + "CANCEL_ANYTIME": "" + }, + "ENTERPRISE_PAYWALL": { + "AVAILABLE_ON": "Captain Tools are only available in the paid plans.", + "UPGRADE_PROMPT": "Please upgrade to a paid plan to use this feature.", + "ASK_ADMIN": "Please reach out to your administrator for the upgrade." + }, "TEST": { "BUTTON": "Test connection", "SUCCESS": "Endpoint returned HTTP {status}", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index d885cf8ce..bfbd920a7 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -68,7 +68,8 @@ "API_SUCCESS": "Signature saved successfully", "IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again", "IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature", - "IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB" + "IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB", + "INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature." }, "MESSAGE_SIGNATURE": { "LABEL": "Message Signature", diff --git a/app/javascript/dashboard/i18n/locale/en/signup.json b/app/javascript/dashboard/i18n/locale/en/signup.json index 4a90fd322..238a1f061 100644 --- a/app/javascript/dashboard/i18n/locale/en/signup.json +++ b/app/javascript/dashboard/i18n/locale/en/signup.json @@ -45,6 +45,13 @@ "ERROR_MESSAGE": "Could not connect to Woot server. Please try again." }, "SUBMIT": "Create account", - "HAVE_AN_ACCOUNT": "Already have an account?" + "HAVE_AN_ACCOUNT": "Already have an account?", + "VERIFY_EMAIL": { + "TITLE": "Check your inbox", + "DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.", + "RESEND": "Resend verification email", + "RESEND_SUCCESS": "Verification email sent. Please check your inbox.", + "RESEND_ERROR": "Could not send verification email. Please try again." + } } } diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js index 9fd87ccba..1ab4fa501 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js @@ -23,6 +23,12 @@ const meta = { installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE], }; +const metaCustomTools = { + permissions: ['administrator', 'agent'], + featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS, + installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE], +}; + const metaV2 = { permissions: ['administrator', 'agent'], featureFlag: FEATURE_FLAGS.CAPTAIN_V2, @@ -46,7 +52,7 @@ const assistantRoutes = [ path: frontendURL('accounts/:accountId/captain/:assistantId/tools'), component: CustomToolsIndex, name: 'captain_tools_index', - meta, + meta: metaCustomTools, }, { path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'), diff --git a/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue index ea331dcaf..442c5ae65 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue @@ -5,13 +5,14 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { usePolicy } from 'dashboard/composables/usePolicy'; import PageLayout from 'dashboard/components-next/captain/PageLayout.vue'; +import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue'; import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue'; import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue'; import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue'; import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; const store = useStore(); -const { isFeatureFlagEnabled } = usePolicy(); +const { isFeatureFlagEnabled, shouldShowPaywall } = usePolicy(); const SOFT_LIMIT = 10; const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2)); @@ -80,7 +81,9 @@ const onDeleteSuccess = () => { }; onMounted(() => { - fetchCustomTools(); + if (!shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)) { + fetchCustomTools(); + } }); @@ -89,6 +92,7 @@ onMounted(() => { :header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')" :button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')" :button-policy="['administrator']" + :feature-flag="FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS" :total-count="customToolsMeta.totalCount" :current-page="customToolsMeta.page" :show-pagination-footer="!isFetching && !!customTools.length" @@ -98,6 +102,10 @@ onMounted(() => { @update:current-page="onPageChange" @click="openCreateDialog" > + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue index 6701d111a..3381a30ed 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue @@ -1,4 +1,5 @@