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:
+
+
+ - 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/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