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