-
-
-
-
-
- {{ $t('GENERAL_SETTINGS.UPGRADE') }}
-
-
-
-
- {{ limitExceededMessage }}
-
-
- {{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
-
-
+
+
+
+
+
+
+
+
+ {{ $t('GENERAL_SETTINGS.UPGRADE') }}
+
+
+
+
+ {{ limitExceededMessage }}
+
+
+ {{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
+
-
+
-
-
+
+
+
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:
+
+
+ - Log in to your DNS provider’s dashboard
+ - Go to the DNS management section
+ - Create a new CNAME record using the information above
+ - Save the changes and allow up to 24 hours for the DNS to propagate
+
+
+ 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/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
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/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
diff --git a/package.json b/package.json
index e8c120074..2aefb3d53 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",
@@ -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 0f7b398b0..ca55fbe34 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
@@ -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
@@ -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':
@@ -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
@@ -5255,7 +5268,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
@@ -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)
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