From bec795f764d805d2a49d4a27553ad819aabc84f3 Mon Sep 17 00:00:00 2001 From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:43:22 +0530 Subject: [PATCH 01/56] Bump version to 4.14.2 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index d2b9909a9..0fb7a35b6 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.1 +4.14.2 diff --git a/config/app.yml b/config/app.yml index fec34cd07..c2494befa 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.14.1' + version: '4.14.2' development: <<: *shared diff --git a/package.json b/package.json index 41313c8b3..bc51bbd85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.14.1", + "version": "4.14.2", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From c6e5657ee52192112c9913d1de797bcd86b5be8a Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:16:41 +0530 Subject: [PATCH 02/56] fix(call): emit assignee activity message when agent answers a call (#14700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket - https://linear.app/chatwoot/issue/CW-7249/debug-assignment-log ## Description When an agent answers an inbound WhatsApp call on an unassigned conversation, the conversation is claimed for that agent — but no "assigned" activity message or assignee-changed event was emitted, so the assignment was invisible in the timeline (and notifications/live assignee panel didn't update). The claim ran before `update_conversation_call_status`, so that later `update!` on the same conversation clobbered `saved_change_to_assignee_id?` before `after_commit` fired. Moving the claim to be the conversation's final write in the transaction restores the activity message, the `ASSIGNEE_CHANGED` event, and the live assignee update. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? 1. Open an unassigned WhatsApp-call conversation. 2. As an agent, answer an inbound call. 3. Before: the conversation is assigned to you, but no "self-assigned" activity message appears. After: the activity message is created and the assignee updates live. ## 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 --- enterprise/app/services/whatsapp/call_service.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index 93eba957c..743409839 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -9,7 +9,7 @@ class Whatsapp::CallService call.with_lock do transition_to_in_progress! update_message_status('in_progress') - update_conversation_call_status(call.display_status) + claim_conversation_and_set_call_status broadcast(:accepted, accepted_by_agent_id: agent.id) end call @@ -56,7 +56,6 @@ class Whatsapp::CallService forward_answer_to_meta! call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current, meta: (call.meta || {}).merge('sdp_answer' => sdp_answer)) - claim_conversation_for_agent end def forward_answer_to_meta! @@ -64,9 +63,13 @@ class Whatsapp::CallService invoke_provider!(:accept_call, sdp_answer) end - # Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI). - def claim_conversation_for_agent - call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank? + # Claim an unheld conversation and set call_status in one save so previous_changes carries both the + # assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook). + def claim_conversation_and_set_call_status + conversation = call.conversation + attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) } + attrs[:assignee] = agent if conversation.assignee_id.blank? + conversation.update!(attrs) end # Raise on Meta failure (bool false or transport error) so callers bail before From 4d3196b02fd7b1046ff22a71ea1d1059f69e3193 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Thu, 11 Jun 2026 13:05:52 +0530 Subject: [PATCH 03/56] feat: Show unread count for all conversations (#14627) ## Description Adds the unread count for All Conversations to the left sidebar. The unread counts API now returns an `all_count` aggregate based on the same permission-scoped inbox counts already used for sidebar badges, and the sidebar renders that backend-provided value on the All Conversations item. Fixes [CW-7240](https://linear.app/chatwoot/issue/CW-7240/unread-count-for-all-conversations) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - `bundle exec rspec spec/services/conversations/unread_counts/counter_spec.rb spec/enterprise/services/conversations/unread_counts/counter_spec.rb spec/controllers/api/v1/accounts/conversations_controller_spec.rb` - `pnpm test app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js` - `pnpm exec eslint app/javascript/dashboard/components-next/sidebar/Sidebar.vue app/javascript/dashboard/store/modules/conversationUnreadCounts.js app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js` - `bundle exec rubocop app/services/conversations/unread_counts/counter.rb spec/services/conversations/unread_counts/counter_spec.rb spec/enterprise/services/conversations/unread_counts/counter_spec.rb spec/controllers/api/v1/accounts/conversations_controller_spec.rb` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] 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 - [x] 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 Co-authored-by: Muhsin Keloth --- .../components-next/sidebar/Sidebar.vue | 4 ++++ .../store/modules/conversationUnreadCounts.js | 14 ++++++++++++-- .../conversationUnreadCounts/actions.spec.js | 1 + .../conversationUnreadCounts/getters.spec.js | 15 +++++++++++++++ .../conversationUnreadCounts/mutations.spec.js | 16 +++++++++++++++- .../conversations/unread_counts/counter.rb | 7 +++++-- .../v1/accounts/conversations_controller_spec.rb | 1 + .../conversations/unread_counts/counter_spec.rb | 5 ++++- .../conversations/unread_counts/counter_spec.rb | 3 +++ 9 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index d9f523dfc..f71652ca0 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd); const inboxes = useMapGetter('inboxes/getInboxes'); const labels = useMapGetter('labels/getLabelsOnSidebar'); +const allUnreadCount = useMapGetter( + 'conversationUnreadCounts/getAllUnreadCount' +); const getInboxUnreadCount = useMapGetter( 'conversationUnreadCounts/getInboxUnreadCount' ); @@ -297,6 +300,7 @@ const menuItems = computed(() => { { name: 'All', label: t('SIDEBAR.ALL_CONVERSATIONS'), + badgeCount: allUnreadCount.value, activeOn: ['inbox_conversation'], to: accountScopedRoute('home'), }, diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js index 0503c0806..c03249311 100644 --- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js +++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js @@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations'; import types from '../mutation-types'; export const state = { + allCount: 0, inboxes: {}, labels: {}, teams: {}, }; +const normalizeCount = count => { + const parsedCount = Number(count); + return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0; +}; + const normalizeCounts = counts => { return Object.entries(counts || {}).reduce((result, [id, count]) => { - const parsedCount = Number(count); - if (Number.isFinite(parsedCount) && parsedCount > 0) { + const parsedCount = normalizeCount(count); + if (parsedCount > 0) { result[String(id)] = parsedCount; } @@ -19,6 +25,9 @@ const normalizeCounts = counts => { }; export const getters = { + getAllUnreadCount($state) { + return $state.allCount; + }, getInboxUnreadCount: $state => inboxId => { return $state.inboxes[String(inboxId)] || 0; }, @@ -55,6 +64,7 @@ export const actions = { export const mutations = { [types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) { + $state.allCount = normalizeCount(payload.all_count); $state.inboxes = normalizeCounts(payload.inboxes); $state.labels = normalizeCounts(payload.labels); $state.teams = normalizeCounts(payload.teams); diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js index 3100cdd10..29fadc897 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js @@ -15,6 +15,7 @@ describe('#actions', () => { describe('#get', () => { it('commits unread counts when API is successful', async () => { const payload = { + all_count: 2, inboxes: { 1: '2' }, labels: { 3: 4 }, teams: { 5: 6 }, diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js index a3e74fc37..9fe19e22d 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js @@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts'; describe('#getters', () => { it('returns inbox unread count by id', () => { const state = { + allCount: 0, inboxes: { 1: 2 }, labels: {}, teams: {}, @@ -15,6 +16,7 @@ describe('#getters', () => { it('returns label unread count by id', () => { const state = { + allCount: 0, inboxes: {}, labels: { 3: 4 }, teams: {}, @@ -27,6 +29,7 @@ describe('#getters', () => { it('returns team unread count by id', () => { const state = { + allCount: 0, inboxes: {}, labels: {}, teams: { 5: 6 }, @@ -37,8 +40,20 @@ describe('#getters', () => { expect(getters.getTeamUnreadCount(state)(6)).toBe(0); }); + it('returns all unread count', () => { + const state = { + allCount: 7, + inboxes: {}, + labels: {}, + teams: {}, + }; + + expect(getters.getAllUnreadCount(state)).toBe(7); + }); + it('returns unread count maps', () => { const state = { + allCount: 0, inboxes: { 1: 2 }, labels: { 3: 4 }, teams: { 5: 6 }, diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js index 3f7e2b1ec..8941d7430 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js @@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts'; describe('#mutations', () => { describe('#SET_CONVERSATION_UNREAD_COUNTS', () => { it('normalizes unread count payload', () => { - const state = { inboxes: {}, labels: {}, teams: {} }; + const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} }; mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { + all_count: '3', inboxes: { 1: '2', 2: 0, @@ -23,6 +24,7 @@ describe('#mutations', () => { }); expect(state).toEqual({ + allCount: 3, inboxes: { 1: 2 }, labels: { 4: 5 }, teams: { 6: 7 }, @@ -31,6 +33,7 @@ describe('#mutations', () => { it('clears counts when payload is empty', () => { const state = { + allCount: 2, inboxes: { 1: 2 }, labels: { 4: 5 }, teams: { 6: 7 }, @@ -39,10 +42,21 @@ describe('#mutations', () => { mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {}); expect(state).toEqual({ + allCount: 0, inboxes: {}, labels: {}, teams: {}, }); }); + + it('normalizes invalid aggregate counts to zero', () => { + const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} }; + + mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { + all_count: 'invalid', + }); + + expect(state.allCount).toBe(0); + }); }); }); diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb index f4126b61f..b1ba5ddb0 100644 --- a/app/services/conversations/unread_counts/counter.rb +++ b/app/services/conversations/unread_counts/counter.rb @@ -19,8 +19,11 @@ class Conversations::UnreadCounts::Counter ensure_base_cache! ensure_assignment_cache! if assignment_mode? + inbox_counts = unread_inbox_counts + { - inboxes: unread_inbox_counts, + all_count: inbox_counts.values.sum, + inboxes: inbox_counts, labels: unread_label_counts, teams: unread_team_counts } @@ -191,7 +194,7 @@ class Conversations::UnreadCounts::Counter end def empty_counts - { inboxes: {}, labels: {}, teams: {} } + { all_count: 0, inboxes: {}, labels: {}, teams: {} } end def store diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index f8fd446d2..bdb8117ac 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) expect(response.parsed_body['payload']).to eq( + 'all_count' => 1, 'inboxes' => { visible_inbox.id.to_s => 1 }, 'labels' => { label.id.to_s => 1 }, 'teams' => {} diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb index cbb2d6c98..e8f850e78 100644 --- a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb +++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb @@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform + expect(result[:all_count]).to eq(2) expect(result[:inboxes]).to eq(inbox.id.to_s => 2) expect(result[:labels]).to eq(label.id.to_s => 2) expect(result[:teams]).to eq(team.id.to_s => 2) @@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform + expect(result[:all_count]).to eq(2) expect(result[:inboxes]).to eq(inbox.id.to_s => 2) expect(result[:labels]).to eq(label.id.to_s => 2) expect(result[:teams]).to eq(team.id.to_s => 2) @@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform + expect(result[:all_count]).to eq(1) expect(result[:inboxes]).to eq(inbox.id.to_s => 1) expect(result[:labels]).to eq(label.id.to_s => 1) expect(result[:teams]).to eq(team.id.to_s => 1) @@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform - expect(result).to eq(inboxes: {}, labels: {}, teams: {}) + expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {}) expect(store.base_ready?(account.id)).to be(false) expect(store.assignment_ready?(account.id)).to be(false) end diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb index bfd10436e..723f2d437 100644 --- a/spec/services/conversations/unread_counts/counter_spec.rb +++ b/spec/services/conversations/unread_counts/counter_spec.rb @@ -62,6 +62,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform expect(result).to eq( + all_count: 1, inboxes: { visible_inbox.id.to_s => 1 }, labels: { label.id.to_s => 1 }, teams: { visible_team.id.to_s => 1 } @@ -75,6 +76,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: admin).perform expect(result).to eq( + all_count: 2, inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 }, labels: { label.id.to_s => 2 }, teams: { visible_team.id.to_s => 2 } @@ -87,6 +89,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do result = described_class.new(account: account, user: agent).perform expect(result).to eq( + all_count: 1, inboxes: { visible_inbox.id.to_s => 1 }, labels: {}, teams: { visible_team.id.to_s => 1 } From 940428c10e5dd23e7dfb195038dd540231845bce Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:47:08 +0530 Subject: [PATCH 04/56] fix: preserve markdown links with unsafe href characters in help center content (#14686) --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bc51bbd85..09eb084f5 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.19", + "@chatwoot/prosemirror-schema": "1.3.21", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68e667953..e843f8b09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.19 - version: 1.3.19 + specifier: 1.3.21 + version: 1.3.21 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.19': - resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==} + '@chatwoot/prosemirror-schema@1.3.21': + resolution: {integrity: sha512-Y/OfXH1orK14foRcMrUees8lDjnDS9qZylVMyrstbfNyP5hbkBofsGAi6SP+5oIbPZ8iSG0sJyXtscoXpu/OFw==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.19': + '@chatwoot/prosemirror-schema@1.3.21': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 From ce93ddec7876b1d291b8b2185e63ab0083376793 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 11 Jun 2026 14:09:13 +0530 Subject: [PATCH 05/56] fix: re-fetch widget conversation status on reconnect (#14683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an inbox has **"Allow messages after conversation is resolved"** disabled, the live-chat widget should hide the reply box once a conversation is resolved — but users could still send a reply, silently reopening it. This syncs the widget's conversation status on reconnect so the reply box hides correctly. ## Closes - CW-7272 ## How to reproduce 1. Disable **Allow messages after conversation is resolved** on an inbox. 2. Open the widget, then let its websocket drop (background tab / lose network). 3. While disconnected, get the conversation resolved (e.g. auto-resolve on inactivity). 4. Reconnect → reply box is still visible and a sent message reopens the resolved conversation. ## Why Reply-box hiding is gated on the widget's local status, updated via the `conversation.status_changed` socket event. If the resolve happens while disconnected, the event is missed — and on reconnect the widget only re-synced messages, never the status, so it stayed stale at `open`. ## What changed - `onReconnect` now also dispatches `conversationAttributes/getAttributes` to refresh status after a missed event. - Added a spec covering the reconnect behavior. Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- app/javascript/widget/helpers/actionCable.js | 3 ++ .../widget/helpers/specs/actionCable.spec.js | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 app/javascript/widget/helpers/specs/actionCable.spec.js diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index 60c379ed8..546317018 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector { onReconnect = () => { this.syncLatestMessages(); + // Re-fetch conversation attributes so a status change (e.g. auto-resolve) + // that happened while disconnected is reflected, keeping the reply box state correct. + this.app.$store.dispatch('conversationAttributes/getAttributes'); }; setLastMessageId = () => { diff --git a/app/javascript/widget/helpers/specs/actionCable.spec.js b/app/javascript/widget/helpers/specs/actionCable.spec.js new file mode 100644 index 000000000..6aa3872bf --- /dev/null +++ b/app/javascript/widget/helpers/specs/actionCable.spec.js @@ -0,0 +1,53 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import ActionCableConnector from '../actionCable'; + +vi.mock('@rails/actioncable', () => ({ + createConsumer: () => ({ + subscriptions: { create: () => ({}) }, + disconnect: vi.fn(), + }), +})); + +describe('Widget ActionCableConnector', () => { + let app; + let mockDispatch; + let connector; + + beforeEach(() => { + vi.useFakeTimers(); + mockDispatch = vi.fn(); + app = { + $store: { + dispatch: mockDispatch, + getters: { + getCurrentAccountId: 1, + getCurrentUserID: 1, + }, + }, + }; + connector = new ActionCableConnector(app, 'test-token'); + mockDispatch.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('registers the conversation.status_changed event handler', () => { + expect(connector.events['conversation.status_changed']).toBe( + connector.onStatusChange + ); + }); + + it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => { + connector.onReconnect(); + + expect(mockDispatch).toHaveBeenCalledWith( + 'conversation/syncLatestMessages' + ); + expect(mockDispatch).toHaveBeenCalledWith( + 'conversationAttributes/getAttributes' + ); + }); +}); From 8d5d02ea972bbaa6d2e7bc445a8b902f94953e8c Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:22:44 +0530 Subject: [PATCH 06/56] feat: add per-inbox toggle to disable incoming calls (#14645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds a per-inbox "Allow incoming calls" toggle for voice-enabled WhatsApp and Twilio inboxes. When turned off, the setting is persisted on the channel; actually rejecting inbound calls is handled in a follow-up PR. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Screenshot Screenshot 2026-06-04 at 11 44 07 AM ## 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 --- app/javascript/dashboard/api/inboxes.js | 6 +++ .../dashboard/i18n/locale/en/inboxMgmt.json | 4 ++ .../settingsPage/VoiceConfigurationPage.vue | 42 +++++++++++++++ .../settingsPage/WhatsappCallingPage.vue | 41 ++++++++++++++ app/models/channel/twilio_sms.rb | 6 +++ app/models/channel/whatsapp.rb | 5 ++ app/policies/inbox_policy.rb | 4 ++ app/views/api/v1/models/_inbox.json.jbuilder | 6 ++- config/routes.rb | 1 + ...d_provider_config_to_channel_twilio_sms.rb | 5 ++ db/schema.rb | 3 +- .../api/v1/accounts/inboxes_controller.rb | 28 ++++++++++ .../controllers/twilio/voice_controller.rb | 12 +++++ .../whatsapp/incoming_call_service.rb | 6 +++ .../v1/accounts/inboxes_controller_spec.rb | 53 +++++++++++++++++++ .../twilio/voice_controller_spec.rb | 17 ++++++ .../models/channel/twilio_sms_voice_spec.rb | 13 +++++ .../whatsapp/incoming_call_service_spec.rb | 14 +++++ spec/models/channel/whatsapp_spec.rb | 18 +++++++ 19 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260604000000_add_provider_config_to_channel_twilio_sms.rb diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 114dbb6f4..3c1d17fc8 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient { disableWhatsappCalling(inboxId) { return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); } + + setInboundCalls(inboxId, enabled) { + return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, { + inbound_calls_enabled: enabled, + }); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index cd7e46330..574cf5b9b 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -653,6 +653,10 @@ }, "CREDENTIALS": { "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." + }, + "INBOUND": { + "LABEL": "Allow incoming calls", + "DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls." } }, "WHATSAPP_CALLING": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue index b0fe858e5..b5f3183f1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue @@ -1,9 +1,11 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index 75eb8a2f8..04de7bda2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import AccessToken from './AccessToken.vue'; import MfaSettingsCard from './MfaSettingsCard.vue'; +import ActiveSessions from './ActiveSessions.vue'; import Policy from 'dashboard/components/policy.vue'; import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; import { @@ -42,6 +43,7 @@ export default { AudioNotifications, AccessToken, MfaSettingsCard, + ActiveSessions, BaseSettingsHeader, }, setup() { @@ -307,6 +309,13 @@ export default { > + + + e + Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}" + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4aa38bbcd..729f674d3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,6 +101,7 @@ class User < ApplicationRecord has_many :messages, as: :sender, dependent: :nullify has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify + has_many :user_sessions, dependent: :destroy has_many :custom_filters, dependent: :destroy_async has_many :dashboard_apps, dependent: :nullify has_many :mentions, dependent: :destroy_async @@ -118,6 +119,7 @@ class User < ApplicationRecord before_validation :set_password_and_uid, on: :create after_destroy :remove_macros + after_save :sync_user_sessions, if: :saved_change_to_tokens? scope :order_by_full_name, -> { order('lower(name) ASC') } @@ -214,6 +216,11 @@ class User < ApplicationRecord private + def sync_user_sessions + active_client_ids = (tokens || {}).keys + user_sessions.where.not(client_id: active_client_ids).destroy_all + end + def remove_macros macros.personal.destroy_all end diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 000000000..0f2503382 --- /dev/null +++ b/app/models/user_session.rb @@ -0,0 +1,46 @@ +# == Schema Information +# +# Table name: user_sessions +# +# id :bigint not null, primary key +# browser_name :string +# browser_version :string +# city :string +# country :string +# country_code :string +# device_name :string +# ip_address :string +# last_activity_at :datetime +# platform_name :string +# platform_version :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# client_id :string not null +# user_id :bigint not null +# +# Indexes +# +# index_user_sessions_on_user_id (user_id) +# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# + +class UserSession < ApplicationRecord + ACTIVITY_THROTTLE = 5.minutes + + belongs_to :user + + validates :client_id, presence: true, uniqueness: { scope: :user_id } + + def current?(active_client_id) + client_id == active_client_id + end + + def should_update_activity? + last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago + end +end diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb new file mode 100644 index 000000000..28f272a18 --- /dev/null +++ b/app/services/user_session_tracking_service.rb @@ -0,0 +1,39 @@ +class UserSessionTrackingService + def initialize(user:, request:, client_id:) + @user = user + @request = request + @client_id = client_id + end + + def create_or_update! + session = @user.user_sessions.find_or_initialize_by(client_id: @client_id) + session.assign_attributes(session_attributes) + session.last_activity_at = Time.current + session.save! + UserSessionIpLookupJob.perform_later(session) if session.ip_address.present? + session + end + + def update_activity! + session = @user.user_sessions.find_by(client_id: @client_id) + return unless session&.should_update_activity? + + session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + private + + def session_attributes + browser = Browser.new(@request.user_agent) + + { + ip_address: @request.remote_ip, + user_agent: @request.user_agent, + browser_name: browser.name, + browser_version: browser.full_version, + device_name: browser.device.name, + platform_name: browser.platform.name, + platform_version: browser.platform.version + } + end +end diff --git a/app/views/api/v1/profile/sessions/index.json.jbuilder b/app/views/api/v1/profile/sessions/index.json.jbuilder new file mode 100644 index 000000000..b009271e0 --- /dev/null +++ b/app/views/api/v1/profile/sessions/index.json.jbuilder @@ -0,0 +1,15 @@ +json.array! @sessions do |session| + json.id session.id + json.browser_name session.browser_name + json.browser_version session.browser_version + json.device_name session.device_name + json.platform_name session.platform_name + json.platform_version session.platform_version + json.ip_address session.ip_address + json.city session.city + json.country session.country + json.country_code session.country_code + json.last_activity_at session.last_activity_at + json.created_at session.created_at + json.current session.current?(@current_client_id) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8b8202f1c..826894466 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -47,6 +47,10 @@ en: saml_not_available: SAML authentication is not available in this installation. inbox_deletetion_response: Your inbox deletion request will be processed in some time. + profile_settings: + sessions: + cannot_revoke_current: You cannot revoke the current session. + errors: account: reporting_timezone: diff --git a/config/routes.rb b/config/routes.rb index 885b69062..f86e3f2cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -437,6 +437,7 @@ Rails.application.routes.draw do post :verify post :backup_codes end + resources :sessions, only: [:index, :destroy] end end diff --git a/db/migrate/20260611184600_create_user_sessions.rb b/db/migrate/20260611184600_create_user_sessions.rb new file mode 100644 index 000000000..96ad2821e --- /dev/null +++ b/db/migrate/20260611184600_create_user_sessions.rb @@ -0,0 +1,23 @@ +class CreateUserSessions < ActiveRecord::Migration[7.0] + def change + create_table :user_sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :client_id, null: false + t.string :ip_address + t.string :user_agent + t.string :browser_name + t.string :browser_version + t.string :device_name + t.string :platform_name + t.string :platform_version + t.string :city + t.string :country + t.string :country_code + t.datetime :last_activity_at + + t.timestamps + end + + add_index :user_sessions, [:user_id, :client_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index eb6b82508..2060bcfda 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_06_04_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1253,6 +1253,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true end + create_table "user_sessions", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "client_id", null: false + t.string "ip_address" + t.string "user_agent" + t.string "browser_name" + t.string "browser_version" + t.string "device_name" + t.string "platform_name" + t.string "platform_version" + t.string "city" + t.string "country" + t.string "country_code" + t.datetime "last_activity_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true + t.index ["user_id"], name: "index_user_sessions_on_user_id" + end + create_table "users", id: :serial, force: :cascade do |t| t.string "provider", default: "email", null: false t.string "uid", default: "", null: false @@ -1325,6 +1345,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "inboxes", "portals" + add_foreign_key "user_sessions", "users" create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1). on("accounts"). after(:insert). diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb index 8ee012670..7e12011b2 100644 --- a/spec/controllers/devise_overrides/sessions_controller_spec.rb +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -163,4 +163,21 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do expect(response).to redirect_to('/frontend/app/login?error=access-denied') end end + + describe 'session tracking' do + let(:user) { create(:user, password: 'Test@123456') } + let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' } + + context 'with a successful login' do + before { request.env['HTTP_USER_AGENT'] = browser_ua } + + it 'creates a UserSession row for the new client_id' do + expect { post :create, params: { email: user.email, password: 'Test@123456' } }.to change(user.user_sessions, :count).by(1) + + session = user.user_sessions.last + expect(session.browser_name).to eq('Safari') + expect(session.platform_name).to eq('macOS') + end + end + end end diff --git a/spec/jobs/user_session_ip_lookup_job_spec.rb b/spec/jobs/user_session_ip_lookup_job_spec.rb new file mode 100644 index 000000000..8d7d08a33 --- /dev/null +++ b/spec/jobs/user_session_ip_lookup_job_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe UserSessionIpLookupJob do + let(:user) { create(:user) } + let(:session) { user.user_sessions.create!(client_id: 'c', ip_address: '8.8.8.8', last_activity_at: Time.current) } + let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') } + let(:ip_lookup) { instance_double(IpLookupService) } + + before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) } + + it 'backfills geo data on the session' do + allow(ip_lookup).to receive(:perform).with('8.8.8.8').and_return(geo_result) + + described_class.perform_now(session) + + session.reload + expect(session.city).to eq('Mountain View') + expect(session.country).to eq('United States') + expect(session.country_code).to eq('US') + end + + it 'is a no-op when ip_address is blank' do + session.update_columns(ip_address: nil) # rubocop:disable Rails/SkipsModelValidations + + described_class.perform_now(session) + + expect(IpLookupService).not_to have_received(:new) + end + + it 'leaves the session untouched when lookup returns nil' do + allow(ip_lookup).to receive(:perform).and_return(nil) + + described_class.perform_now(session) + + session.reload + expect(session.city).to be_nil + expect(session.country).to be_nil + end + + it 'swallows lookup errors so a flaky geocoder does not poison the queue' do + allow(ip_lookup).to receive(:perform).and_raise(StandardError.new('boom')) + + expect { described_class.perform_now(session) }.not_to raise_error + end +end diff --git a/spec/models/user_session_spec.rb b/spec/models/user_session_spec.rb new file mode 100644 index 000000000..0c8fa6cf1 --- /dev/null +++ b/spec/models/user_session_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe UserSession do + let(:user) { create(:user) } + + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + subject { described_class.new(user: user, client_id: 'abc') } + + it { is_expected.to validate_presence_of(:client_id) } + + it 'validates uniqueness of client_id scoped to user_id' do + described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) + + duplicate = described_class.new(user: user, client_id: 'abc') + expect(duplicate).not_to be_valid + expect(duplicate.errors[:client_id]).to be_present + end + + it 'allows the same client_id for different users' do + other = create(:user) + described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) + + expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid + end + end + + describe '#current?' do + let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) } + + it 'returns true when client_id matches' do + expect(session.current?('abc')).to be true + end + + it 'returns false when client_id differs' do + expect(session.current?('xyz')).to be false + end + end + + describe '#should_update_activity?' do + let(:session) { described_class.new(user: user, client_id: 'abc') } + + it 'returns true when last_activity_at is nil' do + session.last_activity_at = nil + expect(session.should_update_activity?).to be true + end + + it 'returns true when last_activity_at is older than the throttle window' do + session.last_activity_at = 10.minutes.ago + expect(session.should_update_activity?).to be true + end + + it 'returns false when last_activity_at is within the throttle window' do + session.last_activity_at = 1.minute.ago + expect(session.should_update_activity?).to be false + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d263708af..fc7a8953b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -254,4 +254,37 @@ RSpec.describe User do end end end + + describe 'sync_user_sessions callback' do + let(:user_with_tokens) do + u = create(:user) + u.tokens = { + 'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }, + 'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i } + } + u.save! + u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current) + u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current) + u + end + + it 'destroys user_sessions whose client_id is no longer in tokens' do + user_with_tokens.tokens = user_with_tokens.tokens.except('client-a') + + expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1) + expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b']) + end + + it 'leaves user_sessions alone when tokens did not change' do + user_with_tokens.update!(name: 'New Name') + + expect(user_with_tokens.user_sessions.count).to eq(2) + end + + it 'destroys all user_sessions when tokens is cleared' do + user_with_tokens.tokens = {} + + expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2) + end + end end diff --git a/spec/requests/api/v1/profile/sessions_controller_spec.rb b/spec/requests/api/v1/profile/sessions_controller_spec.rb new file mode 100644 index 000000000..239fa8e83 --- /dev/null +++ b/spec/requests/api/v1/profile/sessions_controller_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe 'Profile Sessions API', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:auth_headers) { user.create_new_auth_token } + let(:current_client_id) { auth_headers['client'] } + + describe 'GET /api/v1/profile/sessions' do + it 'returns 401 without auth' do + get '/api/v1/profile/sessions', as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns the current user sessions ordered by last_activity_at desc' do + older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago) + newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago) + user.update!(tokens: user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })) + + get '/api/v1/profile/sessions', headers: auth_headers, as: :json + + expect(response).to have_http_status(:success) + sessions = response.parsed_body + expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id]) + expect(sessions.find { |s| s['id'] == older.id }['current']).to be true + expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false + end + + it 'excludes sessions whose token has expired' do + live = user.user_sessions.create!(client_id: current_client_id, last_activity_at: 1.hour.ago) + expired = user.user_sessions.create!(client_id: 'expired-client', last_activity_at: 1.day.ago) + user.update!(tokens: user.tokens.merge('expired-client' => { 'token' => 'x', 'expiry' => 1.day.ago.to_i })) + + get '/api/v1/profile/sessions', headers: auth_headers, as: :json + + expect(response).to have_http_status(:success) + ids = response.parsed_body.map { |s| s['id'] } + expect(ids).to include(live.id) + expect(ids).not_to include(expired.id) + end + + it 'returns an empty array when no sessions exist' do + get '/api/v1/profile/sessions', headers: auth_headers, as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to eq([]) + end + end + + describe 'DELETE /api/v1/profile/sessions/:id' do + let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) } + + before do + # Seed tokens hash so revoke can clean it up + user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }) + user.save! + end + + it 'destroys the session and removes its token entry' do + expect do + delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json + end.to change(user.user_sessions, :count).by(-1) + + expect(response).to have_http_status(:ok) + expect(user.reload.tokens.keys).not_to include('other-client') + end + + it 'returns 422 when trying to revoke the current session' do + current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current) + + delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to be_present + expect(user.user_sessions.exists?(id: current.id)).to be true + end + + it 'returns 404 for a nonexistent session id' do + delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json + + expect(response).to have_http_status(:not_found) + end + + it 'does not allow revoking another user' do + other_user = create(:user, account: account) + foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago) + + delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json + + expect(response).to have_http_status(:not_found) + expect(other_user.user_sessions.exists?(id: foreign.id)).to be true + end + + it 'returns 401 without auth' do + delete "/api/v1/profile/sessions/#{other_session.id}", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb new file mode 100644 index 000000000..71b6d5924 --- /dev/null +++ b/spec/services/user_session_tracking_service_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe UserSessionTrackingService do + let(:user) { create(:user) } + let(:client_id) { 'client-abc' } + let(:request) do + instance_double( + ActionDispatch::Request, + user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', + remote_ip: '8.8.8.8' + ) + end + let(:service) { described_class.new(user: user, request: request, client_id: client_id) } + + describe '#create_or_update!' do + it 'creates a new UserSession with the right client_id and timestamps' do + expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1) + + session = user.user_sessions.last + expect(session.client_id).to eq(client_id) + expect(session.last_activity_at).to be_within(1.second).of(Time.current) + end + + it 'populates request and browser metadata synchronously', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.ip_address).to eq('8.8.8.8') + expect(session.browser_name).to eq('Safari') + expect(session.platform_name).to eq('macOS') + end + + it 'does not call IpLookupService synchronously' do + expect(IpLookupService).not_to receive(:new) + + service.create_or_update! + end + + it 'enqueues UserSessionIpLookupJob to backfill geo data' do + expect { service.create_or_update! }.to have_enqueued_job(UserSessionIpLookupJob) + end + + it 'updates an existing session when client_id matches' do + existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago) + + expect { service.create_or_update! }.not_to change(user.user_sessions, :count) + expect(existing.reload.ip_address).to eq('8.8.8.8') + expect(existing.last_activity_at).to be_within(1.second).of(Time.current) + end + end + + describe '#update_activity!' do + it 'does nothing when no session exists for the client_id' do + expect { service.update_activity! }.not_to change(user.user_sessions, :count) + end + + it 'does nothing when the session was recently active' do + session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago) + before_ts = session.last_activity_at + + service.update_activity! + + expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts) + end + + it 'bumps last_activity_at when the session is stale' do + session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago) + + service.update_activity! + + expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current) + end + + it 'bumps last_activity_at when last_activity_at is nil' do + session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil) + + service.update_activity! + + expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current) + end + end +end From b1c2db5435433a03744e1cbea9a993ff3ec6dfcf Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Sat, 13 Jun 2026 19:57:42 +0530 Subject: [PATCH 16/56] fix: Stabilize help center builder error spec (#14725) ## Description Stabilizes the enterprise help center article builder source URL validation spec by asserting the custom exception via its class name and message text. This keeps the spec focused on the intended behavior while avoiding brittle custom exception constant identity checks in CI/reloading environments. A bunch of builds on different PRs have been failing because of this error, sample traces are below: * https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777 * https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777 Fixes # N/A ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - `/Users/sonymathew/.rbenv/shims/bundle exec rspec spec/enterprise/services/onboarding/help_center_article_builder_spec.rb` - `/Users/sonymathew/.rbenv/shims/bundle exec rubocop spec/enterprise/services/onboarding/help_center_article_builder_spec.rb` - `git diff --check` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] Any dependent changes have been merged and published in downstream modules --- .../services/onboarding/help_center_article_builder_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb b/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb index a0aba2f91..c731f9624 100644 --- a/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb +++ b/spec/enterprise/services/onboarding/help_center_article_builder_spec.rb @@ -12,7 +12,10 @@ RSpec.describe Onboarding::HelpCenterArticleBuilder do expect(Firecrawl::Configuration).not_to receive(:client) expect { builder.perform } - .to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/) + .to raise_error(StandardError) { |error| + expect(error.class.name).to eq('Onboarding::HelpCenterErrors::ArticleBuildFailed') + expect(error.message).to include('no source urls') + } end end end From 006b529918bc57d864f8db2e86b0a858273b1166 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:49:03 +0530 Subject: [PATCH 17/56] chore(deps): bump net-imap from 0.4.24 to 0.6.4.1 (#14688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.24 to 0.6.4.1.
Release notes

Sourced from net-imap's releases.

v0.5.15

What's Changed

🔒 Security

This release fixes several more security vulnerabilities which are related to the fixes in v0.5.14. Please see the linked security advisories for more information.

  • (moderate) Command Injection via non-synchronizing literal in "raw" argument (CVE-2026-47240, GHSA-8p34-64r3-mwg8) This vulnerability depends how the server interprets non-synchronizing literals. The connection is not vulnerable if the server supports non-synchronizing literals.
  • (moderate) Command Injection via unvalidated ID and ENABLE arguments (CVE-2026-47242, GHSA-46q3-7gv7-qmgg)
  • (low) Denial of Service via incomplete "raw" argument validation (CVE-2026-47241, GHSA-c4fp-cxrr-mj66) This results in the affected command hanging until the connection is closed. If another thread attempts to send a concurrent pipelined command, the first thread will return with a syntax error and the second thread will hang until the connection closes.

Fixed

Documentation

Other Changes

Miscellaneous

Full Changelog: https://github.com/ruby/net-imap/compare/v0.5.14...v0.5.15

v0.5.14

What's Changed

🔒 Security

This release contains fixes for multiple vulnerabilities concerning STARTTLS stripping, argument validation, and denial of service attacks.

[!WARNING] ruby/net-imap#665 fixes a STARTTLS stripping vulnerability (GHSA-vcgp-9326-pqcp). Without this fix, a man-in-the-middle attacker can cause Net::IMAP#starttls to return "successfully", without starting TLS.

[!IMPORTANT] Argument validation is significantly improved. Several command injection vulnerabilities have been fixed: ruby/net-imap#662 fixes CRLF/command/argument injection via Symbol arguments (GHSA-75xq-5h9v-w6px). ruby/net-imap#662 fixes CRLF/command/argument injection via the attr argument to #store/#uid_store (GHSA-hm49-wcqc-g2xg)

... (truncated)

Commits
  • ce20fc8 🔖 Bump version to 0.5.15
  • 0b7b83c 🔀 Merge pull request #703 from ruby/backport/v0.5/security-patches
  • f22fd6c 🍒 pick 0ea9eba3 (#701): ✅ Fix flaky tests for MacOS, TruffleRuby
  • 1246074 🍒 pick ae9f83b5 (#701): ♻️ Extract str.bytesize lvar in send_literal
  • a2f61af 🍒 pick 62a0da6d (#701): 🥅 Validate non-synchronizing literals support
  • e33348c 🍒 pick d6ddd294 (#700): 🐛 Prevent trailing {0} in RawData validation
  • 4f81b69 🍒 pick 1f97168b (#699): 🥅 Validate #enable arguments are all atoms
  • 69da4a4 🍒 pick 8d9397ab (#698): 🥅 Validate QuotedString contains only valid bytes
  • 7aab580 🍒 pick e3c50fad (#698): ♻️ Refactor RawText, add improve test coverage
  • fac1733 🍒 pick aab64f92 (#686): 🧵 Fix deadlock in #disconnect
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=net-imap&package-manager=bundler&previous-version=0.4.24&new-version=0.5.15)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/chatwoot/chatwoot/network/alerts).
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 141afc122..8d6132849 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -582,7 +582,7 @@ GEM uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.24) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) From 27404b4a27f26ff745b21de6e529e3ac9050bfab Mon Sep 17 00:00:00 2001 From: Botshxlo <46230844+Botshxlo@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:23:13 +0200 Subject: [PATCH 18/56] fix: redact sensitive integration secrets from API responses (#14147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `GET /api/v1/accounts/:id/integrations/apps` endpoint returns raw secret values (OpenAI API keys, Google service account private keys, Linear refresh tokens, etc.) in hook settings within the JSON response. Although gated behind an administrator check, these secrets are visible in the browser network tab. This PR filters hook settings through the existing `visible_properties` whitelist defined in `config/integration/apps.yml`, and adds explicit whitelists to integrations that were missing them. Closes #14042 ## Bug reproduction **Setup:** Created an OpenAI integration hook with a fake API key (`sk-test-secret-12345`). **Step 1 — Browser Network tab shows raw secrets:** Screenshot 2026-04-24 at 14 32 28 Navigate to Settings > Integrations as an admin. Open DevTools Network tab and observe the response from `GET /api/v1/accounts/:id/integrations/apps`. The hook settings contain the full API key in plaintext: ```json "hooks": [ { "id": 1, "app_id": "openai", "settings": { "api_key": "sk-test-secret-12345", "label_suggestion": false } } ] ``` **Step 2 — API call confirms the leak:** ```bash curl -s "http://localhost:3000/api/v1/accounts/2/integrations/apps" \ -H "access-token: " \ -H "client: " \ -H "uid: user@test.com" \ | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}' ``` Response: ```json { "id": "openai", "name": "OpenAI", "hooks": [ { "id": 1, "app_id": "openai", "settings": { "api_key": "sk-test-secret-12345", "label_suggestion": false } } ] } ``` Any admin user can extract the raw API key from the response. The same applies to other integrations — Dialogflow exposes full Google service account credentials, Linear exposes refresh tokens, etc. ## After fix After applying this change, the GET /api/v1/accounts/:id/integrations/apps endpoint no longer returns sensitive secret values in hook settings. Instead, the response is filtered using each integration’s visible_properties whitelist, ensuring only safe, user-facing fields are exposed. For example, OpenAI integrations return non-sensitive fields like label_suggestion while excluding raw API keys. This prevents secrets from being exposed in the browser network tab or API responses, even for authenticated admin users. ```bash curl -X GET "http://localhost:3000/api/v1/accounts/2/integrations/apps" \ -H "Accept: application/json" \ -H "Authorization: Bearer eyJhY2Nlc3MtdG9rZW4iOiJqZG5jOWg4TnljaWFJa3JlZkxGQzRnIiwidG9rZW4tdHlwZSI6IkJlYXJlciIsImNsaWVudCI6Ik81bjVnTDFEOVVhbGpwbWxjaHZNanciLCJleHBpcnkiOiIxNzgyMzMyNTA4IiwidWlkIjoidXNlckB0ZXN0LmNvbSJ9" \ -H "access-token: jdnc9h8NyciaIkrefLFC4g" \ -H "client: O5n5gL1D9UaljpmlchvMjw" \ -H "uid: user@test.com" \ | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}' % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 10174 100 10174 0 0 33232 0 --:--:-- --:--:-- --:--:-- 33357 { "id": "openai", "name": "OpenAI", "hooks": [ { "id": 1, "app_id": "openai", "settings": { "label_suggestion": false } } ] } ``` ## What changed - Added `visible_properties` accessor to `Integrations::App` model to expose the whitelist from config - Updated `_hook.json.jbuilder` to filter `resource.settings` through the associated app's `visible_properties` instead of returning the full hash - Added `visible_properties` to integrations that were missing it: - Linear: `[]` (settings contain refresh_token) - Notion: `[]` (OAuth-based, no user-facing settings) - Slack: `['channel_name']` (UI needs this to display connected channel) - Shopify: `[]` (no settings) App config metadata (`_app.json.jbuilder`) is left unchanged — hook_type, settings_form_schema, etc. are not secrets and the frontend depends on them. ## How to test 1. Create an OpenAI integration hook with an API key 2. As an admin, call `GET /api/v1/accounts/:id/integrations/apps` 3. Verify hook settings include `api_key` (whitelisted) but not raw credential objects 4. For Dialogflow hooks, verify `credentials` (private key JSON) is excluded while `project_id` is included 5. For Slack hooks, verify `channel_name` is still returned 6. For Linear hooks, verify `refresh_token` is not returned --------- Co-authored-by: Botshelo Nokoane (Konstruktors) Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- app/models/integrations/app.rb | 4 + app/views/api/v1/models/_hook.json.jbuilder | 9 +- config/integration/apps.yml | 8 +- .../integrations/apps_controller_spec.rb | 101 +++++++++++++++--- spec/models/integrations/app_spec.rb | 37 +++++-- 5 files changed, 134 insertions(+), 25 deletions(-) diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 5e4d28c06..b5b0123a2 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -30,6 +30,10 @@ class Integrations::App params[:fields] end + def visible_properties + Array(params[:visible_properties]).map(&:to_s) + end + # There is no way to get the account_id from the linear callback # so we are using the generate_linear_token method to generate a token and encode it in the state parameter def encode_state diff --git a/app/views/api/v1/models/_hook.json.jbuilder b/app/views/api/v1/models/_hook.json.jbuilder index 5df214ac8..3b9b14029 100644 --- a/app/views/api/v1/models/_hook.json.jbuilder +++ b/app/views/api/v1/models/_hook.json.jbuilder @@ -5,5 +5,10 @@ json.inbox resource.inbox&.slice(:id, :name) json.account_id resource.account_id json.hook_type resource.hook_type -json.settings resource.settings if Current.account_user&.administrator? -json.reference_id resource.reference_id if Current.account_user&.administrator? +if Current.account_user&.administrator? + visible_properties = resource.app&.visible_properties || [] + settings = (resource.settings || {}).select { |key, _| visible_properties.include?(key.to_s) } + + json.settings settings + json.reference_id resource.reference_id +end diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 1a45cc098..9ef01ed30 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -6,6 +6,7 @@ # hook_type: ( account / inbox ) # feature_flag: (string) feature flag to enable/disable the integration # allow_multiple_hooks: whether multiple hooks can be created for the integration +# visible_properties: hook setting keys safe to return in API responses and show in the UI # settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/) # settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/) ######################################################## @@ -55,7 +56,7 @@ openai: 'validation': '', }, ] - visible_properties: ['api_key', 'label_suggestion'] + visible_properties: ['label_suggestion'] linear: id: linear logo: linear.png @@ -63,12 +64,14 @@ linear: action: https://linear.app/oauth/authorize hook_type: account allow_multiple_hooks: false + visible_properties: [] notion: id: notion logo: notion.png i18n_key: notion hook_type: account allow_multiple_hooks: false + visible_properties: [] slack: id: slack logo: slack.png @@ -76,6 +79,7 @@ slack: action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:read,channels:manage,channels:join,groups:read,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize,channels:history,groups:history,mpim:history,im:history,files:read,files:write hook_type: account allow_multiple_hooks: false + visible_properties: ['channel_name'] dialogflow: id: dialogflow logo: dialogflow.png @@ -240,6 +244,7 @@ shopify: i18n_key: shopify hook_type: account allow_multiple_hooks: false + visible_properties: [] leadsquared: id: leadsquared @@ -310,7 +315,6 @@ leadsquared: ] visible_properties: [ - 'access_key', 'endpoint_url', 'enable_conversation_activity', 'enable_transcript_activity', 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 bf95c9826..db8cd12f1 100644 --- a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb @@ -42,7 +42,7 @@ RSpec.describe 'Integration Apps API', type: :request do expect(app['hooks'].first['settings']).to be_nil end - it 'returns all active apps with sensitive information if user is an admin' do + it 'returns all active apps with admin metadata if user is an admin' do first_app = Integrations::App.all.find { |app| app.active?(account) } get api_v1_account_integrations_apps_url(account), headers: admin.create_new_auth_token, @@ -56,19 +56,21 @@ RSpec.describe 'Integration Apps API', type: :request do end it 'returns slack app with appropriate redirect url when configured' do - with_modified_env SLACK_CLIENT_ID: 'client_id', SLACK_CLIENT_SECRET: 'client_secret' do - get api_v1_account_integrations_apps_url(account), - headers: admin.create_new_auth_token, - as: :json + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('client_id') + allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret') - expect(response).to have_http_status(:success) - apps = response.parsed_body['payload'] - slack_app = apps.find { |app| app['id'] == 'slack' } - expect(slack_app['action']).to include('client_id=client_id') - end + get api_v1_account_integrations_apps_url(account), + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + apps = response.parsed_body['payload'] + slack_app = apps.find { |app| app['id'] == 'slack' } + expect(slack_app['action']).to include('client_id=client_id') end - it 'will return sensitive information for openai app for admins' do + it 'returns visible hook settings for openai app for admins' do openai = create(:integrations_hook, :openai, account: account) get api_v1_account_integrations_apps_url(account), headers: admin.create_new_auth_token, @@ -79,6 +81,34 @@ RSpec.describe 'Integration Apps API', type: :request do app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id } expect(app['hooks'].first['settings']).not_to be_nil end + + it 'redacts secrets and only returns visible settings for openai hooks' do + openai = create( + :integrations_hook, + :openai, + account: account, + settings: { api_key: 'sk-secret', label_suggestion: true } + ) + get api_v1_account_integrations_apps_url(account), + headers: admin.create_new_auth_token, + as: :json + + app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id } + expect(app['hooks'].first['settings']).to eq('label_suggestion' => true) + end + + it 'keeps slack channel display settings while redacting unspecified settings' do + create(:integrations_hook, account: account, settings: { channel_name: 'support', signing_secret: 'secret' }) + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret') + + get api_v1_account_integrations_apps_url(account), + headers: admin.create_new_auth_token, + as: :json + + app = response.parsed_body['payload'].find { |int_app| int_app['id'] == 'slack' } + expect(app['hooks'].first['settings']).to eq('channel_name' => 'support') + end end end @@ -117,7 +147,7 @@ RSpec.describe 'Integration Apps API', type: :request do expect(app['hooks'].first['settings']).to be_nil end - it 'will return sensitive information for openai app for admins' do + it 'returns visible hook settings for openai app for admins' do openai = create(:integrations_hook, :openai, account: account) get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id), headers: admin.create_new_auth_token, @@ -128,6 +158,53 @@ RSpec.describe 'Integration Apps API', type: :request do app = response.parsed_body expect(app['hooks'].first['settings']).not_to be_nil end + + it 'hides credentials and keeps visible settings for google credential integrations' do + hook = create(:integrations_hook, :google_translate, account: account, + settings: { project_id: 'project-1', + credentials: { private_key: 'secret' } }) + get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id), + headers: admin.create_new_auth_token, + as: :json + + app = response.parsed_body + expect(app['hooks'].first['settings']).to eq('project_id' => 'project-1') + end + + it 'returns empty settings for oauth integrations with no visible properties' do + hook = create( + :integrations_hook, + :linear, + account: account, + settings: { token_type: 'Bearer', refresh_token: 'refresh-secret', expires_in: 7200 } + ) + get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id), + headers: admin.create_new_auth_token, + as: :json + + app = response.parsed_body + expect(app['hooks'].first['settings']).to eq({}) + end + + it 'does not expose leadsquared credential keys in visible settings' do + account.enable_features('crm_integration') + hook = create(:integrations_hook, :leadsquared, account: account, + settings: { + 'access_key' => 'access-secret', + 'secret_key' => 'secret', + 'endpoint_url' => 'https://api.leadsquared.com/', + 'enable_conversation_activity' => true + }) + get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id), + headers: admin.create_new_auth_token, + as: :json + + settings = response.parsed_body['hooks'].first['settings'] + expect(settings).to eq( + 'endpoint_url' => 'https://api.leadsquared.com/', + 'enable_conversation_activity' => true + ) + end end end end diff --git a/spec/models/integrations/app_spec.rb b/spec/models/integrations/app_spec.rb index f9e7c7532..46ae56632 100644 --- a/spec/models/integrations/app_spec.rb +++ b/spec/models/integrations/app_spec.rb @@ -23,6 +23,24 @@ RSpec.describe Integrations::App do end end + describe '#visible_properties' do + context 'when the app has visible properties' do + let(:app_name) { 'dialogflow' } + + it 'returns the configured property names as strings' do + expect(app.visible_properties).to contain_exactly('project_id', 'region', 'language_code') + end + end + + context 'when the app has no visible properties configured' do + let(:app_name) { 'webhook' } + + it 'defaults to an empty list' do + expect(app.visible_properties).to eq([]) + end + end + end + describe '#action' do let(:app_name) { 'slack' } @@ -32,12 +50,13 @@ RSpec.describe Integrations::App do context 'when the app is slack' do it 'returns the action URL with client_id and redirect_uri' do - with_modified_env SLACK_CLIENT_ID: 'dummy_client_id' do - expect(app.action).to include('client_id=dummy_client_id') - expect(app.action).to include( - "/app/accounts/#{account.id}/settings/integrations/slack" - ) - end + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('dummy_client_id') + + expect(app.action).to include('client_id=dummy_client_id') + expect(app.action).to include( + "/app/accounts/#{account.id}/settings/integrations/slack" + ) end end end @@ -47,9 +66,9 @@ RSpec.describe Integrations::App do context 'when the app is slack' do it 'returns true if SLACK_CLIENT_SECRET is present' do - with_modified_env SLACK_CLIENT_SECRET: 'random_secret' do - expect(app.active?(account)).to be true - end + allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('random_secret') + + expect(app.active?(account)).to be true end end From 274e92e0e421fe4afd193ba1e1d7b68e6faa0c5f Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Sun, 14 Jun 2026 06:54:18 +0530 Subject: [PATCH 19/56] chore: collapse conversation sidebar sections (folders, teams, inboxes and labels) - CW-7059 (#14509) ## Description Added ability to collapse conversation sidebar sections (folders, teams, inboxes and labels) Fixes #CW-7059 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Tested locally. Added specs. Attaching the loom for them same. https://github.com/user-attachments/assets/40d613e7-6c82-4078-abf4-79739a00f718 ## 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 - [x] 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 --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../components-next/sidebar/Sidebar.vue | 16 ++ .../components-next/sidebar/SidebarGroup.vue | 95 +++------ .../sidebar/SidebarGroupLeaf.vue | 17 +- .../sidebar/SidebarGroupSeparator.vue | 61 +++++- .../sidebar/SidebarSubGroup.vue | 198 ++++++++++++------ .../sidebar/specs/SidebarSubGroup.spec.js | 172 +++++++++++++++ .../dashboard/constants/localStorage.js | 1 + theme/icons.js | 5 + 8 files changed, 423 insertions(+), 142 deletions(-) create mode 100644 app/javascript/dashboard/components-next/sidebar/specs/SidebarSubGroup.spec.js diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index f71652ca0..ab037618c 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -300,6 +300,7 @@ const menuItems = computed(() => { { name: 'All', label: t('SIDEBAR.ALL_CONVERSATIONS'), + icon: 'i-lucide-inbox', badgeCount: allUnreadCount.value, activeOn: ['inbox_conversation'], to: accountScopedRoute('home'), @@ -307,12 +308,14 @@ const menuItems = computed(() => { { name: 'Mentions', label: t('SIDEBAR.MENTIONED_CONVERSATIONS'), + icon: 'i-lucide-at-sign', activeOn: ['conversation_through_mentions'], to: accountScopedRoute('conversation_mentions'), }, { name: 'Participating', label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'), + icon: 'i-lucide-user-round-check', activeOn: ['conversation_through_participating'], to: accountScopedRoute('conversation_participating'), }, @@ -320,6 +323,7 @@ const menuItems = computed(() => { name: 'Unattended', activeOn: ['conversation_through_unattended'], label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'), + icon: 'i-lucide-clock-alert', to: accountScopedRoute('conversation_unattended'), }, { @@ -327,6 +331,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'), icon: 'i-lucide-folder', activeOn: ['conversations_through_folders'], + collapsible: true, + showTreeLine: true, children: conversationCustomViews.value.map(view => ({ name: `${view.name}-${view.id}`, label: view.name, @@ -338,6 +344,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.TEAMS'), icon: 'i-lucide-users', activeOn: ['conversations_through_team'], + collapsible: true, + showTreeLine: true, children: sortedTeams.value.map(team => ({ name: `${team.name}-${team.id}`, label: team.name, @@ -350,6 +358,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.CHANNELS'), icon: 'i-lucide-mailbox', activeOn: ['conversation_through_inbox'], + collapsible: true, + showTreeLine: true, children: sortedInboxes.value.map(inbox => ({ name: `${inbox.name}-${inbox.id}`, label: inbox.name, @@ -370,6 +380,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.LABELS'), icon: 'i-lucide-tag', activeOn: ['conversations_through_label'], + collapsible: true, + showTreeLine: true, children: sortedLabels.value.map(label => ({ name: `${label.title}-${label.id}`, label: label.title, @@ -481,6 +493,8 @@ const menuItems = computed(() => { name: 'Segments', icon: 'i-lucide-group', label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'), + collapsible: true, + showTreeLine: true, children: contactCustomViews.value.map(view => ({ name: `${view.name}-${view.id}`, label: view.name, @@ -499,6 +513,8 @@ const menuItems = computed(() => { name: 'Tagged With', icon: 'i-lucide-tag', label: t('SIDEBAR.TAGGED_WITH'), + collapsible: true, + showTreeLine: true, children: labels.value.map(label => ({ name: `${label.title}-${label.id}`, label: label.title, diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue index 048a99cf8..f618ad9ba 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue @@ -99,20 +99,39 @@ const handleWindowBlur = () => { closeActivePopover(); }; -const accessibleItems = computed(() => { +const hasAccessibleSubChildren = child => { + return child.children?.some( + subChild => subChild.to && isAllowed(subChild.to) + ); +}; + +const visibleChildren = computed(() => { if (!hasChildren.value) return []; + return props.children.filter(child => { - // If a item has no link, it means it's just a subgroup header - // So we don't need to check for permissions here, because there's nothing to - // access here anyway + if (child.children) return hasAccessibleSubChildren(child); + return child.to && isAllowed(child.to); }); }); -const hasAccessibleChildren = computed(() => { - return accessibleItems.value.length > 0; +const accessibleItems = computed(() => { + if (!hasChildren.value) return []; + + return visibleChildren.value + .flatMap(child => child.children || child) + .filter(child => child.to && isAllowed(child.to)); }); +const hasAccessibleChildren = computed(() => { + return visibleChildren.value.length > 0; +}); + +const isLastVisibleChild = child => { + const lastChild = visibleChildren.value[visibleChildren.value.length - 1]; + return lastChild === child; +}; + const isActive = computed(() => { if (props.to) { if (route.path === resolvePath(props.to)) return true; @@ -274,14 +293,18 @@ watch(