diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
index 02b5001b9..83800e002 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
@@ -26,6 +26,8 @@ const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
+ 'refreshStatus',
+ 'sendCnameInstructions',
]);
const { t } = useI18n();
@@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
+const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
+const fetchSSLStatus = () => {
+ emit('refreshStatus');
+};
+
+const handleSendCnameInstructions = payload => {
+ emit('sendCnameInstructions', payload);
+};
+
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
@@ -85,7 +96,10 @@ const handleDeletePortal = () => {
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index f437b83d9..bd7fb986a 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -157,6 +157,12 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "Error while deleting portal"
}
+ },
+ "SEND_CNAME_INSTRUCTIONS": {
+ "API": {
+ "SUCCESS_MESSAGE": "CNAME instructions sent successfully",
+ "ERROR_MESSAGE": "Error while sending CNAME instructions"
+ }
}
},
"EDIT": {
@@ -747,9 +753,15 @@
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
+ "STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "Portal custom domain",
- "EDIT_BUTTON": "Edit custom domain",
+ "EDIT_BUTTON": "Edit",
"ADD_BUTTON": "Add custom domain",
+ "STATUS": {
+ "LIVE": "Live",
+ "PENDING": "Awaiting verification",
+ "ERROR": "Verification failed"
+ },
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -757,13 +769,20 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
- "ERROR": "Custom domain is required"
+ "ERROR": "Custom domain is required",
+ "FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
- "HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
- "CONFIRM_BUTTON_LABEL": "Got it!"
+ "COPY": "Successfully copied CNAME",
+ "SEND_INSTRUCTIONS": {
+ "HEADER": "Send instructions",
+ "DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
+ "PLACEHOLDER": "Enter their email",
+ "ERROR": "Enter a valid email address",
+ "SEND_BUTTON": "Send"
+ }
}
},
"DELETE_PORTAL": {
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue
index 68a9ee953..e0953335f 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsSettingsIndexPage.vue
@@ -4,12 +4,16 @@ import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
+import { useAccount } from 'dashboard/composables/useAccount';
import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue';
+const SSL_STATUS_FETCH_INTERVAL = 5000;
+
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
+const { isOnChatwootCloud } = useAccount();
const { updateUISettings } = useUISettings();
@@ -24,6 +28,15 @@ const getDefaultLocale = slug => {
return getPortalBySlug.value(slug)?.meta?.default_locale;
};
+const fetchSSLStatus = () => {
+ if (!isOnChatwootCloud.value) return;
+
+ const { portalSlug } = route.params;
+ store.dispatch('portals/sslStatus', {
+ portalSlug,
+ });
+};
+
const fetchPortalAndItsCategories = async (slug, locale) => {
const selectedPortalParam = { portalSlug: slug, locale };
await Promise.all([
@@ -106,8 +119,35 @@ const deletePortal = async selectedPortalForDelete => {
}
};
+const handleSendCnameInstructions = async payload => {
+ try {
+ await store.dispatch('portals/sendCnameInstructions', payload);
+ useAlert(
+ t(
+ 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.SUCCESS_MESSAGE'
+ )
+ );
+ } catch (error) {
+ useAlert(
+ error?.message ||
+ t(
+ 'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.ERROR_MESSAGE'
+ )
+ );
+ }
+};
+
const handleUpdatePortal = updatePortalSettings;
-const handleUpdatePortalConfiguration = updatePortalSettings;
+const handleUpdatePortalConfiguration = portalObj => {
+ updatePortalSettings(portalObj);
+
+ // If custom domain is added or updated, fetch SSL status after a delay of 5 seconds (only on Chatwoot cloud)
+ if (portalObj?.custom_domain && isOnChatwootCloud.value) {
+ setTimeout(() => {
+ fetchSSLStatus();
+ }, SSL_STATUS_FETCH_INTERVAL);
+ }
+};
const handleDeletePortal = deletePortal;
@@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal;
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
+ @refresh-status="fetchSSLStatus"
+ @send-cname-instructions="handleSendCnameInstructions"
/>
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js b/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js
index 05a28756c..130ea097e 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js
@@ -116,4 +116,24 @@ export const actions = {
isSwitching,
});
},
+
+ sendCnameInstructions: async (_, { portalSlug, email }) => {
+ try {
+ await portalAPIs.sendCnameInstructions(portalSlug, email);
+ } catch (error) {
+ throwErrorMessage(error);
+ }
+ },
+
+ sslStatus: async ({ commit }, { portalSlug }) => {
+ try {
+ commit(types.SET_UI_FLAG, { isFetchingSSLStatus: true });
+ const { data } = await portalAPIs.sslStatus(portalSlug);
+ commit(types.SET_SSL_SETTINGS, { portalSlug, sslSettings: data });
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_UI_FLAG, { isFetchingSSLStatus: false });
+ }
+ },
};
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js b/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js
index 7dd8b2b22..f40af2502 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js
@@ -8,6 +8,7 @@ export const getters = {
isFetchingPortals: state => state.uiFlags.isFetching,
isCreatingPortal: state => state.uiFlags.isCreating,
isSwitchingPortal: state => state.uiFlags.isSwitching,
+ isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus,
portalBySlug:
(...getterArguments) =>
portalId => {
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js
index 621e180e5..4feb098c0 100755
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js
@@ -6,6 +6,7 @@ export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
+ isFetchingSSLStatus: false,
};
const state = {
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js b/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js
index 7e14bc63e..3f2fce3c8 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js
@@ -13,6 +13,7 @@ export const types = {
REMOVE_PORTAL_ID: 'removePortalId',
SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag',
SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag',
+ SET_SSL_SETTINGS: 'setSSLSettings',
};
export const mutations = {
@@ -110,4 +111,18 @@ export const mutations = {
[types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) {
$state.uiFlags.isSwitching = isSwitching;
},
+
+ [types.SET_SSL_SETTINGS]($state, { portalSlug, sslSettings }) {
+ const portal = $state.portals.byId[portalSlug];
+ $state.portals.byId = {
+ ...$state.portals.byId,
+ [portalSlug]: {
+ ...portal,
+ ssl_settings: {
+ ...portal.ssl_settings,
+ ...sslSettings,
+ },
+ },
+ };
+ },
};
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js
index 9acbbb0b5..9bd85cb98 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js
@@ -135,6 +135,36 @@ describe('#actions', () => {
});
});
+ describe('#sslStatus', () => {
+ it('commits SET_SSL_SETTINGS with data from API', async () => {
+ axios.get.mockResolvedValue({
+ data: { status: 'active', verification_errors: [] },
+ });
+ await actions.sslStatus({ commit }, { portalSlug: 'domain' });
+ expect(commit.mock.calls).toEqual([
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
+ [
+ types.SET_SSL_SETTINGS,
+ {
+ portalSlug: 'domain',
+ sslSettings: { status: 'active', verification_errors: [] },
+ },
+ ],
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
+ ]);
+ });
+ it('throws error and does not commit when API fails', async () => {
+ axios.get.mockRejectedValue({ message: 'error' });
+ await expect(
+ actions.sslStatus({ commit }, { portalSlug: 'domain' })
+ ).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
+ ]);
+ });
+ });
+
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js
index 2e58c1de7..c468ee017 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js
@@ -89,6 +89,25 @@ describe('#mutations', () => {
isFetching: true,
isUpdating: false,
isDeleting: false,
+ isFetchingSSLStatus: false,
+ });
+ });
+ });
+
+ describe('[types.SET_SSL_SETTINGS]', () => {
+ it('merges new ssl settings into existing portal.ssl_settings', () => {
+ state.portals.byId.domain = {
+ slug: 'domain',
+ ssl_settings: { cf_status: 'pending' },
+ };
+ mutations[types.SET_SSL_SETTINGS](state, {
+ portalSlug: 'domain',
+ sslSettings: { status: 'active', verification_errors: ['error'] },
+ });
+ expect(state.portals.byId.domain.ssl_settings).toEqual({
+ cf_status: 'pending',
+ status: 'active',
+ verification_errors: ['error'],
});
});
});
diff --git a/app/mailers/portal_instructions_mailer.rb b/app/mailers/portal_instructions_mailer.rb
new file mode 100644
index 000000000..eccbd7db1
--- /dev/null
+++ b/app/mailers/portal_instructions_mailer.rb
@@ -0,0 +1,41 @@
+class PortalInstructionsMailer < ApplicationMailer
+ def send_cname_instructions(portal:, recipient_email:)
+ return unless smtp_config_set_or_development?
+ return if target_domain.blank?
+
+ @portal = portal
+ @cname_record = generate_cname_record
+
+ send_mail_with_liquid(
+ to: recipient_email,
+ subject: I18n.t('portals.send_instructions.subject', custom_domain: @portal.custom_domain)
+ )
+ end
+
+ private
+
+ def liquid_locals
+ { cname_record: @cname_record }
+ end
+
+ def generate_cname_record
+ "#{@portal.custom_domain} CNAME #{target_domain}"
+ end
+
+ def target_domain
+ helpcenter_url = ENV.fetch('HELPCENTER_URL', '')
+ frontend_url = ENV.fetch('FRONTEND_URL', '')
+
+ return extract_hostname(helpcenter_url) if helpcenter_url.present?
+ return extract_hostname(frontend_url) if frontend_url.present?
+
+ ''
+ end
+
+ def extract_hostname(url)
+ uri = URI.parse(url)
+ uri.host
+ rescue URI::InvalidURIError
+ url.gsub(%r{https?://}, '').split('/').first
+ end
+end
diff --git a/app/policies/portal_policy.rb b/app/policies/portal_policy.rb
index 1e09c41f6..0eace233c 100644
--- a/app/policies/portal_policy.rb
+++ b/app/policies/portal_policy.rb
@@ -26,6 +26,14 @@ class PortalPolicy < ApplicationPolicy
def logo?
@account_user.administrator?
end
+
+ def send_instructions?
+ @account_user.administrator?
+ end
+
+ def ssl_status?
+ @account.users.include?(@user)
+ end
end
PortalPolicy.prepend_mod_with('PortalPolicy')
diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
index 5e267f60c..37020b5dd 100644
--- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder
+++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
@@ -34,3 +34,10 @@ json.meta do
json.categories_count portal.categories.try(:size)
json.default_locale portal.default_locale
end
+
+if portal.ssl_settings.present?
+ json.ssl_settings do
+ json.status portal.ssl_settings['cf_status']
+ json.verification_errors portal.ssl_settings['cf_verification_errors']
+ end
+end
diff --git a/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid b/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid
new file mode 100644
index 000000000..b14ca1098
--- /dev/null
+++ b/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid
@@ -0,0 +1,30 @@
+
+ |
+ Hello there,
+ To complete the setup of your help center, you'll need to update the DNS settings for your custom domain: {{ cname_record | split: ' ' | first }}.
+ Please add the following CNAME record to your DNS provider's configuration:
+ |
+
+
+
+ |
+ {{ cname_record }}
+ |
+
+
+
+ |
+ Step-by-step Instructions:
+
+
+ - Log in to your DNS provider’s dashboard
+ - Go to the DNS management section
+ - Create a new CNAME record using the information above
+ - Save the changes and allow up to 24 hours for the DNS to propagate
+
+
+ Once the DNS record is live, your custom domain will automatically be secured with an SSL certificate.
+
+ If you have any questions or need help, feel free to reach out to our support team—we’re here to assist you.
+ |
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 221f83472..9faa0cd9d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -358,3 +358,12 @@ en:
Transcript:
%{format_messages}
+ portals:
+ send_instructions:
+ email_required: 'Email is required'
+ invalid_email_format: 'Invalid email format'
+ custom_domain_not_configured: 'Custom domain is not configured'
+ instructions_sent_successfully: 'Instructions sent successfully'
+ subject: 'Finish setting up %{custom_domain}'
+ ssl_status:
+ custom_domain_not_configured: 'Custom domain is not configured'
diff --git a/config/routes.rb b/config/routes.rb
index c12aa670b..7fb348084 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -290,6 +290,8 @@ Rails.application.routes.draw do
member do
patch :archive
delete :logo
+ post :send_instructions
+ get :ssl_status
end
resources :categories
resources :articles do
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb
new file mode 100644
index 000000000..488f7e700
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb
@@ -0,0 +1,15 @@
+module Enterprise::Api::V1::Accounts::PortalsController
+ def ssl_status
+ return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
+
+ result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
+
+ return render_could_not_create_error(result[:errors]) if result[:errors].present?
+
+ ssl_settings = @portal.ssl_settings || {}
+ render json: {
+ status: ssl_settings['cf_status'],
+ verification_errors: ssl_settings['cf_verification_errors']
+ }
+ end
+end
diff --git a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
index 7fad50790..162f61915 100644
--- a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
+++ b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
@@ -17,4 +17,25 @@ class Cloudflare::BaseCloudflareZoneService
def zone_id
InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
end
+
+ def update_portal_ssl_settings(portal, data)
+ verification_record = data['ownership_verification_http']
+ ssl_record = data['ssl']
+ verification_errors = data['verification_errors']&.first || ''
+
+ # Start with existing settings to preserve verification data if it exists
+ ssl_settings = portal.ssl_settings || {}
+
+ # Only update verification fields if they exist in the response (during initial setup)
+ if verification_record.present?
+ ssl_settings['cf_verification_id'] = verification_record['http_url'].split('/').last
+ ssl_settings['cf_verification_body'] = verification_record['http_body']
+ end
+
+ # Always update SSL status and errors from current response
+ ssl_settings['cf_status'] = ssl_record&.dig('status')
+ ssl_settings['cf_verification_errors'] = verification_errors
+
+ portal.update(ssl_settings: ssl_settings)
+ end
end
diff --git a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
index 588a9c4a7..716623a18 100644
--- a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
+++ b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
@@ -14,21 +14,10 @@ class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneSer
data = response.parsed_response['result']
if data.present?
- update_portal_ssl_settings(data.first)
+ update_portal_ssl_settings(@portal, data.first)
return { data: data }
end
{ errors: ['Hostname is missing in Cloudflare'] }
end
-
- private
-
- def update_portal_ssl_settings(data)
- verification_record = data['ownership_verification_http']
- ssl_settings = {
- 'cf_verification_id': verification_record['http_url'].split('/').last,
- 'cf_verification_body': verification_record['http_body']
- }
- @portal.update(ssl_settings: ssl_settings)
- end
end
diff --git a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
index f1546caed..236b434e5 100644
--- a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
+++ b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
@@ -12,7 +12,7 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
data = response.parsed_response['result']
if data.present?
- update_portal_ssl_settings(data)
+ update_portal_ssl_settings(@portal, data)
return { data: data }
end
@@ -25,16 +25,13 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
HTTParty.post(
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
headers: headers,
- body: { hostname: @portal.custom_domain }.to_json
+ body: {
+ hostname: @portal.custom_domain,
+ ssl: {
+ method: 'http',
+ type: 'dv'
+ }
+ }.to_json
)
end
-
- def update_portal_ssl_settings(data)
- verification_record = data['ownership_verification_http']
- ssl_settings = {
- 'cf_verification_id': verification_record['http_url'].split('/').last,
- 'cf_verification_body': verification_record['http_body']
- }
- @portal.update(ssl_settings: ssl_settings)
- end
end
diff --git a/package.json b/package.json
index e8c120074..02dd757aa 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.1",
- "@chatwoot/utils": "^0.0.48",
+ "@chatwoot/utils": "^0.0.49",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f7b398b0..449176df4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: 1.2.1
version: 1.2.1
'@chatwoot/utils':
- specifier: ^0.0.48
- version: 0.0.48
+ specifier: ^0.0.49
+ version: 0.0.49
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -406,8 +406,8 @@ packages:
'@chatwoot/prosemirror-schema@1.2.1':
resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
- '@chatwoot/utils@0.0.48':
- resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==}
+ '@chatwoot/utils@0.0.49':
+ resolution: {integrity: sha512-Co68VzaFtctTNYKY6y4izBBATvk6/8ZVtkyEP5HL72uhFDA11LrY5pqSh04HMoFyfdIU+uVPimfI45HAeso1IA==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5255,7 +5255,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.48':
+ '@chatwoot/utils@0.0.49':
dependencies:
date-fns: 2.30.0
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index aeec9cab4..d0ea13e2b 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -210,4 +210,76 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/send_instructions' do
+ let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ params: { email: 'dev@example.com' }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated agent' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: agent.create_new_auth_token,
+ params: { email: 'dev@example.com' },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated admin' do
+ it 'returns error when email is missing' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: {},
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Email is required')
+ end
+
+ it 'returns error when email is invalid' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: { email: 'invalid-email' },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Invalid email format')
+ end
+
+ it 'returns error when custom domain is not configured' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: { email: 'dev@example.com' },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Custom domain is not configured')
+ end
+
+ it 'sends instructions successfully' do
+ mailer_double = instance_double(ActionMailer::MessageDelivery)
+ allow(PortalInstructionsMailer).to receive(:send_cname_instructions).and_return(mailer_double)
+ allow(mailer_double).to receive(:deliver_later)
+
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: { email: 'dev@example.com' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['message']).to eq('Instructions sent successfully')
+ expect(PortalInstructionsMailer).to have_received(:send_cname_instructions)
+ .with(portal: portal_with_domain, recipient_email: 'dev@example.com')
+ end
+ end
+ end
end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
index cb296494c..48e6c9e00 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
@@ -87,4 +87,73 @@ RSpec.describe 'Enterprise Portal API', type: :request do
end
end
end
+
+ describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/ssl_status' do
+ let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns error when custom domain is not configured' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Custom domain is not configured')
+ end
+
+ it 'returns SSL status when portal has ssl_settings' do
+ portal_with_domain.update(ssl_settings: {
+ 'cf_status' => 'active',
+ 'cf_verification_errors' => nil
+ })
+
+ mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
+ allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
+ allow(mock_service).to receive(:perform).and_return({ data: [] })
+
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['status']).to eq('active')
+ expect(response.parsed_body['verification_errors']).to be_nil
+ end
+
+ it 'returns null values when portal has no ssl_settings' do
+ mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
+ allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
+ allow(mock_service).to receive(:perform).and_return({ data: [] })
+
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['status']).to be_nil
+ expect(response.parsed_body['verification_errors']).to be_nil
+ end
+
+ it 'returns error when Cloudflare service returns errors' do
+ mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
+ allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
+ allow(mock_service).to receive(:perform).and_return({ errors: ['API token not found'] })
+
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq(['API token not found'])
+ end
+ end
+ end
end
diff --git a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
index d7ed80b90..b7e567465 100644
--- a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
+++ b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
@@ -96,8 +96,10 @@ RSpec.describe Cloudflare::CheckCustomHostnameService do
expect(portal).to receive(:update).with(
ssl_settings: {
- 'cf_verification_id': 'verification-id',
- 'cf_verification_body': 'verification-body'
+ 'cf_verification_id' => 'verification-id',
+ 'cf_verification_body' => 'verification-body',
+ 'cf_status' => nil,
+ 'cf_verification_errors' => ''
}
)
diff --git a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
index a3ddc8273..3f49c96dd 100644
--- a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
+++ b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
@@ -54,7 +54,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
- body: { hostname: 'test.example.com' }.to_json)
+ body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
@@ -72,7 +72,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
- body: { hostname: 'test.example.com' }.to_json)
+ body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
@@ -92,17 +92,22 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
}
}
}
+ expect(portal.ssl_settings).to eq({})
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
- body: { hostname: 'test.example.com' }.to_json)
+ body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
- expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
- 'cf_verification_body': 'verification-body' })
-
result = service.perform
-
+ expect(portal.ssl_settings).to eq(
+ {
+ 'cf_verification_id' => 'verification-id',
+ 'cf_verification_body' => 'verification-body',
+ 'cf_status' => nil,
+ 'cf_verification_errors' => ''
+ }
+ )
expect(result).to eq(data: success_response['result'])
end
end
diff --git a/spec/mailers/portal_instructions_mailer_spec.rb b/spec/mailers/portal_instructions_mailer_spec.rb
new file mode 100644
index 000000000..bfad55a08
--- /dev/null
+++ b/spec/mailers/portal_instructions_mailer_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PortalInstructionsMailer do
+ describe 'send_cname_instructions' do
+ let!(:account) { create(:account) }
+ let!(:portal) { create(:portal, account: account, custom_domain: 'help.example.com') }
+ let(:recipient_email) { 'admin@example.com' }
+ let(:class_instance) { described_class.new }
+
+ before do
+ allow(described_class).to receive(:new).and_return(class_instance)
+ allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
+ end
+
+ context 'when target domain is available' do
+ it 'sends email with cname instructions' do
+ with_modified_env HELPCENTER_URL: 'https://help.chatwoot.com' do
+ mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
+
+ expect(mail.to).to eq([recipient_email])
+ expect(mail.subject).to eq("Finish setting up #{portal.custom_domain}")
+ expect(mail.body.encoded).to include('help.example.com CNAME help.chatwoot.com')
+ end
+ end
+ end
+
+ context 'when helpcenter url is not available but frontend url is' do
+ it 'uses frontend url as target domain' do
+ with_modified_env HELPCENTER_URL: '', FRONTEND_URL: 'https://app.chatwoot.com' do
+ mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
+
+ expect(mail.to).to eq([recipient_email])
+ expect(mail.body.encoded).to include('help.example.com CNAME app.chatwoot.com')
+ end
+ end
+ end
+
+ context 'when no target domain is available' do
+ it 'does not send email' do
+ with_modified_env HELPCENTER_URL: '', FRONTEND_URL: '' do
+ mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
+
+ expect(mail).to be_nil
+ end
+ end
+ end
+ end
+end
From b434279422f946dfdd6ac3affd1f9ea91811893f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 30 Jul 2025 10:53:59 -0700
Subject: [PATCH 5/6] chore(deps): bump vue-i18n from 9.14.3 to 9.14.5 (#11960)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps
[vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n)
from 9.14.3 to 9.14.5.
Release notes
Sourced from vue-i18n's
releases.
v9.14.5
What's Changed
🔒 Security Fixes
Full Changelog: https://github.com/intlify/vue-i18n/compare/v9.14.4...v9.14.5
v9.14.4
What's Changed
🐛 Bug Fixes
Full Changelog: https://github.com/intlify/vue-i18n/compare/v9.14.3...v9.14.4
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package.json | 2 +-
pnpm-lock.yaml | 89 ++++++++++++++++++++++++++++++++++----------------
2 files changed, 61 insertions(+), 30 deletions(-)
diff --git a/package.json b/package.json
index 02dd757aa..2aefb3d53 100644
--- a/package.json
+++ b/package.json
@@ -93,7 +93,7 @@
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
- "vue-i18n": "9.14.3",
+ "vue-i18n": "9.14.5",
"vue-letter": "^0.2.1",
"vue-multiselect": "3.1.0",
"vue-router": "~4.4.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 449176df4..ca55fbe34 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,8 +200,8 @@ importers:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
vue-i18n:
- specifier: 9.14.3
- version: 9.14.3(vue@3.5.12(typescript@5.6.2))
+ specifier: 9.14.5
+ version: 9.14.5(vue@3.5.12(typescript@5.6.2))
vue-letter:
specifier: ^0.2.1
version: 0.2.1
@@ -906,8 +906,8 @@ packages:
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
engines: {node: '>= 16'}
- '@intlify/core-base@9.14.3':
- resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
+ '@intlify/core-base@9.14.5':
+ resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/eslint-plugin-vue-i18n@3.2.0':
@@ -920,16 +920,16 @@ packages:
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
engines: {node: '>= 16'}
- '@intlify/message-compiler@9.14.3':
- resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
+ '@intlify/message-compiler@9.14.5':
+ resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.2':
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
engines: {node: '>= 16'}
- '@intlify/shared@9.14.3':
- resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
+ '@intlify/shared@9.14.5':
+ resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@isaacs/cliui@8.0.2':
@@ -940,6 +940,9 @@ packages:
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
+ '@jridgewell/gen-mapping@0.3.12':
+ resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
+
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
@@ -952,19 +955,29 @@ packages:
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
engines: {node: '>=6.0.0'}
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
- '@jridgewell/source-map@0.3.6':
- resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
+ '@jridgewell/source-map@0.3.10':
+ resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+ '@jridgewell/sourcemap-codec@1.5.4':
+ resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
+
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@jridgewell/trace-mapping@0.3.29':
+ resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
+
'@june-so/analytics-next@2.0.0':
resolution: {integrity: sha512-7uFP94JLD7mP4qLyOwn5HBs+CC8VlevOkiGd1CIYqPSjSRmbCOI+MVcJNlTAcpyNvMi9iUnWZ3jGVO5177Di4A==}
@@ -1993,8 +2006,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
- acorn@8.14.1:
- resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -4960,8 +4973,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
- vue-i18n@9.14.3:
- resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
+ vue-i18n@9.14.5:
+ resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -5778,10 +5791,10 @@ snapshots:
'@intlify/message-compiler': 9.14.2
'@intlify/shared': 9.14.2
- '@intlify/core-base@9.14.3':
+ '@intlify/core-base@9.14.5':
dependencies:
- '@intlify/message-compiler': 9.14.3
- '@intlify/shared': 9.14.3
+ '@intlify/message-compiler': 9.14.5
+ '@intlify/shared': 9.14.5
'@intlify/eslint-plugin-vue-i18n@3.2.0(eslint@8.57.0)':
dependencies:
@@ -5813,14 +5826,14 @@ snapshots:
'@intlify/shared': 9.14.2
source-map-js: 1.2.1
- '@intlify/message-compiler@9.14.3':
+ '@intlify/message-compiler@9.14.5':
dependencies:
- '@intlify/shared': 9.14.3
+ '@intlify/shared': 9.14.5
source-map-js: 1.2.1
'@intlify/shared@9.14.2': {}
- '@intlify/shared@9.14.3': {}
+ '@intlify/shared@9.14.5': {}
'@isaacs/cliui@8.0.2':
dependencies:
@@ -5833,6 +5846,12 @@ snapshots:
'@istanbuljs/schema@0.1.3': {}
+ '@jridgewell/gen-mapping@0.3.12':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.4
+ '@jridgewell/trace-mapping': 0.3.29
+ optional: true
+
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -5847,21 +5866,33 @@ snapshots:
'@jridgewell/resolve-uri@3.1.1': {}
+ '@jridgewell/resolve-uri@3.1.2':
+ optional: true
+
'@jridgewell/set-array@1.2.1': {}
- '@jridgewell/source-map@0.3.6':
+ '@jridgewell/source-map@0.3.10':
dependencies:
- '@jridgewell/gen-mapping': 0.3.8
- '@jridgewell/trace-mapping': 0.3.25
+ '@jridgewell/gen-mapping': 0.3.12
+ '@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {}
+ '@jridgewell/sourcemap-codec@1.5.4':
+ optional: true
+
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping@0.3.29':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.4
+ optional: true
+
'@june-so/analytics-next@2.0.0':
dependencies:
'@lukeed/uuid': 2.0.0
@@ -7078,7 +7109,7 @@ snapshots:
acorn@8.14.0: {}
- acorn@8.14.1:
+ acorn@8.15.0:
optional: true
activestorage@5.2.8:
@@ -10124,8 +10155,8 @@ snapshots:
terser@5.33.0:
dependencies:
- '@jridgewell/source-map': 0.3.6
- acorn: 8.14.1
+ '@jridgewell/source-map': 0.3.10
+ acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -10479,10 +10510,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vue-i18n@9.14.3(vue@3.5.12(typescript@5.6.2)):
+ vue-i18n@9.14.5(vue@3.5.12(typescript@5.6.2)):
dependencies:
- '@intlify/core-base': 9.14.3
- '@intlify/shared': 9.14.3
+ '@intlify/core-base': 9.14.5
+ '@intlify/shared': 9.14.5
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.2)
From 86cb4fd80952df40c979f9e67d8064f6deda02f9 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Thu, 31 Jul 2025 02:19:27 +0530
Subject: [PATCH 6/6] chore: Improve layout styles (#12025)
# Pull Request Template
## Description
This PR fixes the layout overflow scroll issue and removes unused CSS.
It also optimizes the display of the Sidebar, Copilot Panel, and
Conversation Panel in the mobile view.
Additionally, it resolves a runtime console warning.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screencast
https://github.com/user-attachments/assets/7e8885fa-6174-4740-80f1-bb1cec6517fc
## 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
- [ ] 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: Muhsin Keloth
---
app/javascript/dashboard/App.vue | 2 +-
.../dashboard/assets/scss/_woot.scss | 24 -----
.../sidebar/MobileSidebarLauncher.vue | 58 ++++++++++++
.../components-next/sidebar/Sidebar.vue | 28 +++++-
.../components/copilot/CopilotContainer.vue | 30 +++++-
.../conversation/ConversationSidebar.vue | 28 +++++-
app/javascript/dashboard/constants/globals.js | 2 +-
.../dashboard/routes/dashboard/Dashboard.vue | 91 +++++++++----------
.../conversation/search/SwitchLayout.vue | 2 +-
.../helpcenter/components/UpgradePage.vue | 2 +-
.../pages/HelpCenterPageRouteView.vue | 2 +-
.../dashboard/settings/SettingsHeader.vue | 20 ----
.../routes/dashboard/settings/Wrapper.vue | 16 +---
.../dashboard/settings/inbox/inbox.routes.js | 2 -
.../dashboard/settings/teams/teams.routes.js | 2 -
.../routes/dashboard/upgrade/UpgradePage.vue | 72 ++++++++-------
16 files changed, 226 insertions(+), 155 deletions(-)
create mode 100644 app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 675f6ea67..0fbb20ea9 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -136,7 +136,7 @@ export default {
diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss
index 66416c64c..40ca7b436 100644
--- a/app/javascript/dashboard/assets/scss/_woot.scss
+++ b/app/javascript/dashboard/assets/scss/_woot.scss
@@ -37,30 +37,6 @@ body {
width: 100%;
}
-.app-wrapper {
- @apply h-screen flex-grow-0 min-h-0 w-full;
-
- .button--fixed-top {
- @apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
- }
-}
-
-.banner + .app-wrapper {
- // Reduce the height of the dashboard to make room for the banner.
- // And causing the top right green-action button to be pushed down when scrolling.
- @apply h-[calc(100%-48px)];
-
- .button--fixed-top {
- @apply top-14;
- }
-
- .off-canvas-content {
- .button--fixed-top {
- @apply top-2;
- }
- }
-}
-
.tooltip {
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
}
diff --git a/app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue b/app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue
new file mode 100644
index 000000000..a7265f840
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/MobileSidebarLauncher.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 12f15ce4b..dcd637055 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
+import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -17,10 +18,18 @@ import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
+const props = defineProps({
+ isMobileSidebarOpen: {
+ type: Boolean,
+ default: false,
+ },
+});
+
const emit = defineEmits([
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
+ 'closeMobileSidebar',
]);
const { accountScopedRoute } = useAccount();
@@ -77,6 +86,11 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
+const closeMobileSidebar = () => {
+ if (!props.isMobileSidebarOpen) return;
+ emit('closeMobileSidebar');
+};
+
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -488,7 +502,19 @@ const menuItems = computed(() => {