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:
Sivin Varghese
2025-07-30 23:22:47 +05:30
committed by GitHub
parent 97f1825a14
commit d9900e50a0
31 changed files with 874 additions and 106 deletions

View File

@@ -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')

View File

@@ -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;

View File

@@ -1,6 +1,9 @@
<script setup>
import { ref, reactive, watch } from 'vue';
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 Input from 'dashboard/components-next/input/Input.vue';
@@ -26,6 +29,20 @@ const formState = reactive({
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(
() => props.customDomain,
newVal => {
@@ -33,7 +50,10 @@ watch(
}
);
const handleDialogConfirm = () => {
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('addCustomDomain', formState.customDomain);
};
@@ -67,6 +87,11 @@ defineExpose({ dialogRef });
'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>
</template>

View File

@@ -1,9 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
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 Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
@@ -12,10 +18,20 @@ const props = defineProps({
},
});
const emit = defineEmits(['confirm']);
const emit = defineEmits(['send', 'close']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
@@ -25,10 +41,34 @@ const subdomainCNAME = computed(
() => `${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 handleDialogConfirm = () => {
emit('confirm');
const resetForm = () => {
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 });
@@ -37,42 +77,103 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
:show-confirm-button="false"
@close="resetForm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<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(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
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 }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<div class="flex flex-col gap-6">
<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"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
<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(
'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>
</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>
</template>

View File

@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
@@ -11,11 +12,52 @@ const props = defineProps({
type: Object,
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 { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
@@ -25,6 +67,45 @@ const customDomainAddress = computed(
() => 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 portal = {
id: props.activePortal?.id,
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<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
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@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
v-else
:label="
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
/>
</div>
</template>

View File

@@ -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 = () => {
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">

View File

@@ -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": {

View File

@@ -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;
</script>
@@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal;
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
</template>

View File

@@ -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 });
}
},
};

View File

@@ -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 => {

View File

@@ -6,6 +6,7 @@ export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
};
const state = {

View File

@@ -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,
},
},
};
},
};

View File

@@ -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({});

View File

@@ -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'],
});
});
});

View 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

View File

@@ -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')

View File

@@ -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

View File

@@ -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 providers 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—were here to assist you.</p>
</td>
</tr>

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

10
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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' => ''
}
)

View File

@@ -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

View 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