From 5ed84af2e3b02a5bb41d366b72d0a651be386d03 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 30 Jul 2025 20:07:50 +0530 Subject: [PATCH 1/5] fix: custom branch upgrade via cwctl (#12070) - fix custom branch `cwctl --Upgrade` --- VERSION_CWCTL | 2 +- deployment/setup_20.04.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 18091983f..47b322c97 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.4.0 +3.4.1 diff --git a/deployment/setup_20.04.sh b/deployment/setup_20.04.sh index e904441a9..aaa928f4d 100644 --- a/deployment/setup_20.04.sh +++ b/deployment/setup_20.04.sh @@ -2,7 +2,7 @@ # Description: Install and manage a Chatwoot installation. # OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS -# Script Version: 3.4.0 +# Script Version: 3.4.1 # Run this script as root set -eu -o errexit -o pipefail -o noclobber -o nounset @@ -1005,7 +1005,7 @@ EOF upgrade_redis upgrade_node get_pnpm - sudo -i -u chatwoot << "EOF" + sudo -i -u chatwoot << EOF # Navigate to the Chatwoot directory cd chatwoot From 97f1825a1479dda1cc33df8a8571d65dce0fd3c0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 30 Jul 2025 21:23:34 +0400 Subject: [PATCH 2/5] fix: `CAPTAIN_OPEN_AI_ENDPOINT` presence not checked correctly (#12069) --- enterprise/app/services/llm/base_open_ai_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb index 04909cbf4..2d3932246 100644 --- a/enterprise/app/services/llm/base_open_ai_service.rb +++ b/enterprise/app/services/llm/base_open_ai_service.rb @@ -15,7 +15,8 @@ class Llm::BaseOpenAiService private def uri_base - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value + endpoint.presence || 'https://api.openai.com/' end def setup_model From d9900e50a0a8f030f69916e96c971819d255b9f1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:22:47 +0530 Subject: [PATCH 3/5] feat(cloud): Add support for viewing status of SSL in custom domains (#12011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description Fixes [CW-4620](https://linear.app/chatwoot/issue/CW-4620/rethinking-custom-domains-in-chatwoot) Screenshot 2025-07-29 at 8 17 44 PM ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ## 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: Vishnu Narayanan Co-authored-by: Pranav Co-authored-by: Pranav --- .../api/v1/accounts/portals_controller.rb | 24 ++- .../dashboard/api/helpCenter/portals.js | 8 + .../AddCustomDomainDialog.vue | 27 ++- .../DNSConfigurationDialog.vue | 175 +++++++++++++---- .../PortalConfigurationSettings.vue | 182 +++++++++++++++--- .../PortalSettingsPage/PortalSettings.vue | 14 ++ .../dashboard/i18n/locale/en/helpCenter.json | 27 ++- .../pages/PortalsSettingsIndexPage.vue | 44 ++++- .../modules/helpCenterPortals/actions.js | 20 ++ .../modules/helpCenterPortals/getters.js | 1 + .../store/modules/helpCenterPortals/index.js | 1 + .../modules/helpCenterPortals/mutations.js | 15 ++ .../helpCenterPortals/specs/actions.spec.js | 30 +++ .../helpCenterPortals/specs/mutations.spec.js | 19 ++ app/mailers/portal_instructions_mailer.rb | 41 ++++ app/policies/portal_policy.rb | 8 + .../v1/accounts/portals/_portal.json.jbuilder | 7 + .../send_cname_instructions.liquid | 30 +++ config/locales/en.yml | 9 + config/routes.rb | 2 + .../api/v1/accounts/portals_controller.rb | 15 ++ .../base_cloudflare_zone_service.rb | 21 ++ .../check_custom_hostname_service.rb | 13 +- .../create_custom_hostname_service.rb | 19 +- package.json | 2 +- pnpm-lock.yaml | 10 +- .../v1/accounts/portals_controller_spec.rb | 72 +++++++ .../v1/accounts/portals_controller_spec.rb | 69 +++++++ .../check_custom_hostname_service_spec.rb | 6 +- .../create_custom_hostname_service_spec.rb | 19 +- .../portal_instructions_mailer_spec.rb | 50 +++++ 31 files changed, 874 insertions(+), 106 deletions(-) create mode 100644 app/mailers/portal_instructions_mailer.rb create mode 100644 app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb create mode 100644 spec/mailers/portal_instructions_mailer_spec.rb diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 6cfed161f..18538e842 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -47,6 +47,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController head :ok end + def send_instructions + email = permitted_params[:email] + return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank? + return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email) + return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank? + + PortalInstructionsMailer.send_cname_instructions( + portal: @portal, + recipient_email: email + ).deliver_later + + render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok + end + def process_attached_logo blob_id = params[:blob_id] blob = ActiveStorage::Blob.find_by(id: blob_id) @@ -60,12 +74,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:id) + params.permit(:id, :email) end def portal_params params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, + :id, :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } ) end @@ -88,4 +102,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end + + def valid_email?(email) + ValidEmail2::Address.new(email).valid? + end end + +Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController') diff --git a/app/javascript/dashboard/api/helpCenter/portals.js b/app/javascript/dashboard/api/helpCenter/portals.js index 7c6210dbd..d65dcaf1a 100644 --- a/app/javascript/dashboard/api/helpCenter/portals.js +++ b/app/javascript/dashboard/api/helpCenter/portals.js @@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient { deleteLogo(portalSlug) { return axios.delete(`${this.url}/${portalSlug}/logo`); } + + sendCnameInstructions(portalSlug, email) { + return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email }); + } + + sslStatus(portalSlug) { + return axios.get(`${this.url}/${portalSlug}/ssl_status`); + } } export default PortalsAPI; diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue index b43e2f2fd..0791f0519 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue @@ -1,6 +1,9 @@ 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 02b5001b9..83800e002 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue @@ -26,6 +26,8 @@ const emit = defineEmits([ 'updatePortal', 'updatePortalConfiguration', 'deletePortal', + 'refreshStatus', + 'sendCnameInstructions', ]); const { t } = useI18n(); @@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null); const currentPortalSlug = computed(() => route.params.portalSlug); const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); +const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus'); const activePortal = computed(() => { return props.portals?.find(portal => portal.slug === currentPortalSlug.value); @@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => { emit('updatePortalConfiguration', portal); }; +const fetchSSLStatus = () => { + emit('refreshStatus'); +}; + +const handleSendCnameInstructions = payload => { + emit('sendCnameInstructions', payload); +}; + const openConfirmDeletePortalDialog = () => { confirmDeletePortalDialogRef.value.dialogRef.open(); }; @@ -85,7 +96,10 @@ const handleDeletePortal = () => {
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index f437b83d9..bd7fb986a 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -157,6 +157,12 @@ "DELETE_SUCCESS": "Portal deleted successfully", "DELETE_ERROR": "Error while deleting portal" } + }, + "SEND_CNAME_INSTRUCTIONS": { + "API": { + "SUCCESS_MESSAGE": "CNAME instructions sent successfully", + "ERROR_MESSAGE": "Error while sending CNAME instructions" + } } }, "EDIT": { @@ -747,9 +753,15 @@ "HEADER": "Custom domain", "LABEL": "Custom domain:", "DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.", + "STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.", "PLACEHOLDER": "Portal custom domain", - "EDIT_BUTTON": "Edit custom domain", + "EDIT_BUTTON": "Edit", "ADD_BUTTON": "Add custom domain", + "STATUS": { + "LIVE": "Live", + "PENDING": "Awaiting verification", + "ERROR": "Verification failed" + }, "DIALOG": { "ADD_HEADER": "Add custom domain", "EDIT_HEADER": "Edit custom domain", @@ -757,13 +769,20 @@ "EDIT_CONFIRM_BUTTON_LABEL": "Update domain", "LABEL": "Custom domain", "PLACEHOLDER": "Portal custom domain", - "ERROR": "Custom domain is required" + "ERROR": "Custom domain is required", + "FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com" }, "DNS_CONFIGURATION_DIALOG": { "HEADER": "DNS configuration", "DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help", - "HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.", - "CONFIRM_BUTTON_LABEL": "Got it!" + "COPY": "Successfully copied CNAME", + "SEND_INSTRUCTIONS": { + "HEADER": "Send instructions", + "DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.", + "PLACEHOLDER": "Enter their email", + "ERROR": "Enter a valid email address", + "SEND_BUTTON": "Send" + } } }, "DELETE_PORTAL": { diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue index 68a9ee953..e0953335f 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue @@ -4,12 +4,16 @@ import { useRoute, useRouter } from 'vue-router'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useAlert } from 'dashboard/composables'; import { useMapGetter, useStore } from 'dashboard/composables/store.js'; +import { useAccount } from 'dashboard/composables/useAccount'; import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue'; +const SSL_STATUS_FETCH_INTERVAL = 5000; + const { t } = useI18n(); const store = useStore(); const route = useRoute(); const router = useRouter(); +const { isOnChatwootCloud } = useAccount(); const { updateUISettings } = useUISettings(); @@ -24,6 +28,15 @@ const getDefaultLocale = slug => { return getPortalBySlug.value(slug)?.meta?.default_locale; }; +const fetchSSLStatus = () => { + if (!isOnChatwootCloud.value) return; + + const { portalSlug } = route.params; + store.dispatch('portals/sslStatus', { + portalSlug, + }); +}; + const fetchPortalAndItsCategories = async (slug, locale) => { const selectedPortalParam = { portalSlug: slug, locale }; await Promise.all([ @@ -106,8 +119,35 @@ const deletePortal = async selectedPortalForDelete => { } }; +const handleSendCnameInstructions = async payload => { + try { + await store.dispatch('portals/sendCnameInstructions', payload); + useAlert( + t( + 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.SUCCESS_MESSAGE' + ) + ); + } catch (error) { + useAlert( + error?.message || + t( + 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.ERROR_MESSAGE' + ) + ); + } +}; + const handleUpdatePortal = updatePortalSettings; -const handleUpdatePortalConfiguration = updatePortalSettings; +const handleUpdatePortalConfiguration = portalObj => { + updatePortalSettings(portalObj); + + // If custom domain is added or updated, fetch SSL status after a delay of 5 seconds (only on Chatwoot cloud) + if (portalObj?.custom_domain && isOnChatwootCloud.value) { + setTimeout(() => { + fetchSSLStatus(); + }, SSL_STATUS_FETCH_INTERVAL); + } +}; const handleDeletePortal = deletePortal; @@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal; @update-portal="handleUpdatePortal" @update-portal-configuration="handleUpdatePortalConfiguration" @delete-portal="handleDeletePortal" + @refresh-status="fetchSSLStatus" + @send-cname-instructions="handleSendCnameInstructions" /> diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js b/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js index 05a28756c..130ea097e 100644 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js @@ -116,4 +116,24 @@ export const actions = { isSwitching, }); }, + + sendCnameInstructions: async (_, { portalSlug, email }) => { + try { + await portalAPIs.sendCnameInstructions(portalSlug, email); + } catch (error) { + throwErrorMessage(error); + } + }, + + sslStatus: async ({ commit }, { portalSlug }) => { + try { + commit(types.SET_UI_FLAG, { isFetchingSSLStatus: true }); + const { data } = await portalAPIs.sslStatus(portalSlug); + commit(types.SET_SSL_SETTINGS, { portalSlug, sslSettings: data }); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_UI_FLAG, { isFetchingSSLStatus: false }); + } + }, }; diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js b/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js index 7dd8b2b22..f40af2502 100644 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js @@ -8,6 +8,7 @@ export const getters = { isFetchingPortals: state => state.uiFlags.isFetching, isCreatingPortal: state => state.uiFlags.isCreating, isSwitchingPortal: state => state.uiFlags.isSwitching, + isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus, portalBySlug: (...getterArguments) => portalId => { diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js index 621e180e5..4feb098c0 100755 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js @@ -6,6 +6,7 @@ export const defaultPortalFlags = { isFetching: false, isUpdating: false, isDeleting: false, + isFetchingSSLStatus: false, }; const state = { diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js b/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js index 7e14bc63e..3f2fce3c8 100644 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js @@ -13,6 +13,7 @@ export const types = { REMOVE_PORTAL_ID: 'removePortalId', SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag', SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag', + SET_SSL_SETTINGS: 'setSSLSettings', }; export const mutations = { @@ -110,4 +111,18 @@ export const mutations = { [types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) { $state.uiFlags.isSwitching = isSwitching; }, + + [types.SET_SSL_SETTINGS]($state, { portalSlug, sslSettings }) { + const portal = $state.portals.byId[portalSlug]; + $state.portals.byId = { + ...$state.portals.byId, + [portalSlug]: { + ...portal, + ssl_settings: { + ...portal.ssl_settings, + ...sslSettings, + }, + }, + }; + }, }; diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js index 9acbbb0b5..9bd85cb98 100644 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js @@ -135,6 +135,36 @@ describe('#actions', () => { }); }); + describe('#sslStatus', () => { + it('commits SET_SSL_SETTINGS with data from API', async () => { + axios.get.mockResolvedValue({ + data: { status: 'active', verification_errors: [] }, + }); + await actions.sslStatus({ commit }, { portalSlug: 'domain' }); + expect(commit.mock.calls).toEqual([ + [types.SET_UI_FLAG, { isFetchingSSLStatus: true }], + [ + types.SET_SSL_SETTINGS, + { + portalSlug: 'domain', + sslSettings: { status: 'active', verification_errors: [] }, + }, + ], + [types.SET_UI_FLAG, { isFetchingSSLStatus: false }], + ]); + }); + it('throws error and does not commit when API fails', async () => { + axios.get.mockRejectedValue({ message: 'error' }); + await expect( + actions.sslStatus({ commit }, { portalSlug: 'domain' }) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_UI_FLAG, { isFetchingSSLStatus: true }], + [types.SET_UI_FLAG, { isFetchingSSLStatus: false }], + ]); + }); + }); + describe('#delete', () => { it('sends correct actions if API is success', async () => { axios.delete.mockResolvedValue({}); diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js index 2e58c1de7..c468ee017 100644 --- a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js @@ -89,6 +89,25 @@ describe('#mutations', () => { isFetching: true, isUpdating: false, isDeleting: false, + isFetchingSSLStatus: false, + }); + }); + }); + + describe('[types.SET_SSL_SETTINGS]', () => { + it('merges new ssl settings into existing portal.ssl_settings', () => { + state.portals.byId.domain = { + slug: 'domain', + ssl_settings: { cf_status: 'pending' }, + }; + mutations[types.SET_SSL_SETTINGS](state, { + portalSlug: 'domain', + sslSettings: { status: 'active', verification_errors: ['error'] }, + }); + expect(state.portals.byId.domain.ssl_settings).toEqual({ + cf_status: 'pending', + status: 'active', + verification_errors: ['error'], }); }); }); diff --git a/app/mailers/portal_instructions_mailer.rb b/app/mailers/portal_instructions_mailer.rb new file mode 100644 index 000000000..eccbd7db1 --- /dev/null +++ b/app/mailers/portal_instructions_mailer.rb @@ -0,0 +1,41 @@ +class PortalInstructionsMailer < ApplicationMailer + def send_cname_instructions(portal:, recipient_email:) + return unless smtp_config_set_or_development? + return if target_domain.blank? + + @portal = portal + @cname_record = generate_cname_record + + send_mail_with_liquid( + to: recipient_email, + subject: I18n.t('portals.send_instructions.subject', custom_domain: @portal.custom_domain) + ) + end + + private + + def liquid_locals + { cname_record: @cname_record } + end + + def generate_cname_record + "#{@portal.custom_domain} CNAME #{target_domain}" + end + + def target_domain + helpcenter_url = ENV.fetch('HELPCENTER_URL', '') + frontend_url = ENV.fetch('FRONTEND_URL', '') + + return extract_hostname(helpcenter_url) if helpcenter_url.present? + return extract_hostname(frontend_url) if frontend_url.present? + + '' + end + + def extract_hostname(url) + uri = URI.parse(url) + uri.host + rescue URI::InvalidURIError + url.gsub(%r{https?://}, '').split('/').first + end +end diff --git a/app/policies/portal_policy.rb b/app/policies/portal_policy.rb index 1e09c41f6..0eace233c 100644 --- a/app/policies/portal_policy.rb +++ b/app/policies/portal_policy.rb @@ -26,6 +26,14 @@ class PortalPolicy < ApplicationPolicy def logo? @account_user.administrator? end + + def send_instructions? + @account_user.administrator? + end + + def ssl_status? + @account.users.include?(@user) + end end PortalPolicy.prepend_mod_with('PortalPolicy') diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 5e267f60c..37020b5dd 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -34,3 +34,10 @@ json.meta do json.categories_count portal.categories.try(:size) json.default_locale portal.default_locale end + +if portal.ssl_settings.present? + json.ssl_settings do + json.status portal.ssl_settings['cf_status'] + json.verification_errors portal.ssl_settings['cf_verification_errors'] + end +end diff --git a/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid b/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid new file mode 100644 index 000000000..b14ca1098 --- /dev/null +++ b/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid @@ -0,0 +1,30 @@ + + +

Hello there,

+

To complete the setup of your help center, you'll need to update the DNS settings for your custom domain: {{ cname_record | split: ' ' | first }}.

+

Please add the following CNAME record to your DNS provider's configuration:

+ + + + + +

{{ cname_record }}

+ + + + + +

Step-by-step Instructions:

+ +
    +
  1. Log in to your DNS provider’s dashboard
  2. +
  3. Go to the DNS management section
  4. +
  5. Create a new CNAME record using the information above
  6. +
  7. Save the changes and allow up to 24 hours for the DNS to propagate
  8. +
+ +

Once the DNS record is live, your custom domain will automatically be secured with an SSL certificate.

+ +

If you have any questions or need help, feel free to reach out to our support team—we’re here to assist you.

+ + diff --git a/config/locales/en.yml b/config/locales/en.yml index 221f83472..9faa0cd9d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -358,3 +358,12 @@ en: Transcript: %{format_messages} + portals: + send_instructions: + email_required: 'Email is required' + invalid_email_format: 'Invalid email format' + custom_domain_not_configured: 'Custom domain is not configured' + instructions_sent_successfully: 'Instructions sent successfully' + subject: 'Finish setting up %{custom_domain}' + ssl_status: + custom_domain_not_configured: 'Custom domain is not configured' diff --git a/config/routes.rb b/config/routes.rb index c12aa670b..7fb348084 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -290,6 +290,8 @@ Rails.application.routes.draw do member do patch :archive delete :logo + post :send_instructions + get :ssl_status end resources :categories resources :articles do diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb new file mode 100644 index 000000000..488f7e700 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb @@ -0,0 +1,15 @@ +module Enterprise::Api::V1::Accounts::PortalsController + def ssl_status + return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank? + + result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform + + return render_could_not_create_error(result[:errors]) if result[:errors].present? + + ssl_settings = @portal.ssl_settings || {} + render json: { + status: ssl_settings['cf_status'], + verification_errors: ssl_settings['cf_verification_errors'] + } + end +end diff --git a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb index 7fad50790..162f61915 100644 --- a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb +++ b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb @@ -17,4 +17,25 @@ class Cloudflare::BaseCloudflareZoneService def zone_id InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value end + + def update_portal_ssl_settings(portal, data) + verification_record = data['ownership_verification_http'] + ssl_record = data['ssl'] + verification_errors = data['verification_errors']&.first || '' + + # Start with existing settings to preserve verification data if it exists + ssl_settings = portal.ssl_settings || {} + + # Only update verification fields if they exist in the response (during initial setup) + if verification_record.present? + ssl_settings['cf_verification_id'] = verification_record['http_url'].split('/').last + ssl_settings['cf_verification_body'] = verification_record['http_body'] + end + + # Always update SSL status and errors from current response + ssl_settings['cf_status'] = ssl_record&.dig('status') + ssl_settings['cf_verification_errors'] = verification_errors + + portal.update(ssl_settings: ssl_settings) + end end diff --git a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb index 588a9c4a7..716623a18 100644 --- a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb +++ b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb @@ -14,21 +14,10 @@ class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneSer data = response.parsed_response['result'] if data.present? - update_portal_ssl_settings(data.first) + update_portal_ssl_settings(@portal, data.first) return { data: data } end { errors: ['Hostname is missing in Cloudflare'] } end - - private - - def update_portal_ssl_settings(data) - verification_record = data['ownership_verification_http'] - ssl_settings = { - 'cf_verification_id': verification_record['http_url'].split('/').last, - 'cf_verification_body': verification_record['http_body'] - } - @portal.update(ssl_settings: ssl_settings) - end end diff --git a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb index f1546caed..236b434e5 100644 --- a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb +++ b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb @@ -12,7 +12,7 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe data = response.parsed_response['result'] if data.present? - update_portal_ssl_settings(data) + update_portal_ssl_settings(@portal, data) return { data: data } end @@ -25,16 +25,13 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe HTTParty.post( "#{BASE_URI}/zones/#{zone_id}/custom_hostnames", headers: headers, - body: { hostname: @portal.custom_domain }.to_json + body: { + hostname: @portal.custom_domain, + ssl: { + method: 'http', + type: 'dv' + } + }.to_json ) end - - def update_portal_ssl_settings(data) - verification_record = data['ownership_verification_http'] - ssl_settings = { - 'cf_verification_id': verification_record['http_url'].split('/').last, - 'cf_verification_body': verification_record['http_body'] - } - @portal.update(ssl_settings: ssl_settings) - end end diff --git a/package.json b/package.json index e8c120074..02dd757aa 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.2.1", - "@chatwoot/utils": "^0.0.48", + "@chatwoot/utils": "^0.0.49", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f7b398b0..449176df4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 1.2.1 version: 1.2.1 '@chatwoot/utils': - specifier: ^0.0.48 - version: 0.0.48 + specifier: ^0.0.49 + version: 0.0.49 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -406,8 +406,8 @@ packages: '@chatwoot/prosemirror-schema@1.2.1': resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==} - '@chatwoot/utils@0.0.48': - resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==} + '@chatwoot/utils@0.0.49': + resolution: {integrity: sha512-Co68VzaFtctTNYKY6y4izBBATvk6/8ZVtkyEP5HL72uhFDA11LrY5pqSh04HMoFyfdIU+uVPimfI45HAeso1IA==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5255,7 +5255,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.48': + '@chatwoot/utils@0.0.49': dependencies: date-fns: 2.30.0 diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index aeec9cab4..d0ea13e2b 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -210,4 +210,76 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/send_instructions' do + let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions", + params: { email: 'dev@example.com' } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated agent' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions", + headers: agent.create_new_auth_token, + params: { email: 'dev@example.com' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + it 'returns error when email is missing' do + post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions", + headers: admin.create_new_auth_token, + params: {}, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Email is required') + end + + it 'returns error when email is invalid' do + post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions", + headers: admin.create_new_auth_token, + params: { email: 'invalid-email' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Invalid email format') + end + + it 'returns error when custom domain is not configured' do + post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/send_instructions", + headers: admin.create_new_auth_token, + params: { email: 'dev@example.com' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Custom domain is not configured') + end + + it 'sends instructions successfully' do + mailer_double = instance_double(ActionMailer::MessageDelivery) + allow(PortalInstructionsMailer).to receive(:send_cname_instructions).and_return(mailer_double) + allow(mailer_double).to receive(:deliver_later) + + post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions", + headers: admin.create_new_auth_token, + params: { email: 'dev@example.com' }, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['message']).to eq('Instructions sent successfully') + expect(PortalInstructionsMailer).to have_received(:send_cname_instructions) + .with(portal: portal_with_domain, recipient_email: 'dev@example.com') + end + end + end end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb index cb296494c..48e6c9e00 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb @@ -87,4 +87,73 @@ RSpec.describe 'Enterprise Portal API', type: :request do end end end + + describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/ssl_status' do + let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'returns error when custom domain is not configured' do + get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/ssl_status", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Custom domain is not configured') + end + + it 'returns SSL status when portal has ssl_settings' do + portal_with_domain.update(ssl_settings: { + 'cf_status' => 'active', + 'cf_verification_errors' => nil + }) + + mock_service = instance_double(Cloudflare::CheckCustomHostnameService) + allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:perform).and_return({ data: [] }) + + get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['status']).to eq('active') + expect(response.parsed_body['verification_errors']).to be_nil + end + + it 'returns null values when portal has no ssl_settings' do + mock_service = instance_double(Cloudflare::CheckCustomHostnameService) + allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:perform).and_return({ data: [] }) + + get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['status']).to be_nil + expect(response.parsed_body['verification_errors']).to be_nil + end + + it 'returns error when Cloudflare service returns errors' do + mock_service = instance_double(Cloudflare::CheckCustomHostnameService) + allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service) + allow(mock_service).to receive(:perform).and_return({ errors: ['API token not found'] }) + + get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq(['API token not found']) + end + end + end end diff --git a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb index d7ed80b90..b7e567465 100644 --- a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb +++ b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb @@ -96,8 +96,10 @@ RSpec.describe Cloudflare::CheckCustomHostnameService do expect(portal).to receive(:update).with( ssl_settings: { - 'cf_verification_id': 'verification-id', - 'cf_verification_body': 'verification-body' + 'cf_verification_id' => 'verification-id', + 'cf_verification_body' => 'verification-body', + 'cf_status' => nil, + 'cf_verification_errors' => '' } ) diff --git a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb index a3ddc8273..3f49c96dd 100644 --- a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb +++ b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb @@ -54,7 +54,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' }, - body: { hostname: 'test.example.com' }.to_json) + body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json) .to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' }) result = service.perform @@ -72,7 +72,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' }, - body: { hostname: 'test.example.com' }.to_json) + body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json) .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) result = service.perform @@ -92,17 +92,22 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do } } } + expect(portal.ssl_settings).to eq({}) stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' }, - body: { hostname: 'test.example.com' }.to_json) + body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json) .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) - expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id', - 'cf_verification_body': 'verification-body' }) - result = service.perform - + expect(portal.ssl_settings).to eq( + { + 'cf_verification_id' => 'verification-id', + 'cf_verification_body' => 'verification-body', + 'cf_status' => nil, + 'cf_verification_errors' => '' + } + ) expect(result).to eq(data: success_response['result']) end end diff --git a/spec/mailers/portal_instructions_mailer_spec.rb b/spec/mailers/portal_instructions_mailer_spec.rb new file mode 100644 index 000000000..bfad55a08 --- /dev/null +++ b/spec/mailers/portal_instructions_mailer_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PortalInstructionsMailer do + describe 'send_cname_instructions' do + let!(:account) { create(:account) } + let!(:portal) { create(:portal, account: account, custom_domain: 'help.example.com') } + let(:recipient_email) { 'admin@example.com' } + let(:class_instance) { described_class.new } + + before do + allow(described_class).to receive(:new).and_return(class_instance) + allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) + end + + context 'when target domain is available' do + it 'sends email with cname instructions' do + with_modified_env HELPCENTER_URL: 'https://help.chatwoot.com' do + mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now + + expect(mail.to).to eq([recipient_email]) + expect(mail.subject).to eq("Finish setting up #{portal.custom_domain}") + expect(mail.body.encoded).to include('help.example.com CNAME help.chatwoot.com') + end + end + end + + context 'when helpcenter url is not available but frontend url is' do + it 'uses frontend url as target domain' do + with_modified_env HELPCENTER_URL: '', FRONTEND_URL: 'https://app.chatwoot.com' do + mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now + + expect(mail.to).to eq([recipient_email]) + expect(mail.body.encoded).to include('help.example.com CNAME app.chatwoot.com') + end + end + end + + context 'when no target domain is available' do + it 'does not send email' do + with_modified_env HELPCENTER_URL: '', FRONTEND_URL: '' do + mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now + + expect(mail).to be_nil + end + end + end + end +end From b434279422f946dfdd6ac3affd1f9ea91811893f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:53:59 -0700 Subject: [PATCH 4/5] chore(deps): bump vue-i18n from 9.14.3 to 9.14.5 (#11960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 9.14.3 to 9.14.5.
Release notes

Sourced from vue-i18n's releases.

v9.14.5

What's Changed

🔒 Security Fixes

Full Changelog: https://github.com/intlify/vue-i18n/compare/v9.14.4...v9.14.5

v9.14.4

What's Changed

🐛 Bug Fixes

Full Changelog: https://github.com/intlify/vue-i18n/compare/v9.14.3...v9.14.4

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vue-i18n&package-manager=npm_and_yarn&previous-version=9.14.3&new-version=9.14.5)](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 merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@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> --- package.json | 2 +- pnpm-lock.yaml | 89 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 02dd757aa..2aefb3d53 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "vue-chartjs": "5.3.1", "vue-datepicker-next": "^1.0.3", "vue-dompurify-html": "^5.1.0", - "vue-i18n": "9.14.3", + "vue-i18n": "9.14.5", "vue-letter": "^0.2.1", "vue-multiselect": "3.1.0", "vue-router": "~4.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 449176df4..ca55fbe34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,8 +200,8 @@ importers: specifier: ^5.1.0 version: 5.1.0(vue@3.5.12(typescript@5.6.2)) vue-i18n: - specifier: 9.14.3 - version: 9.14.3(vue@3.5.12(typescript@5.6.2)) + specifier: 9.14.5 + version: 9.14.5(vue@3.5.12(typescript@5.6.2)) vue-letter: specifier: ^0.2.1 version: 0.2.1 @@ -906,8 +906,8 @@ packages: resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==} engines: {node: '>= 16'} - '@intlify/core-base@9.14.3': - resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==} + '@intlify/core-base@9.14.5': + resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==} engines: {node: '>= 16'} '@intlify/eslint-plugin-vue-i18n@3.2.0': @@ -920,16 +920,16 @@ packages: resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==} engines: {node: '>= 16'} - '@intlify/message-compiler@9.14.3': - resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==} + '@intlify/message-compiler@9.14.5': + resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==} engines: {node: '>= 16'} '@intlify/shared@9.14.2': resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==} engines: {node: '>= 16'} - '@intlify/shared@9.14.3': - resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==} + '@intlify/shared@9.14.5': + resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} engines: {node: '>= 16'} '@isaacs/cliui@8.0.2': @@ -940,6 +940,9 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -952,19 +955,29 @@ packages: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/set-array@1.2.1': resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.6': - resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + '@jridgewell/source-map@0.3.10': + resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.4': + resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@june-so/analytics-next@2.0.0': resolution: {integrity: sha512-7uFP94JLD7mP4qLyOwn5HBs+CC8VlevOkiGd1CIYqPSjSRmbCOI+MVcJNlTAcpyNvMi9iUnWZ3jGVO5177Di4A==} @@ -1993,8 +2006,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -4960,8 +4973,8 @@ packages: peerDependencies: eslint: '>=6.0.0' - vue-i18n@9.14.3: - resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==} + vue-i18n@9.14.5: + resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} engines: {node: '>= 16'} peerDependencies: vue: ^3.0.0 @@ -5778,10 +5791,10 @@ snapshots: '@intlify/message-compiler': 9.14.2 '@intlify/shared': 9.14.2 - '@intlify/core-base@9.14.3': + '@intlify/core-base@9.14.5': dependencies: - '@intlify/message-compiler': 9.14.3 - '@intlify/shared': 9.14.3 + '@intlify/message-compiler': 9.14.5 + '@intlify/shared': 9.14.5 '@intlify/eslint-plugin-vue-i18n@3.2.0(eslint@8.57.0)': dependencies: @@ -5813,14 +5826,14 @@ snapshots: '@intlify/shared': 9.14.2 source-map-js: 1.2.1 - '@intlify/message-compiler@9.14.3': + '@intlify/message-compiler@9.14.5': dependencies: - '@intlify/shared': 9.14.3 + '@intlify/shared': 9.14.5 source-map-js: 1.2.1 '@intlify/shared@9.14.2': {} - '@intlify/shared@9.14.3': {} + '@intlify/shared@9.14.5': {} '@isaacs/cliui@8.0.2': dependencies: @@ -5833,6 +5846,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping': 0.3.29 + optional: true + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -5847,21 +5866,33 @@ snapshots: '@jridgewell/resolve-uri@3.1.1': {} + '@jridgewell/resolve-uri@3.1.2': + optional: true + '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.6': + '@jridgewell/source-map@0.3.10': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 optional: true '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.4': + optional: true + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.29': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.4 + optional: true + '@june-so/analytics-next@2.0.0': dependencies: '@lukeed/uuid': 2.0.0 @@ -7078,7 +7109,7 @@ snapshots: acorn@8.14.0: {} - acorn@8.14.1: + acorn@8.15.0: optional: true activestorage@5.2.8: @@ -10124,8 +10155,8 @@ snapshots: terser@5.33.0: dependencies: - '@jridgewell/source-map': 0.3.6 - acorn: 8.14.1 + '@jridgewell/source-map': 0.3.10 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -10479,10 +10510,10 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@9.14.3(vue@3.5.12(typescript@5.6.2)): + vue-i18n@9.14.5(vue@3.5.12(typescript@5.6.2)): dependencies: - '@intlify/core-base': 9.14.3 - '@intlify/shared': 9.14.3 + '@intlify/core-base': 9.14.5 + '@intlify/shared': 9.14.5 '@vue/devtools-api': 6.6.4 vue: 3.5.12(typescript@5.6.2) From 86cb4fd80952df40c979f9e67d8064f6deda02f9 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 31 Jul 2025 02:19:27 +0530 Subject: [PATCH 5/5] chore: Improve layout styles (#12025) # Pull Request Template ## Description This PR fixes the layout overflow scroll issue and removes unused CSS. It also optimizes the display of the Sidebar, Copilot Panel, and Conversation Panel in the mobile view. Additionally, it resolves a runtime console warning. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screencast https://github.com/user-attachments/assets/7e8885fa-6174-4740-80f1-bb1cec6517fc ## 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 - [ ] 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 --- app/javascript/dashboard/App.vue | 2 +- .../dashboard/assets/scss/_woot.scss | 24 ----- .../sidebar/MobileSidebarLauncher.vue | 58 ++++++++++++ .../components-next/sidebar/Sidebar.vue | 28 +++++- .../components/copilot/CopilotContainer.vue | 30 +++++- .../conversation/ConversationSidebar.vue | 28 +++++- app/javascript/dashboard/constants/globals.js | 2 +- .../dashboard/routes/dashboard/Dashboard.vue | 91 +++++++++---------- .../conversation/search/SwitchLayout.vue | 2 +- .../helpcenter/components/UpgradePage.vue | 2 +- .../pages/HelpCenterPageRouteView.vue | 2 +- .../dashboard/settings/SettingsHeader.vue | 20 ---- .../routes/dashboard/settings/Wrapper.vue | 16 +--- .../dashboard/settings/inbox/inbox.routes.js | 2 - .../dashboard/settings/teams/teams.routes.js | 2 - .../routes/dashboard/upgrade/UpgradePage.vue | 72 ++++++++------- 16 files changed, 226 insertions(+), 155 deletions(-) create mode 100644 app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 675f6ea67..0fbb20ea9 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -136,7 +136,7 @@ export default {
diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 66416c64c..40ca7b436 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -37,30 +37,6 @@ body { width: 100%; } -.app-wrapper { - @apply h-screen flex-grow-0 min-h-0 w-full; - - .button--fixed-top { - @apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row; - } -} - -.banner + .app-wrapper { - // Reduce the height of the dashboard to make room for the banner. - // And causing the top right green-action button to be pushed down when scrolling. - @apply h-[calc(100%-48px)]; - - .button--fixed-top { - @apply top-14; - } - - .off-canvas-content { - .button--fixed-top { - @apply top-2; - } - } -} - .tooltip { @apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96; } diff --git a/app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue b/app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue new file mode 100644 index 000000000..a7265f840 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue @@ -0,0 +1,58 @@ + + +