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,42 +77,103 @@ defineExpose({ dialogRef });
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
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"
|
:show-cancel-button="false"
|
||||||
@confirm="handleDialogConfirm"
|
:show-confirm-button="false"
|
||||||
|
@close="resetForm"
|
||||||
>
|
>
|
||||||
<template #description>
|
<NextButton
|
||||||
<p class="mb-0 text-sm text-n-slate-12">
|
icon="i-lucide-x"
|
||||||
{{
|
sm
|
||||||
t(
|
ghost
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
|
slate
|
||||||
)
|
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
|
||||||
}}
|
@click="onClose"
|
||||||
</p>
|
/>
|
||||||
</template>
|
<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">
|
<div class="flex flex-col gap-6 pt-6">
|
||||||
<span
|
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
|
||||||
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
|
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||||
>
|
{{
|
||||||
{{ subdomainCNAME }}
|
t(
|
||||||
</span>
|
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
|
||||||
<p class="text-sm text-n-slate-12">
|
)
|
||||||
{{
|
}}
|
||||||
t(
|
</h3>
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
|
<p class="mb-0 text-sm text-n-slate-12">
|
||||||
)
|
{{
|
||||||
}}
|
t(
|
||||||
</p>
|
'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>
|
</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,33 +155,76 @@ 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(
|
||||||
|
'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(
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end w-full">
|
<div class="flex items-center">
|
||||||
<Button
|
<div v-if="customDomainAddress" class="flex items-center gap-3">
|
||||||
v-if="customDomainAddress"
|
<div
|
||||||
color="slate"
|
v-if="statusText && isOnChatwootCloud"
|
||||||
:label="
|
v-tooltip="verificationErrors"
|
||||||
t(
|
class="flex items-center gap-3 flex-shrink-0"
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
|
>
|
||||||
)
|
<span
|
||||||
"
|
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
|
||||||
@click="addCustomDomainDialogRef.dialogRef.open()"
|
: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
|
<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