diff --git a/.bundler-audit.yml b/.bundler-audit.yml
index 0a6c574ab..ffbfa18e0 100644
--- a/.bundler-audit.yml
+++ b/.bundler-audit.yml
@@ -2,6 +2,9 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
+ # Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
+ # Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
+ - GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
diff --git a/Gemfile b/Gemfile
index 275848e4f..54ce18883 100644
--- a/Gemfile
+++ b/Gemfile
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
-gem 'sidekiq', '>= 7.3.1'
+gem 'sidekiq', '~> 7.3', '>= 7.3.1'
# We want cron jobs
-gem 'sidekiq-cron', '>= 1.12.0'
+gem 'sidekiq-cron', '>= 2.4.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
diff --git a/Gemfile.lock b/Gemfile.lock
index 44cba32e4..4f2443944 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -196,6 +196,9 @@ GEM
bigdecimal
rexml
crass (1.0.6)
+ cronex (0.15.0)
+ tzinfo
+ unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -903,10 +906,11 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
- sidekiq-cron (1.12.0)
- fugit (~> 1.8)
+ sidekiq-cron (2.4.0)
+ cronex (>= 0.13.0)
+ fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
- sidekiq (>= 6)
+ sidekiq (>= 6.5.0)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -980,6 +984,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
+ unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1156,8 +1161,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
- sidekiq (>= 7.3.1)
- sidekiq-cron (>= 1.12.0)
+ sidekiq (~> 7.3, >= 7.3.1)
+ sidekiq-cron (>= 2.4.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index 27c4126a6..770018e3c 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
- :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
+ :name, :page_title, :slug, :archived,
+ { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
+ { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
)
end
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalLayoutContentSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalLayoutContentSettings.vue
new file mode 100644
index 000000000..20016dfc1
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalLayoutContentSettings.vue
@@ -0,0 +1,290 @@
+
+
+
+
+
+
+ {{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
+
+
+ {{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
+
+
+
+
+
+
(state.layout = value)"
+ >
+
+
+
+
+
+
(state.layout = value)"
+ >
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
+ }}
+
+
+ {{
+ t(
+ 'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
+ )
+ }}
+
+
+
+
+
+ {{
+ platform.prefix
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
index 83800e002..eccf988cc 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
@@ -7,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
+import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -102,6 +103,12 @@ const handleDeletePortal = () => {
@send-cname-instructions="handleSendCnameInstructions"
/>
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/classic-layout-preview.svg b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/classic-layout-preview.svg
new file mode 100644
index 000000000..d9fe9078a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/classic-layout-preview.svg
@@ -0,0 +1,10 @@
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/documentation-layout-preview.svg b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/documentation-layout-preview.svg
new file mode 100644
index 000000000..efda93a6a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/documentation-layout-preview.svg
@@ -0,0 +1,18 @@
+
diff --git a/app/javascript/dashboard/components-next/radioCard/RadioCard.vue b/app/javascript/dashboard/components-next/radioCard/RadioCard.vue
index 182325ed8..5f6528f90 100644
--- a/app/javascript/dashboard/components-next/radioCard/RadioCard.vue
+++ b/app/javascript/dashboard/components-next/radioCard/RadioCard.vue
@@ -1,4 +1,5 @@
-
@@ -75,5 +83,5 @@ const handleChange = () => {
-
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
index 8bd3e9457..1c7787de6 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
@@ -7,6 +7,7 @@ import {
ArticleMarkdownTransformer,
EditorState,
Selection,
+ imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -235,6 +236,7 @@ export default {
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
+ imageUpload: () => this.openFileBrowser(),
};
const command = commandMap[actionKey];
@@ -332,6 +334,9 @@ export default {
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
+ nodeViews: {
+ image: imageResizeView,
+ },
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue b/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue
index d74d889b2..fd168e7a6 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue
@@ -60,6 +60,12 @@ const EDITOR_ACTIONS = [
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
+ {
+ value: 'imageUpload',
+ labelKey: 'SLASH_COMMANDS.IMAGE',
+ icon: 'i-lucide-image',
+ menuKey: 'imageUpload',
+ },
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
diff --git a/app/javascript/dashboard/i18n/locale/en/components.json b/app/javascript/dashboard/i18n/locale/en/components.json
index 68db1cc85..5e84ac024 100644
--- a/app/javascript/dashboard/i18n/locale/en/components.json
+++ b/app/javascript/dashboard/i18n/locale/en/components.json
@@ -63,6 +63,7 @@
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
- "TABLE": "Table"
+ "TABLE": "Table",
+ "IMAGE": "Image"
}
}
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index 9ae849d25..758bb1dca 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -866,6 +866,28 @@
},
"EDIT_CONFIGURATION": "Edit configuration"
},
+ "LAYOUT_CONTENT": {
+ "HEADER": "Appearance",
+ "DESCRIPTION": "Pick the layout that fits how your visitors read.",
+ "LAYOUT": {
+ "CLASSIC": {
+ "TITLE": "Classic",
+ "DESCRIPTION": "A welcoming home page with search and featured topics."
+ },
+ "SIDEBAR": {
+ "TITLE": "Documentation",
+ "DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
+ }
+ },
+ "SOCIAL_LINKS": {
+ "HEADER": "Social links",
+ "DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
+ "PLACEHOLDER": "handle",
+ "ADD": "Add social link",
+ "REMOVE": "Remove"
+ },
+ "SAVE": "Save changes"
+ },
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/LockToSingleConversationPreview.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/LockToSingleConversationPreview.vue
index 77cbc2216..d2109ba9c 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/LockToSingleConversationPreview.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/LockToSingleConversationPreview.vue
@@ -13,7 +13,7 @@ defineEmits(['update']);
-defineProps({
- active: {
- type: Boolean,
- default: false,
- },
- title: {
- type: String,
- default: '',
- },
- description: {
- type: String,
- default: '',
- },
- lightImage: {
- type: String,
- default: '',
- },
- darkImage: {
- type: String,
- default: '',
- },
-});
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
index ed674e3bc..75eb8a2f8 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
@@ -13,7 +13,6 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
-import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
@@ -22,6 +21,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
+import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -36,7 +36,7 @@ export default {
UserProfilePicture,
Policy,
UserBasicDetails,
- HotKeyCard,
+ RadioCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
@@ -268,26 +268,27 @@ export default {
-
+
![]()
+
<%
- socials = (portal.config.is_a?(Hash) && portal.config['social_profiles']) || {}
+ socials = portal.social_profiles
social_definitions = [
- { key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook' },
- { key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X' },
- { key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram' },
- { key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok' },
- { key: 'github', icon: 'i-ri-github-fill', label: 'GitHub' },
- { key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp' }
+ { key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook', base_url: 'https://facebook.com/' },
+ { key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X', base_url: 'https://x.com/' },
+ { key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram', base_url: 'https://instagram.com/' },
+ { key: 'linkedin', icon: 'i-ri-linkedin-box-fill', label: 'LinkedIn', base_url: 'https://linkedin.com/' },
+ { key: 'youtube', icon: 'i-ri-youtube-fill', label: 'YouTube', base_url: 'https://youtube.com/' },
+ { key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok', base_url: 'https://tiktok.com/' },
+ { key: 'github', icon: 'i-ri-github-fill', label: 'GitHub', base_url: 'https://github.com/' },
+ { key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp', base_url: 'https://wa.me/' }
]
- active_socials = social_definitions.select { |s| socials[s[:key]].to_s.strip.present? }
+ active_socials = social_definitions.filter_map do |social|
+ handle = socials[social[:key]].to_s.strip
+ social.merge(url: "#{social[:base_url]}#{handle}") if handle.present?
+ end
show_branding = !portal.account.feature_enabled?('disable_branding')
%>
<% if show_branding || active_socials.any? %>
-