mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat(cloud): Add support for viewing status of SSL in custom domains (#12011)
# Pull Request Template ## Description Fixes [CW-4620](https://linear.app/chatwoot/issue/CW-4620/rethinking-custom-domains-in-chatwoot) <img width="642" height="187" alt="Screenshot 2025-07-29 at 8 17 44 PM" src="https://github.com/user-attachments/assets/ad2f5dac-4b27-4dce-93ca-6cbba74443fb" /> ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com> Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
		| @@ -47,6 +47,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | |||||||
|     head :ok |     head :ok | ||||||
|   end |   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 |   def process_attached_logo | ||||||
|     blob_id = params[:blob_id] |     blob_id = params[:blob_id] | ||||||
|     blob = ActiveStorage::Blob.find_by(id: blob_id) |     blob = ActiveStorage::Blob.find_by(id: blob_id) | ||||||
| @@ -60,12 +74,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def permitted_params |   def permitted_params | ||||||
|     params.permit(:id) |     params.permit(:id, :email) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def portal_params |   def portal_params | ||||||
|     params.require(:portal).permit( |     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: [] }] } |       :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
| @@ -88,4 +102,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController | |||||||
|     domain = URI.parse(@portal.custom_domain) |     domain = URI.parse(@portal.custom_domain) | ||||||
|     domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain |     domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def valid_email?(email) | ||||||
|  |     ValidEmail2::Address.new(email).valid? | ||||||
|   end |   end | ||||||
|  | end | ||||||
|  |  | ||||||
|  | Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController') | ||||||
|   | |||||||
| @@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient { | |||||||
|   deleteLogo(portalSlug) { |   deleteLogo(portalSlug) { | ||||||
|     return axios.delete(`${this.url}/${portalSlug}/logo`); |     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; | export default PortalsAPI; | ||||||
|   | |||||||
| @@ -1,6 +1,9 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, reactive, watch } from 'vue'; | import { ref, reactive, watch } from 'vue'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { helpers } from '@vuelidate/validators'; | ||||||
|  | import { isValidDomain } from '@chatwoot/utils'; | ||||||
|  |  | ||||||
| import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
| import Input from 'dashboard/components-next/input/Input.vue'; | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
| @@ -26,6 +29,20 @@ const formState = reactive({ | |||||||
|   customDomain: props.customDomain, |   customDomain: props.customDomain, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const rules = { | ||||||
|  |   customDomain: { | ||||||
|  |     isValidDomain: helpers.withMessage( | ||||||
|  |       () => | ||||||
|  |         t( | ||||||
|  |           'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR' | ||||||
|  |         ), | ||||||
|  |       isValidDomain | ||||||
|  |     ), | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(rules, formState); | ||||||
|  |  | ||||||
| watch( | watch( | ||||||
|   () => props.customDomain, |   () => props.customDomain, | ||||||
|   newVal => { |   newVal => { | ||||||
| @@ -33,7 +50,10 @@ watch( | |||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| const handleDialogConfirm = () => { | const handleDialogConfirm = async () => { | ||||||
|  |   const isFormCorrect = await v$.value.$validate(); | ||||||
|  |   if (!isFormCorrect) return; | ||||||
|  |  | ||||||
|   emit('addCustomDomain', formState.customDomain); |   emit('addCustomDomain', formState.customDomain); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -67,6 +87,11 @@ defineExpose({ dialogRef }); | |||||||
|           'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER' |           'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER' | ||||||
|         ) |         ) | ||||||
|       " |       " | ||||||
|  |       :message=" | ||||||
|  |         v$.customDomain.$error ? v$.customDomain.$errors[0].$message : '' | ||||||
|  |       " | ||||||
|  |       :message-type="v$.customDomain.$error ? 'error' : 'info'" | ||||||
|  |       @blur="v$.customDomain.$touch()" | ||||||
|     /> |     /> | ||||||
|   </Dialog> |   </Dialog> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,9 +1,15 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, computed } from 'vue'; | import { reactive, computed, ref } from 'vue'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { copyTextToClipboard } from 'shared/helpers/clipboard'; | ||||||
| import { getHostNameFromURL } from 'dashboard/helper/URLHelper'; | import { getHostNameFromURL } from 'dashboard/helper/URLHelper'; | ||||||
|  | import { email, required } from '@vuelidate/validators'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  |  | ||||||
| import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import NextButton from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   customDomain: { |   customDomain: { | ||||||
| @@ -12,10 +18,20 @@ const props = defineProps({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['confirm']); | const emit = defineEmits(['send', 'close']); | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const state = reactive({ | ||||||
|  |   email: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const validationRules = { | ||||||
|  |   email: { email, required }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(validationRules, state); | ||||||
|  |  | ||||||
| const domain = computed(() => { | const domain = computed(() => { | ||||||
|   const { hostURL, helpCenterURL } = window?.chatwootConfig || {}; |   const { hostURL, helpCenterURL } = window?.chatwootConfig || {}; | ||||||
|   return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || ''; |   return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || ''; | ||||||
| @@ -25,10 +41,34 @@ const subdomainCNAME = computed( | |||||||
|   () => `${props.customDomain} CNAME ${domain.value}` |   () => `${props.customDomain} CNAME ${domain.value}` | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | const handleCopy = async e => { | ||||||
|  |   e.stopPropagation(); | ||||||
|  |   await copyTextToClipboard(subdomainCNAME.value); | ||||||
|  |   useAlert( | ||||||
|  |     t( | ||||||
|  |       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY' | ||||||
|  |     ) | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
| const dialogRef = ref(null); | const dialogRef = ref(null); | ||||||
|  |  | ||||||
| const handleDialogConfirm = () => { | const resetForm = () => { | ||||||
|   emit('confirm'); |   v$.value.$reset(); | ||||||
|  |   state.email = ''; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onClose = () => { | ||||||
|  |   resetForm(); | ||||||
|  |   emit('close'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSend = async () => { | ||||||
|  |   const isFormCorrect = await v$.value.$validate(); | ||||||
|  |   if (!isFormCorrect) return; | ||||||
|  |  | ||||||
|  |   emit('send', state.email); | ||||||
|  |   onClose(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| defineExpose({ dialogRef }); | defineExpose({ dialogRef }); | ||||||
| @@ -37,20 +77,28 @@ defineExpose({ dialogRef }); | |||||||
| <template> | <template> | ||||||
|   <Dialog |   <Dialog | ||||||
|     ref="dialogRef" |     ref="dialogRef" | ||||||
|     :title=" |     :show-cancel-button="false" | ||||||
|  |     :show-confirm-button="false" | ||||||
|  |     @close="resetForm" | ||||||
|  |   > | ||||||
|  |     <NextButton | ||||||
|  |       icon="i-lucide-x" | ||||||
|  |       sm | ||||||
|  |       ghost | ||||||
|  |       slate | ||||||
|  |       class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2" | ||||||
|  |       @click="onClose" | ||||||
|  |     /> | ||||||
|  |     <div class="flex flex-col gap-6 divide-y divide-n-strong"> | ||||||
|  |       <div class="flex flex-col gap-6"> | ||||||
|  |         <div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10"> | ||||||
|  |           <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||||
|  |             {{ | ||||||
|               t( |               t( | ||||||
|                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER' |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER' | ||||||
|               ) |               ) | ||||||
|     " |             }} | ||||||
|     :confirm-button-label=" |           </h3> | ||||||
|       t( |  | ||||||
|         'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL' |  | ||||||
|       ) |  | ||||||
|     " |  | ||||||
|     :show-cancel-button="false" |  | ||||||
|     @confirm="handleDialogConfirm" |  | ||||||
|   > |  | ||||||
|     <template #description> |  | ||||||
|           <p class="mb-0 text-sm text-n-slate-12"> |           <p class="mb-0 text-sm text-n-slate-12"> | ||||||
|             {{ |             {{ | ||||||
|               t( |               t( | ||||||
| @@ -58,21 +106,74 @@ defineExpose({ dialogRef }); | |||||||
|               ) |               ) | ||||||
|             }} |             }} | ||||||
|           </p> |           </p> | ||||||
|     </template> |         </div> | ||||||
|  |         <div class="flex items-center gap-3 w-full"> | ||||||
|     <div class="flex flex-col gap-6"> |  | ||||||
|           <span |           <span | ||||||
|         class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong" |             class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong" | ||||||
|           > |           > | ||||||
|             {{ subdomainCNAME }} |             {{ subdomainCNAME }} | ||||||
|           </span> |           </span> | ||||||
|       <p class="text-sm text-n-slate-12"> |           <NextButton | ||||||
|  |             faded | ||||||
|  |             slate | ||||||
|  |             type="button" | ||||||
|  |             icon="i-lucide-copy" | ||||||
|  |             class="flex-shrink-0" | ||||||
|  |             @click="handleCopy" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="flex flex-col gap-6 pt-6"> | ||||||
|  |         <div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10"> | ||||||
|  |           <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||||
|             {{ |             {{ | ||||||
|               t( |               t( | ||||||
|             'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT' |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER' | ||||||
|  |               ) | ||||||
|  |             }} | ||||||
|  |           </h3> | ||||||
|  |           <p class="mb-0 text-sm text-n-slate-12"> | ||||||
|  |             {{ | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION' | ||||||
|               ) |               ) | ||||||
|             }} |             }} | ||||||
|           </p> |           </p> | ||||||
|         </div> |         </div> | ||||||
|  |         <form | ||||||
|  |           class="flex items-start gap-3 w-full" | ||||||
|  |           @submit.prevent="handleSend" | ||||||
|  |         > | ||||||
|  |           <Input | ||||||
|  |             v-model="state.email" | ||||||
|  |             :placeholder=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             :message=" | ||||||
|  |               v$.email.$error | ||||||
|  |                 ? t( | ||||||
|  |                     'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR' | ||||||
|  |                   ) | ||||||
|  |                 : '' | ||||||
|  |             " | ||||||
|  |             :message-type="v$.email.$error ? 'error' : 'info'" | ||||||
|  |             class="w-full" | ||||||
|  |             @blur="v$.email.$touch()" | ||||||
|  |           /> | ||||||
|  |           <NextButton | ||||||
|  |             :label=" | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON' | ||||||
|  |               ) | ||||||
|  |             " | ||||||
|  |             type="submit" | ||||||
|  |             class="flex-shrink-0" | ||||||
|  |           /> | ||||||
|  |         </form> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|   </Dialog> |   </Dialog> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref } from 'vue'; | import { computed, ref } from 'vue'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useAccount } from 'dashboard/composables/useAccount'; | ||||||
|  |  | ||||||
| import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue'; | import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue'; | ||||||
| import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue'; | import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue'; | ||||||
| @@ -11,11 +12,52 @@ const props = defineProps({ | |||||||
|     type: Object, |     type: Object, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   isFetchingStatus: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['updatePortalConfiguration']); | const emit = defineEmits([ | ||||||
|  |   'updatePortalConfiguration', | ||||||
|  |   'refreshStatus', | ||||||
|  |   'sendCnameInstructions', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const SSL_STATUS = { | ||||||
|  |   LIVE: ['active', 'staging_active'], | ||||||
|  |   PENDING: [ | ||||||
|  |     'provisioned', | ||||||
|  |     'pending', | ||||||
|  |     'initializing', | ||||||
|  |     'pending_validation', | ||||||
|  |     'pending_deployment', | ||||||
|  |     'pending_issuance', | ||||||
|  |     'holding_deployment', | ||||||
|  |     'holding_validation', | ||||||
|  |     'pending_expiration', | ||||||
|  |     'pending_cleanup', | ||||||
|  |     'pending_deletion', | ||||||
|  |     'staging_deployment', | ||||||
|  |     'backup_issued', | ||||||
|  |   ], | ||||||
|  |   ERROR: [ | ||||||
|  |     'blocked', | ||||||
|  |     'inactive', | ||||||
|  |     'moved', | ||||||
|  |     'expired', | ||||||
|  |     'deleted', | ||||||
|  |     'timed_out_initializing', | ||||||
|  |     'timed_out_validation', | ||||||
|  |     'timed_out_issuance', | ||||||
|  |     'timed_out_deployment', | ||||||
|  |     'timed_out_deletion', | ||||||
|  |     'deactivating', | ||||||
|  |   ], | ||||||
|  | }; | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  | const { isOnChatwootCloud } = useAccount(); | ||||||
|  |  | ||||||
| const addCustomDomainDialogRef = ref(null); | const addCustomDomainDialogRef = ref(null); | ||||||
| const dnsConfigurationDialogRef = ref(null); | const dnsConfigurationDialogRef = ref(null); | ||||||
| @@ -25,6 +67,45 @@ const customDomainAddress = computed( | |||||||
|   () => props.activePortal?.custom_domain || '' |   () => props.activePortal?.custom_domain || '' | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | const sslSettings = computed(() => props.activePortal?.ssl_settings || {}); | ||||||
|  | const verificationErrors = computed( | ||||||
|  |   () => sslSettings.value.verification_errors || '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const isLive = computed(() => | ||||||
|  |   SSL_STATUS.LIVE.includes(sslSettings.value.status) | ||||||
|  | ); | ||||||
|  | const isPending = computed(() => | ||||||
|  |   SSL_STATUS.PENDING.includes(sslSettings.value.status) | ||||||
|  | ); | ||||||
|  | const isError = computed(() => | ||||||
|  |   SSL_STATUS.ERROR.includes(sslSettings.value.status) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const statusText = computed(() => { | ||||||
|  |   if (isLive.value) | ||||||
|  |     return t( | ||||||
|  |       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE' | ||||||
|  |     ); | ||||||
|  |   if (isPending.value) | ||||||
|  |     return t( | ||||||
|  |       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING' | ||||||
|  |     ); | ||||||
|  |   if (isError.value) | ||||||
|  |     return t( | ||||||
|  |       'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR' | ||||||
|  |     ); | ||||||
|  |   return ''; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const statusColors = computed(() => { | ||||||
|  |   if (isLive.value) | ||||||
|  |     return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' }; | ||||||
|  |   if (isError.value) | ||||||
|  |     return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' }; | ||||||
|  |   return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' }; | ||||||
|  | }); | ||||||
|  |  | ||||||
| const updatePortalConfiguration = customDomain => { | const updatePortalConfiguration = customDomain => { | ||||||
|   const portal = { |   const portal = { | ||||||
|     id: props.activePortal?.id, |     id: props.activePortal?.id, | ||||||
| @@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => { | |||||||
|   updatedDomainAddress.value = ''; |   updatedDomainAddress.value = ''; | ||||||
|   dnsConfigurationDialogRef.value.dialogRef.close(); |   dnsConfigurationDialogRef.value.dialogRef.close(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const onClickRefreshSSLStatus = () => { | ||||||
|  |   emit('refreshStatus'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onClickSend = email => { | ||||||
|  |   emit('sendCnameInstructions', { | ||||||
|  |     portalSlug: props.activePortal?.slug, | ||||||
|  |     email, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -63,11 +155,9 @@ const closeDNSConfigurationDialog = () => { | |||||||
|       </span> |       </span> | ||||||
|     </div> |     </div> | ||||||
|     <div class="flex flex-col w-full gap-4"> |     <div class="flex flex-col w-full gap-4"> | ||||||
|       <div class="flex justify-between w-full gap-2"> |       <div class="flex items-center justify-between w-full gap-2"> | ||||||
|         <div |         <div v-if="customDomainAddress" class="flex flex-col gap-1"> | ||||||
|           v-if="customDomainAddress" |           <div class="flex items-center w-full h-8 gap-4"> | ||||||
|           class="flex items-center w-full h-8 gap-4" |  | ||||||
|         > |  | ||||||
|             <label class="text-sm font-medium text-n-slate-12"> |             <label class="text-sm font-medium text-n-slate-12"> | ||||||
|               {{ |               {{ | ||||||
|                 t( |                 t( | ||||||
| @@ -79,17 +169,62 @@ const closeDNSConfigurationDialog = () => { | |||||||
|               {{ customDomainAddress }} |               {{ customDomainAddress }} | ||||||
|             </span> |             </span> | ||||||
|           </div> |           </div> | ||||||
|         <div class="flex items-center justify-end w-full"> |           <span | ||||||
|  |             v-if="!isLive && isOnChatwootCloud" | ||||||
|  |             class="text-sm text-n-slate-11" | ||||||
|  |           > | ||||||
|  |             {{ | ||||||
|  |               t( | ||||||
|  |                 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION' | ||||||
|  |               ) | ||||||
|  |             }} | ||||||
|  |           </span> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex items-center"> | ||||||
|  |           <div v-if="customDomainAddress" class="flex items-center gap-3"> | ||||||
|  |             <div | ||||||
|  |               v-if="statusText && isOnChatwootCloud" | ||||||
|  |               v-tooltip="verificationErrors" | ||||||
|  |               class="flex items-center gap-3 flex-shrink-0" | ||||||
|  |             > | ||||||
|  |               <span | ||||||
|  |                 class="size-1.5 rounded-full outline outline-2 block flex-shrink-0" | ||||||
|  |                 :class="statusColors.bubble" | ||||||
|  |               /> | ||||||
|  |               <span | ||||||
|  |                 :class="statusColors.text" | ||||||
|  |                 class="text-sm leading-[16px] font-medium" | ||||||
|  |               > | ||||||
|  |                 {{ statusText }} | ||||||
|  |               </span> | ||||||
|  |             </div> | ||||||
|  |             <div | ||||||
|  |               v-if="statusText && isOnChatwootCloud" | ||||||
|  |               class="w-px h-3 bg-n-weak" | ||||||
|  |             /> | ||||||
|             <Button |             <Button | ||||||
|             v-if="customDomainAddress" |               slate | ||||||
|             color="slate" |               sm | ||||||
|  |               link | ||||||
|               :label=" |               :label=" | ||||||
|                 t( |                 t( | ||||||
|                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON' |                   'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON' | ||||||
|                 ) |                 ) | ||||||
|               " |               " | ||||||
|  |               class="hover:!no-underline flex-shrink-0" | ||||||
|               @click="addCustomDomainDialogRef.dialogRef.open()" |               @click="addCustomDomainDialogRef.dialogRef.open()" | ||||||
|             /> |             /> | ||||||
|  |             <div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" /> | ||||||
|  |             <Button | ||||||
|  |               v-if="isOnChatwootCloud" | ||||||
|  |               slate | ||||||
|  |               sm | ||||||
|  |               link | ||||||
|  |               icon="i-lucide-refresh-ccw" | ||||||
|  |               :class="isFetchingStatus && 'animate-spin'" | ||||||
|  |               @click="onClickRefreshSSLStatus" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|           <Button |           <Button | ||||||
|             v-else |             v-else | ||||||
|             :label=" |             :label=" | ||||||
| @@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => { | |||||||
|     <DNSConfigurationDialog |     <DNSConfigurationDialog | ||||||
|       ref="dnsConfigurationDialogRef" |       ref="dnsConfigurationDialogRef" | ||||||
|       :custom-domain="updatedDomainAddress || customDomainAddress" |       :custom-domain="updatedDomainAddress || customDomainAddress" | ||||||
|       @confirm="closeDNSConfigurationDialog" |       @close="closeDNSConfigurationDialog" | ||||||
|  |       @send="onClickSend" | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -26,6 +26,8 @@ const emit = defineEmits([ | |||||||
|   'updatePortal', |   'updatePortal', | ||||||
|   'updatePortalConfiguration', |   'updatePortalConfiguration', | ||||||
|   'deletePortal', |   'deletePortal', | ||||||
|  |   'refreshStatus', | ||||||
|  |   'sendCnameInstructions', | ||||||
| ]); | ]); | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
| @@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null); | |||||||
| const currentPortalSlug = computed(() => route.params.portalSlug); | const currentPortalSlug = computed(() => route.params.portalSlug); | ||||||
|  |  | ||||||
| const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal'); | ||||||
|  | const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus'); | ||||||
|  |  | ||||||
| const activePortal = computed(() => { | const activePortal = computed(() => { | ||||||
|   return props.portals?.find(portal => portal.slug === currentPortalSlug.value); |   return props.portals?.find(portal => portal.slug === currentPortalSlug.value); | ||||||
| @@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => { | |||||||
|   emit('updatePortalConfiguration', portal); |   emit('updatePortalConfiguration', portal); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const fetchSSLStatus = () => { | ||||||
|  |   emit('refreshStatus'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSendCnameInstructions = payload => { | ||||||
|  |   emit('sendCnameInstructions', payload); | ||||||
|  | }; | ||||||
|  |  | ||||||
| const openConfirmDeletePortalDialog = () => { | const openConfirmDeletePortalDialog = () => { | ||||||
|   confirmDeletePortalDialogRef.value.dialogRef.open(); |   confirmDeletePortalDialogRef.value.dialogRef.open(); | ||||||
| }; | }; | ||||||
| @@ -85,7 +96,10 @@ const handleDeletePortal = () => { | |||||||
|         <PortalConfigurationSettings |         <PortalConfigurationSettings | ||||||
|           :active-portal="activePortal" |           :active-portal="activePortal" | ||||||
|           :is-fetching="isFetching" |           :is-fetching="isFetching" | ||||||
|  |           :is-fetching-status="isFetchingSSLStatus" | ||||||
|           @update-portal-configuration="handleUpdatePortalConfiguration" |           @update-portal-configuration="handleUpdatePortalConfiguration" | ||||||
|  |           @refresh-status="fetchSSLStatus" | ||||||
|  |           @send-cname-instructions="handleSendCnameInstructions" | ||||||
|         /> |         /> | ||||||
|         <div class="w-full h-px bg-n-weak" /> |         <div class="w-full h-px bg-n-weak" /> | ||||||
|         <div class="flex items-end justify-between w-full gap-4"> |         <div class="flex items-end justify-between w-full gap-4"> | ||||||
|   | |||||||
| @@ -157,6 +157,12 @@ | |||||||
|             "DELETE_SUCCESS": "Portal deleted successfully", |             "DELETE_SUCCESS": "Portal deleted successfully", | ||||||
|             "DELETE_ERROR": "Error while deleting portal" |             "DELETE_ERROR": "Error while deleting portal" | ||||||
|           } |           } | ||||||
|  |         }, | ||||||
|  |         "SEND_CNAME_INSTRUCTIONS": { | ||||||
|  |           "API": { | ||||||
|  |             "SUCCESS_MESSAGE": "CNAME instructions sent successfully", | ||||||
|  |             "ERROR_MESSAGE": "Error while sending CNAME instructions" | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       "EDIT": { |       "EDIT": { | ||||||
| @@ -747,9 +753,15 @@ | |||||||
|           "HEADER": "Custom domain", |           "HEADER": "Custom domain", | ||||||
|           "LABEL": "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.", |           "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", |           "PLACEHOLDER": "Portal custom domain", | ||||||
|           "EDIT_BUTTON": "Edit custom domain", |           "EDIT_BUTTON": "Edit", | ||||||
|           "ADD_BUTTON": "Add custom domain", |           "ADD_BUTTON": "Add custom domain", | ||||||
|  |           "STATUS": { | ||||||
|  |             "LIVE": "Live", | ||||||
|  |             "PENDING": "Awaiting verification", | ||||||
|  |             "ERROR": "Verification failed" | ||||||
|  |           }, | ||||||
|           "DIALOG": { |           "DIALOG": { | ||||||
|             "ADD_HEADER": "Add custom domain", |             "ADD_HEADER": "Add custom domain", | ||||||
|             "EDIT_HEADER": "Edit custom domain", |             "EDIT_HEADER": "Edit custom domain", | ||||||
| @@ -757,13 +769,20 @@ | |||||||
|             "EDIT_CONFIRM_BUTTON_LABEL": "Update domain", |             "EDIT_CONFIRM_BUTTON_LABEL": "Update domain", | ||||||
|             "LABEL": "Custom domain", |             "LABEL": "Custom domain", | ||||||
|             "PLACEHOLDER": "Portal 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": { |           "DNS_CONFIGURATION_DIALOG": { | ||||||
|             "HEADER": "DNS configuration", |             "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", |             "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.", |             "COPY": "Successfully copied CNAME", | ||||||
|             "CONFIRM_BUTTON_LABEL": "Got it!" |             "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": { |         "DELETE_PORTAL": { | ||||||
|   | |||||||
| @@ -4,12 +4,16 @@ import { useRoute, useRouter } from 'vue-router'; | |||||||
| import { useUISettings } from 'dashboard/composables/useUISettings'; | import { useUISettings } from 'dashboard/composables/useUISettings'; | ||||||
| import { useAlert } from 'dashboard/composables'; | import { useAlert } from 'dashboard/composables'; | ||||||
| import { useMapGetter, useStore } from 'dashboard/composables/store.js'; | 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'; | import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue'; | ||||||
|  |  | ||||||
|  | const SSL_STATUS_FETCH_INTERVAL = 5000; | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
| const store = useStore(); | const store = useStore(); | ||||||
| const route = useRoute(); | const route = useRoute(); | ||||||
| const router = useRouter(); | const router = useRouter(); | ||||||
|  | const { isOnChatwootCloud } = useAccount(); | ||||||
|  |  | ||||||
| const { updateUISettings } = useUISettings(); | const { updateUISettings } = useUISettings(); | ||||||
|  |  | ||||||
| @@ -24,6 +28,15 @@ const getDefaultLocale = slug => { | |||||||
|   return getPortalBySlug.value(slug)?.meta?.default_locale; |   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 fetchPortalAndItsCategories = async (slug, locale) => { | ||||||
|   const selectedPortalParam = { portalSlug: slug, locale }; |   const selectedPortalParam = { portalSlug: slug, locale }; | ||||||
|   await Promise.all([ |   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 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; | const handleDeletePortal = deletePortal; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal; | |||||||
|     @update-portal="handleUpdatePortal" |     @update-portal="handleUpdatePortal" | ||||||
|     @update-portal-configuration="handleUpdatePortalConfiguration" |     @update-portal-configuration="handleUpdatePortalConfiguration" | ||||||
|     @delete-portal="handleDeletePortal" |     @delete-portal="handleDeletePortal" | ||||||
|  |     @refresh-status="fetchSSLStatus" | ||||||
|  |     @send-cname-instructions="handleSendCnameInstructions" | ||||||
|   /> |   /> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -116,4 +116,24 @@ export const actions = { | |||||||
|       isSwitching, |       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 }); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ export const getters = { | |||||||
|   isFetchingPortals: state => state.uiFlags.isFetching, |   isFetchingPortals: state => state.uiFlags.isFetching, | ||||||
|   isCreatingPortal: state => state.uiFlags.isCreating, |   isCreatingPortal: state => state.uiFlags.isCreating, | ||||||
|   isSwitchingPortal: state => state.uiFlags.isSwitching, |   isSwitchingPortal: state => state.uiFlags.isSwitching, | ||||||
|  |   isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus, | ||||||
|   portalBySlug: |   portalBySlug: | ||||||
|     (...getterArguments) => |     (...getterArguments) => | ||||||
|     portalId => { |     portalId => { | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ export const defaultPortalFlags = { | |||||||
|   isFetching: false, |   isFetching: false, | ||||||
|   isUpdating: false, |   isUpdating: false, | ||||||
|   isDeleting: false, |   isDeleting: false, | ||||||
|  |   isFetchingSSLStatus: false, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const state = { | const state = { | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ export const types = { | |||||||
|   REMOVE_PORTAL_ID: 'removePortalId', |   REMOVE_PORTAL_ID: 'removePortalId', | ||||||
|   SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag', |   SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag', | ||||||
|   SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag', |   SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag', | ||||||
|  |   SET_SSL_SETTINGS: 'setSSLSettings', | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const mutations = { | export const mutations = { | ||||||
| @@ -110,4 +111,18 @@ export const mutations = { | |||||||
|   [types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) { |   [types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) { | ||||||
|     $state.uiFlags.isSwitching = 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, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -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', () => { |   describe('#delete', () => { | ||||||
|     it('sends correct actions if API is success', async () => { |     it('sends correct actions if API is success', async () => { | ||||||
|       axios.delete.mockResolvedValue({}); |       axios.delete.mockResolvedValue({}); | ||||||
|   | |||||||
| @@ -89,6 +89,25 @@ describe('#mutations', () => { | |||||||
|         isFetching: true, |         isFetching: true, | ||||||
|         isUpdating: false, |         isUpdating: false, | ||||||
|         isDeleting: 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'], | ||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|   | |||||||
							
								
								
									
										41
									
								
								app/mailers/portal_instructions_mailer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								app/mailers/portal_instructions_mailer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -26,6 +26,14 @@ class PortalPolicy < ApplicationPolicy | |||||||
|   def logo? |   def logo? | ||||||
|     @account_user.administrator? |     @account_user.administrator? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def send_instructions? | ||||||
|  |     @account_user.administrator? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def ssl_status? | ||||||
|  |     @account.users.include?(@user) | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
| PortalPolicy.prepend_mod_with('PortalPolicy') | PortalPolicy.prepend_mod_with('PortalPolicy') | ||||||
|   | |||||||
| @@ -34,3 +34,10 @@ json.meta do | |||||||
|   json.categories_count portal.categories.try(:size) |   json.categories_count portal.categories.try(:size) | ||||||
|   json.default_locale portal.default_locale |   json.default_locale portal.default_locale | ||||||
| end | 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 | ||||||
|   | |||||||
| @@ -0,0 +1,30 @@ | |||||||
|  | <tr> | ||||||
|  |   <td> | ||||||
|  |     <p>Hello there,</p> | ||||||
|  |     <p>To complete the setup of your help center, you'll need to update the DNS settings for your custom domain: <strong>{{ cname_record | split: ' ' | first }}</strong>.</p> | ||||||
|  |     <p>Please add the following CNAME record to your DNS provider's configuration:</p> | ||||||
|  |   </td> | ||||||
|  | </tr> | ||||||
|  |  | ||||||
|  | <tr> | ||||||
|  |   <td> | ||||||
|  |     <p><strong>{{ cname_record }}</strong></p> | ||||||
|  |   </td> | ||||||
|  | </tr> | ||||||
|  |  | ||||||
|  | <tr> | ||||||
|  |   <td> | ||||||
|  |     <p>Step-by-step Instructions:</p> | ||||||
|  |  | ||||||
|  |     <ol> | ||||||
|  |       <li>Log in to your DNS provider’s dashboard</li> | ||||||
|  |       <li>Go to the DNS management section</li> | ||||||
|  |       <li>Create a new CNAME record using the information above</li> | ||||||
|  |       <li>Save the changes and allow up to 24 hours for the DNS to propagate</li> | ||||||
|  |     </ol> | ||||||
|  |  | ||||||
|  |     <p>Once the DNS record is live, your custom domain will automatically be secured with an SSL certificate.</p> | ||||||
|  |  | ||||||
|  |     <p>If you have any questions or need help, feel free to reach out to our support team—we’re here to assist you.</p> | ||||||
|  |   </td> | ||||||
|  | </tr> | ||||||
| @@ -358,3 +358,12 @@ en: | |||||||
|  |  | ||||||
|       Transcript: |       Transcript: | ||||||
|       %{format_messages} |       %{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' | ||||||
|   | |||||||
| @@ -290,6 +290,8 @@ Rails.application.routes.draw do | |||||||
|             member do |             member do | ||||||
|               patch :archive |               patch :archive | ||||||
|               delete :logo |               delete :logo | ||||||
|  |               post :send_instructions | ||||||
|  |               get :ssl_status | ||||||
|             end |             end | ||||||
|             resources :categories |             resources :categories | ||||||
|             resources :articles do |             resources :articles do | ||||||
|   | |||||||
| @@ -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 | ||||||
| @@ -17,4 +17,25 @@ class Cloudflare::BaseCloudflareZoneService | |||||||
|   def zone_id |   def zone_id | ||||||
|     InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value |     InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value | ||||||
|   end |   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 | end | ||||||
|   | |||||||
| @@ -14,21 +14,10 @@ class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneSer | |||||||
|     data = response.parsed_response['result'] |     data = response.parsed_response['result'] | ||||||
|  |  | ||||||
|     if data.present? |     if data.present? | ||||||
|       update_portal_ssl_settings(data.first) |       update_portal_ssl_settings(@portal, data.first) | ||||||
|       return { data: data } |       return { data: data } | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     { errors: ['Hostname is missing in Cloudflare'] } |     { errors: ['Hostname is missing in Cloudflare'] } | ||||||
|   end |   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 | end | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe | |||||||
|     data = response.parsed_response['result'] |     data = response.parsed_response['result'] | ||||||
|  |  | ||||||
|     if data.present? |     if data.present? | ||||||
|       update_portal_ssl_settings(data) |       update_portal_ssl_settings(@portal, data) | ||||||
|       return { data: data } |       return { data: data } | ||||||
|     end |     end | ||||||
|  |  | ||||||
| @@ -25,16 +25,13 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe | |||||||
|     HTTParty.post( |     HTTParty.post( | ||||||
|       "#{BASE_URI}/zones/#{zone_id}/custom_hostnames", |       "#{BASE_URI}/zones/#{zone_id}/custom_hostnames", | ||||||
|       headers: headers, |       headers: headers, | ||||||
|       body: { hostname: @portal.custom_domain }.to_json |       body: { | ||||||
|  |         hostname: @portal.custom_domain, | ||||||
|  |         ssl: { | ||||||
|  |           method: 'http', | ||||||
|  |           type: 'dv' | ||||||
|  |         } | ||||||
|  |       }.to_json | ||||||
|     ) |     ) | ||||||
|   end |   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 | end | ||||||
|   | |||||||
| @@ -34,7 +34,7 @@ | |||||||
|     "@breezystack/lamejs": "^1.2.7", |     "@breezystack/lamejs": "^1.2.7", | ||||||
|     "@chatwoot/ninja-keys": "1.2.3", |     "@chatwoot/ninja-keys": "1.2.3", | ||||||
|     "@chatwoot/prosemirror-schema": "1.2.1", |     "@chatwoot/prosemirror-schema": "1.2.1", | ||||||
|     "@chatwoot/utils": "^0.0.48", |     "@chatwoot/utils": "^0.0.49", | ||||||
|     "@formkit/core": "^1.6.7", |     "@formkit/core": "^1.6.7", | ||||||
|     "@formkit/vue": "^1.6.7", |     "@formkit/vue": "^1.6.7", | ||||||
|     "@hcaptcha/vue3-hcaptcha": "^1.3.0", |     "@hcaptcha/vue3-hcaptcha": "^1.3.0", | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -23,8 +23,8 @@ importers: | |||||||
|         specifier: 1.2.1 |         specifier: 1.2.1 | ||||||
|         version: 1.2.1 |         version: 1.2.1 | ||||||
|       '@chatwoot/utils': |       '@chatwoot/utils': | ||||||
|         specifier: ^0.0.48 |         specifier: ^0.0.49 | ||||||
|         version: 0.0.48 |         version: 0.0.49 | ||||||
|       '@formkit/core': |       '@formkit/core': | ||||||
|         specifier: ^1.6.7 |         specifier: ^1.6.7 | ||||||
|         version: 1.6.7 |         version: 1.6.7 | ||||||
| @@ -406,8 +406,8 @@ packages: | |||||||
|   '@chatwoot/prosemirror-schema@1.2.1': |   '@chatwoot/prosemirror-schema@1.2.1': | ||||||
|     resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==} |     resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==} | ||||||
|  |  | ||||||
|   '@chatwoot/utils@0.0.48': |   '@chatwoot/utils@0.0.49': | ||||||
|     resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==} |     resolution: {integrity: sha512-Co68VzaFtctTNYKY6y4izBBATvk6/8ZVtkyEP5HL72uhFDA11LrY5pqSh04HMoFyfdIU+uVPimfI45HAeso1IA==} | ||||||
|     engines: {node: '>=10'} |     engines: {node: '>=10'} | ||||||
|  |  | ||||||
|   '@codemirror/commands@6.7.0': |   '@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-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) | ||||||
|       prosemirror-view: 1.34.1 |       prosemirror-view: 1.34.1 | ||||||
|  |  | ||||||
|   '@chatwoot/utils@0.0.48': |   '@chatwoot/utils@0.0.49': | ||||||
|     dependencies: |     dependencies: | ||||||
|       date-fns: 2.30.0 |       date-fns: 2.30.0 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -210,4 +210,76 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do | |||||||
|       end |       end | ||||||
|     end |     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 | end | ||||||
|   | |||||||
| @@ -87,4 +87,73 @@ RSpec.describe 'Enterprise Portal API', type: :request do | |||||||
|       end |       end | ||||||
|     end |     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 | end | ||||||
|   | |||||||
| @@ -96,8 +96,10 @@ RSpec.describe Cloudflare::CheckCustomHostnameService do | |||||||
|  |  | ||||||
|           expect(portal).to receive(:update).with( |           expect(portal).to receive(:update).with( | ||||||
|             ssl_settings: { |             ssl_settings: { | ||||||
|               'cf_verification_id': 'verification-id', |               'cf_verification_id' => 'verification-id', | ||||||
|               'cf_verification_body': 'verification-body' |               'cf_verification_body' => 'verification-body', | ||||||
|  |               'cf_status' => nil, | ||||||
|  |               'cf_verification_errors' => '' | ||||||
|             } |             } | ||||||
|           ) |           ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -54,7 +54,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do | |||||||
|  |  | ||||||
|           stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') |           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' }, |             .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' }) |             .to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' }) | ||||||
|  |  | ||||||
|           result = service.perform |           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') |           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' }, |             .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' }) |             .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) | ||||||
|  |  | ||||||
|           result = service.perform |           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') |           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' }, |             .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' }) |             .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 |           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']) |           expect(result).to eq(data: success_response['result']) | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								spec/mailers/portal_instructions_mailer_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								spec/mailers/portal_instructions_mailer_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese