From 5de7ae492cd7ccac1deea0664da2e2860f403daf Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:55:21 +0530 Subject: [PATCH 1/8] fix: html/body background not applied in appearance mode (#13955) # Pull Request Template ## Description This PR fixes the white background bleed visible in the widget, widget article viewer and help center when dark mode is active. **What was happening** While scrolling, the `` element retained a white background in dark mode. This occurred because dark mode classes were only applied to inner container elements, not the root. **What changed** * **Widget:** Updated the `useDarkMode` composable to sync the `dark` class to `` using `watchEffect`, allowing `` to inherit dark theme variables. Also added background styles to `html`, `body`, and `#app` in `woot.scss`. * **Help center portal:** Moved `bg-white dark:bg-slate-900` from `
` to `` in the portal layout so the entire page background responds correctly to dark mode, including within the widget iframe. * **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1` to ensure better theming. Fixes https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screencasts ### Before **Widget** https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728 **Help center** https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845 ### After **Widget** https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c **Help center** https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/javascript/widget/assets/scss/woot.scss | 2 +- app/javascript/widget/composables/useDarkMode.js | 6 +++++- app/javascript/widget/views/ArticleViewer.vue | 2 +- app/views/layouts/portal.html.erb | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index 07aa6a0e3..0044ccdfc 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -7,7 +7,7 @@ html, body { - @apply antialiased h-full; + @apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1; } .is-mobile { diff --git a/app/javascript/widget/composables/useDarkMode.js b/app/javascript/widget/composables/useDarkMode.js index bc19c456b..407d90980 100644 --- a/app/javascript/widget/composables/useDarkMode.js +++ b/app/javascript/widget/composables/useDarkMode.js @@ -1,4 +1,4 @@ -import { computed } from 'vue'; +import { computed, watchEffect } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; const isDarkModeAuto = mode => mode === 'auto'; @@ -23,6 +23,10 @@ export function useDarkMode() { calculatePrefersDarkMode(darkMode.value, systemPreference.value) ); + watchEffect(() => { + document.documentElement.classList.toggle('dark', prefersDarkMode.value); + }); + return { darkMode, prefersDarkMode, diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue index 9289d0546..bc4cf775c 100644 --- a/app/javascript/widget/views/ArticleViewer.vue +++ b/app/javascript/widget/views/ArticleViewer.vue @@ -10,7 +10,7 @@ export default { diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 78418881a..52d8e2789 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -58,9 +58,9 @@ By default, it renders: } - +
-
+
<% if !@is_plain_layout_enabled %> <%= render "public/api/v1/portals/header", portal: @portal %> <% end %> From 8824efe0e1767bafb007e5a946df78eab14c8bc7 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:09:02 +0530 Subject: [PATCH 2/8] fix(sentry): syntaxError: No error message (#13954) --- app/javascript/dashboard/App.vue | 4 +++- .../routes/dashboard/settings/account/Index.vue | 13 +++++++------ app/javascript/v3/App.vue | 4 +++- 3 files changed, 13 insertions(+), 8 deletions(-) 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/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 5be704c24..0502ebc1b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -103,7 +103,10 @@ export default { const { name, locale, id, domain, support_email, features } = this.getAccount(this.accountId); - this.$root.$i18n.locale = this.uiSettings?.locale || locale; + const effectiveLocale = this.uiSettings?.locale || locale; + if (effectiveLocale) { + this.$root.$i18n.locale = effectiveLocale; + } this.name = name; this.locale = locale; this.id = id; @@ -129,11 +132,9 @@ export default { support_email: this.supportEmail, }); // If user locale is set, update the locale with user locale - if (this.uiSettings?.locale) { - this.$root.$i18n.locale = this.uiSettings?.locale; - } else { - // If user locale is not set, update the locale with account locale - this.$root.$i18n.locale = this.locale; + const updatedLocale = this.uiSettings?.locale || this.locale; + if (updatedLocale) { + this.$root.$i18n.locale = updatedLocale; } this.getAccount(this.id).locale = this.locale; useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); diff --git a/app/javascript/v3/App.vue b/app/javascript/v3/App.vue index ef7107beb..c3f9b1734 100644 --- a/app/javascript/v3/App.vue +++ b/app/javascript/v3/App.vue @@ -35,7 +35,9 @@ export default { }; }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, }, }; From f2cb23d6e90c7ce00f486a02b8c6727a53648057 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:55:49 +0530 Subject: [PATCH 3/8] fix: handle Socket::ResolutionError in browser push notifications (#13957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently#comment-14e0f9ff ## Description Browser push notifications fail with Socket::ResolutionError when the push subscription endpoint's domain can't be resolved via DNS (e.g., defunct push service, transient DNS failure). This error wasn't handled in handle_browser_push_error, so it fell through to the catch-all else branch and got reported to Sentry on every notification attempt — 1,637 times in the last 7 days. The dead subscription was never cleaned up or the error suppressed, so every subsequent notification for the affected user triggered the same Sentry alert. Added Socket::ResolutionError to the existing transient network error handler alongside Errno::ECONNRESET, Net::OpenTimeout, and Net::ReadTimeout. The error is logged but not reported to Sentry, and the subscription is kept intact in case it's a temporary DNS blip. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Verified that Socket::ResolutionError is a subclass of StandardError and matches the when clause ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Co-authored-by: Vishnu Narayanan --- app/services/notification/push_notification_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb index 125ad9113..90f835ecb 100644 --- a/app/services/notification/push_notification_service.rb +++ b/app/services/notification/push_notification_service.rb @@ -79,7 +79,7 @@ class Notification::PushNotificationService subscription.destroy! when WebPush::TooManyRequests Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}" - when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout + when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError Rails.logger.error "WebPush operation error: #{error.message}" else ChatwootExceptionTracker.new(error, account: notification.account).capture_exception From 4cce7f6ad89a6e3e0d967c0e6c7aae34d67fbef0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 1 Apr 2026 15:59:12 +0400 Subject: [PATCH 4/8] fix(line): Use non-expiring URLs for image and video messages (#13949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images and videos sent from Chatwoot to LINE inboxes fail to display on the LINE mobile app — users see expired markers, broken thumbnails, or missing images. This happens because LINE mobile lazy-loads images rather than downloading them immediately, and the ActiveStorage signed URLs expire after 5 minutes. Closes https://linear.app/chatwoot/issue/CW-6696/line-messaging-with-image-or-video-may-not-show-when-client-inactive ## How to reproduce 1. Create a LINE inbox and start a chat from the LINE mobile app 2. Close the LINE mobile app 3. Send an image from Chatwoot to that chat 4. Wait 7-8 minutes (past the 5-minute URL expiration) 5. Open the LINE mobile app — the image is broken/expired ## What changed - **`originalContentUrl`**: switched from `download_url` (signed, 5-min expiry) to `file_url` (permanent redirect-based URL) - **`previewImageUrl`**: switched to `thumb_url` (250px resized thumbnail meeting LINE's 1MB/240x240 recommendation), with fallback to `file_url` for non-image attachments like video 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Sojan Jose --- app/services/line/send_on_line_service.rb | 9 +++++++-- spec/services/line/send_on_line_service_spec.rb | 16 ++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/services/line/send_on_line_service.rb b/app/services/line/send_on_line_service.rb index b0b6d828d..3c9d6cf17 100644 --- a/app/services/line/send_on_line_service.rb +++ b/app/services/line/send_on_line_service.rb @@ -44,10 +44,15 @@ class Line::SendOnLineService < Base::SendOnChannelService # Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message next unless attachment.file_type == 'image' || attachment.file_type == 'video' + # Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes). + # LINE mobile app lazy-loads images and may fetch them well after the message is sent. + original_url = attachment.file_url + preview_url = attachment.thumb_url.presence || original_url + { type: attachment.file_type, - originalContentUrl: attachment.download_url, - previewImageUrl: attachment.download_url + originalContentUrl: original_url, + previewImageUrl: preview_url } end end diff --git a/spec/services/line/send_on_line_service_spec.rb b/spec/services/line/send_on_line_service_spec.rb index a7520b8d8..4451a53b9 100644 --- a/spec/services/line/send_on_line_service_spec.rb +++ b/spec/services/line/send_on_line_service_spec.rb @@ -161,7 +161,9 @@ describe Line::SendOnLineService do it 'sends the message with text and attachments' do attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') - expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png} + attachment.save! + expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png} + expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png} expect(line_client).to receive(:push_message).with( message.conversation.contact_inbox.source_id, @@ -169,8 +171,8 @@ describe Line::SendOnLineService do { type: 'text', text: message.content }, { type: 'image', - originalContentUrl: match(expected_url_regex), - previewImageUrl: match(expected_url_regex) + originalContentUrl: match(expected_original_url_regex), + previewImageUrl: match(expected_preview_url_regex) } ] ) @@ -181,16 +183,18 @@ describe Line::SendOnLineService do it 'sends the message with attachments only' do attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.save! message.update!(content: nil) - expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png} + expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png} + expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png} expect(line_client).to receive(:push_message).with( message.conversation.contact_inbox.source_id, [ { type: 'image', - originalContentUrl: match(expected_url_regex), - previewImageUrl: match(expected_url_regex) + originalContentUrl: match(expected_original_url_regex), + previewImageUrl: match(expected_preview_url_regex) } ] ) From 65867b8b36bdeffa630d8db00e53ebd62d9af10e Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 1 Apr 2026 18:02:19 +0530 Subject: [PATCH 5/8] fix: exclude MutexApplicationJob::LockAcquisitionError from Sentry (#13965) ## Summary - Add `MutexApplicationJob::LockAcquisitionError` to Sentry's `excluded_exceptions` - This error is expected control flow (mutex lock contention during webhook processing), not a bug - Generated ~131K Sentry events in March 2026, 100% from `InstagramEventsJob` Fixes https://linear.app/chatwoot/issue/INF-58 --- config/initializers/sentry.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index ae21d7f61..eff36bfc5 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present? # We recommend adjusting the value in production: config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS'] - config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException'] + config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError'] # to track post data in sentry config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII'] From 7b09b033ef2f82801b633dd1869fd91a3428a4b1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:02:21 +0530 Subject: [PATCH 6/8] fix: Markdown tables don't render properly in help centre (#13971) # Pull Request Template ## Description This PR fixes an issue where markdown tables were not rendering correctly in the Help Center. The issue was caused by a backslash `(\)` being appended after table row separators `(|)`, which breaks the markdown table parsing. The issue was introduced after recent editor changes made to preserve new lines, which unintentionally affected how table markdown is parsed and displayed. ### https://github.com/chatwoot/prosemirror-schema/pull/44 Fixes https://linear.app/chatwoot/issue/CW-6714/markdown-tables-dont-render-properly-in-help-centre-preview ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Before** ``` | Type | What you provide |\ |--------------|-------------------------------|\ | None | No authentication |\ | Bearer Token | A token string |\ | Basic Auth | Username and password |\ | API Key | A custom header name and value| ``` **After** ``` | Type | What you provide | |--------------|-------------------------------| | None | No authentication | | Bearer Token | A token string | | Basic Auth | Username and password | | API Key | A custom header name and value| ``` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c8a1b7fdf..ddb6c09cc 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.8", + "@chatwoot/prosemirror-schema": "1.3.9", "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48edce442..cb4b2b148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.8 - version: 1.3.8 + specifier: 1.3.9 + version: 1.3.9 '@chatwoot/utils': specifier: ^0.0.52 version: 0.0.52 @@ -454,8 +454,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.8': - resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==} + '@chatwoot/prosemirror-schema@1.3.9': + resolution: {integrity: sha512-nbzvW4Rfe7EC+tHF/wWJK5pIxRzfQj/DDAtZI7pwM9uJfv9yQz6bAUCA7kz7Vq1NF29XOisZaT5W0005ygk1pg==} '@chatwoot/utils@0.0.52': resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} @@ -4966,7 +4966,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.8': + '@chatwoot/prosemirror-schema@1.3.9': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0 From 211fb1102dd208daee414cff1b8d71ea27ac5ebf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 2 Apr 2026 11:26:29 +0530 Subject: [PATCH 7/8] chore: rotate oauth password if unconfirmed (#13878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user signs up with an email they don't own and sets a password, that password remains valid even after the real owner later signs in via OAuth. This means the original registrant — who never proved ownership of the email — retains working credentials on the account. This change closes that gap by rotating the password to a random value whenever an unconfirmed user completes an OAuth sign-in. The check (`oauth_user_needs_password_reset?`) is evaluated before `skip_confirmation!` runs, since confirmation would flip `confirmed_at` and mask the condition. If the user was unconfirmed, the stored password is replaced with a secure random string that satisfies the password policy. This applies to both the web and mobile OAuth callback paths, as well as the sign-up path where the password is rotated before the reset token is generated. Users who lose access to password-based login as a side effect can recover through the standard "Forgot password" flow at any time. Since they've already proven email ownership via OAuth, this is a low-friction recovery path --- .../omniauth_callbacks_controller.rb | 18 ++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) 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/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb index 603458a01..35bae8e0b 100644 --- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do expect(response).to have_http_status(:ok) end end + + it 'resets password for an unconfirmed persisted user on OAuth login' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false) + original_password_digest = user.encrypted_password + set_omniauth_config('unconfirmed-oauth@example.com') + + get '/omniauth/google_oauth2/callback' + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + user.reload + expect(user).to be_confirmed + expect(user.encrypted_password).not_to eq(original_password_digest) + end + end end end From 8daf6cf6cbba1246f98a59ce474b6bd633646f46 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:40:11 +0530 Subject: [PATCH 8/8] feat: captain custom tools v1 (#13890) # Pull Request Template ## Description Adds custom tool support to v1 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. CleanShot 2026-03-24 at 11 37 33@2x CleanShot 2026-03-24 at 11 38 18@2x CleanShot 2026-03-24 at 11 38
32@2x ## Checklist: - [x] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Shivam Mishra --- .../dashboard/api/captain/customTools.js | 6 ++ .../customTool/CustomToolCard.vue | 9 +- .../customTool/CustomToolForm.vue | 86 +++++++++++++++++-- .../emptyStates/CustomToolsPageEmptyState.vue | 12 +++ .../components-next/sidebar/Sidebar.vue | 30 +++++-- app/javascript/dashboard/featureFlags.js | 1 + .../i18n/locale/en/integrations.json | 10 ++- .../dashboard/captain/captain.routes.js | 2 +- .../routes/dashboard/captain/tools/Index.vue | 22 +++-- config/locales/en.yml | 1 + config/routes.rb | 4 +- .../captain/custom_tools_controller.rb | 24 +++++- enterprise/app/models/captain/custom_tool.rb | 27 ++++-- enterprise/app/models/concerns/toolable.rb | 31 ++++--- .../policies/captain/custom_tool_policy.rb | 4 + .../captain/llm/assistant_chat_service.rb | 22 ++++- .../captain/llm/system_prompts_service.rb | 15 +++- .../captain/tools/custom_http_tool.rb | 47 ++++++++++ .../reconcile_plan_features_service.rb | 2 +- .../models/captain/_custom_tool.json.jbuilder | 2 +- .../captain/custom_tools_controller_spec.rb | 7 +- 21 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 enterprise/app/services/captain/tools/custom_http_tool.rb 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; + } +};