mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: SAML UI [CW-2958] (#12345)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
		
							
								
								
									
										26
									
								
								app/javascript/dashboard/api/samlSettings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/javascript/dashboard/api/samlSettings.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | /* global axios */ | ||||||
|  | import ApiClient from './ApiClient'; | ||||||
|  |  | ||||||
|  | class SamlSettingsAPI extends ApiClient { | ||||||
|  |   constructor() { | ||||||
|  |     super('saml_settings', { accountScoped: true }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get() { | ||||||
|  |     return axios.get(this.url); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   create(data) { | ||||||
|  |     return axios.post(this.url, { saml_settings: data }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   update(data) { | ||||||
|  |     return axios.put(this.url, { saml_settings: data }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   delete() { | ||||||
|  |     return axios.delete(this.url); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default new SamlSettingsAPI(); | ||||||
| @@ -494,6 +494,12 @@ const menuItems = computed(() => { | |||||||
|           icon: 'i-lucide-clock-alert', |           icon: 'i-lucide-clock-alert', | ||||||
|           to: accountScopedRoute('sla_list'), |           to: accountScopedRoute('sla_list'), | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           name: 'Settings Security', | ||||||
|  |           label: t('SIDEBAR.SECURITY'), | ||||||
|  |           icon: 'i-lucide-shield', | ||||||
|  |           to: accountScopedRoute('security_settings_index'), | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|           name: 'Settings Billing', |           name: 'Settings Billing', | ||||||
|           label: t('SIDEBAR.BILLING'), |           label: t('SIDEBAR.BILLING'), | ||||||
|   | |||||||
| @@ -40,6 +40,7 @@ export const FEATURE_FLAGS = { | |||||||
|   CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', |   CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', | ||||||
|   WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup', |   WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup', | ||||||
|   CAPTAIN_V2: 'captain_integration_v2', |   CAPTAIN_V2: 'captain_integration_v2', | ||||||
|  |   SAML: 'saml', | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const PREMIUM_FEATURES = [ | export const PREMIUM_FEATURES = [ | ||||||
| @@ -49,4 +50,5 @@ export const PREMIUM_FEATURES = [ | |||||||
|   FEATURE_FLAGS.AUDIT_LOGS, |   FEATURE_FLAGS.AUDIT_LOGS, | ||||||
|   FEATURE_FLAGS.HELP_CENTER, |   FEATURE_FLAGS.HELP_CENTER, | ||||||
|   FEATURE_FLAGS.CAPTAIN_V2, |   FEATURE_FLAGS.CAPTAIN_V2, | ||||||
|  |   FEATURE_FLAGS.SAML, | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = { | |||||||
|   team_management: 'https://chwt.app/hc/teams', |   team_management: 'https://chwt.app/hc/teams', | ||||||
|   webhook: 'https://chwt.app/hc/webhooks', |   webhook: 'https://chwt.app/hc/webhooks', | ||||||
|   billing: 'https://chwt.app/pricing', |   billing: 'https://chwt.app/pricing', | ||||||
|  |   saml: 'https://chwt.app/hc/saml', | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export function getHelpUrlForFeature(featureName) { | export function getHelpUrlForFeature(featureName) { | ||||||
|   | |||||||
| @@ -358,7 +358,8 @@ | |||||||
|       "INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.", |       "INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.", | ||||||
|       "INFO_SHORT": "Automatically mark offline when you aren't using the app." |       "INFO_SHORT": "Automatically mark offline when you aren't using the app." | ||||||
|     }, |     }, | ||||||
|     "DOCS": "Read docs" |     "DOCS": "Read docs", | ||||||
|  |     "SECURITY": "Security" | ||||||
|   }, |   }, | ||||||
|   "BILLING_SETTINGS": { |   "BILLING_SETTINGS": { | ||||||
|     "TITLE": "Billing", |     "TITLE": "Billing", | ||||||
| @@ -390,6 +391,77 @@ | |||||||
|     }, |     }, | ||||||
|     "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again." |     "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again." | ||||||
|   }, |   }, | ||||||
|  |   "SECURITY_SETTINGS": { | ||||||
|  |     "TITLE": "Security", | ||||||
|  |     "DESCRIPTION": "Manage your account security settings.", | ||||||
|  |     "LINK_TEXT": "Learn more about SAML SSO", | ||||||
|  |     "SAML": { | ||||||
|  |       "TITLE": "SAML SSO", | ||||||
|  |       "NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.", | ||||||
|  |       "ACS_URL": { | ||||||
|  |         "LABEL": "ACS URL", | ||||||
|  |         "TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses" | ||||||
|  |       }, | ||||||
|  |       "SSO_URL": { | ||||||
|  |         "LABEL": "SSO URL", | ||||||
|  |         "HELP": "The URL where SAML authentication requests will be sent", | ||||||
|  |         "PLACEHOLDER": "https://your-idp.com/saml/sso" | ||||||
|  |       }, | ||||||
|  |       "CERTIFICATE": { | ||||||
|  |         "LABEL": "Signing certificate in PEM format", | ||||||
|  |         "HELP": "The public certificate from your identity provider used to verify SAML responses", | ||||||
|  |         "PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..." | ||||||
|  |       }, | ||||||
|  |       "FINGERPRINT": { | ||||||
|  |         "LABEL": "Fingerprint", | ||||||
|  |         "TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration" | ||||||
|  |       }, | ||||||
|  |       "COPY_SUCCESS": "Copied to clipboard", | ||||||
|  |       "SP_ENTITY_ID": { | ||||||
|  |         "LABEL": "SP Entity ID", | ||||||
|  |         "HELP": "Unique identifier for this application as a service provider (auto-generated).", | ||||||
|  |         "TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings" | ||||||
|  |       }, | ||||||
|  |       "IDP_ENTITY_ID": { | ||||||
|  |         "LABEL": "Identity Provider Entity ID", | ||||||
|  |         "HELP": "Unique identifier for your identity provider (usually found in IdP configuration)", | ||||||
|  |         "PLACEHOLDER": "https://your-idp.com/saml" | ||||||
|  |       }, | ||||||
|  |       "UPDATE_BUTTON": "Update SAML Settings", | ||||||
|  |       "API": { | ||||||
|  |         "SUCCESS": "SAML settings updated successfully", | ||||||
|  |         "ERROR": "Failed to update SAML settings", | ||||||
|  |         "ERROR_LOADING": "Failed to load SAML settings", | ||||||
|  |         "DISABLED": "SAML settings disabled successfully" | ||||||
|  |       }, | ||||||
|  |       "VALIDATION": { | ||||||
|  |         "REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields", | ||||||
|  |         "SSO_URL_ERROR": "Please enter a valid SSO URL", | ||||||
|  |         "CERTIFICATE_ERROR": "Certificate is required", | ||||||
|  |         "IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required" | ||||||
|  |       }, | ||||||
|  |       "ENTERPRISE_PAYWALL": { | ||||||
|  |         "AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.", | ||||||
|  |         "UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.", | ||||||
|  |         "ASK_ADMIN": "Please reach out to your administrator for the upgrade." | ||||||
|  |       }, | ||||||
|  |       "PAYWALL": { | ||||||
|  |         "TITLE": "Upgrade to enable SAML SSO", | ||||||
|  |         "AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.", | ||||||
|  |         "UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.", | ||||||
|  |         "UPGRADE_NOW": "Upgrade now", | ||||||
|  |         "CANCEL_ANYTIME": "You can change or cancel your plan anytime" | ||||||
|  |       }, | ||||||
|  |       "ATTRIBUTE_MAPPING": { | ||||||
|  |         "TITLE": "SAML Attribute Setup", | ||||||
|  |         "DESCRIPTION": "The following attribute mappings must be configured in your identity provider" | ||||||
|  |       }, | ||||||
|  |       "INFO_SECTION": { | ||||||
|  |         "TITLE": "Service Provider Information", | ||||||
|  |         "TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   "CREATE_ACCOUNT": { |   "CREATE_ACCOUNT": { | ||||||
|     "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", |     "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", | ||||||
|     "NEW_ACCOUNT": "New Account", |     "NEW_ACCOUNT": "New Account", | ||||||
|   | |||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; | ||||||
|  | import SettingsLayout from '../SettingsLayout.vue'; | ||||||
|  | import SamlSettings from './components/SamlSettings.vue'; | ||||||
|  | import SamlPaywall from './components/SamlPaywall.vue'; | ||||||
|  |  | ||||||
|  | import { usePolicy } from 'dashboard/composables/usePolicy'; | ||||||
|  | import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; | ||||||
|  | import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | ||||||
|  | const { shouldShow, shouldShowPaywall } = usePolicy(); | ||||||
|  |  | ||||||
|  | const shouldShowSaml = computed(() => | ||||||
|  |   shouldShow( | ||||||
|  |     FEATURE_FLAGS.SAML, | ||||||
|  |     ['administrator'], | ||||||
|  |     [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE] | ||||||
|  |   ) | ||||||
|  | ); | ||||||
|  | const showPaywall = computed(() => shouldShowPaywall('saml')); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <SettingsLayout | ||||||
|  |     class="max-w-2xl mx-auto" | ||||||
|  |     :loading-message="$t('ATTRIBUTES_MGMT.LOADING')" | ||||||
|  |   > | ||||||
|  |     <template #header> | ||||||
|  |       <BaseSettingsHeader | ||||||
|  |         :title="$t('SECURITY_SETTINGS.TITLE')" | ||||||
|  |         :description="$t('SECURITY_SETTINGS.DESCRIPTION')" | ||||||
|  |         :link-text="$t('SECURITY_SETTINGS.LINK_TEXT')" | ||||||
|  |         feature-name="saml" | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |     <template #body> | ||||||
|  |       <SamlPaywall v-if="showPaywall" /> | ||||||
|  |       <SamlSettings v-else-if="shouldShowSaml" /> | ||||||
|  |     </template> | ||||||
|  |   </SettingsLayout> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,50 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import Icon from 'next/icon/Icon.vue'; | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const isExpanded = ref(false); | ||||||
|  |  | ||||||
|  | const toggleExpanded = () => { | ||||||
|  |   isExpanded.value = !isExpanded.value; | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <section | ||||||
|  |     class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 mb-5 overflow-hidden" | ||||||
|  |   > | ||||||
|  |     <button | ||||||
|  |       type="button" | ||||||
|  |       class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-n-solid-2 transition-colors" | ||||||
|  |       @click="toggleExpanded" | ||||||
|  |     > | ||||||
|  |       <h4 class="font-medium text-n-slate-12"> | ||||||
|  |         {{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }} | ||||||
|  |       </h4> | ||||||
|  |       <Icon | ||||||
|  |         icon="i-lucide-chevron-down" | ||||||
|  |         class="transition-transform duration-200" | ||||||
|  |         :class="{ 'rotate-180': isExpanded }" | ||||||
|  |       /> | ||||||
|  |     </button> | ||||||
|  |  | ||||||
|  |     <div | ||||||
|  |       class="transition-[height] duration-200 ease-in-out overflow-hidden" | ||||||
|  |       :class="isExpanded ? 'h-auto' : 'h-0'" | ||||||
|  |     > | ||||||
|  |       <div class="px-4 pb-3"> | ||||||
|  |         <p class="text-n-slate-11 mb-2"> | ||||||
|  |           {{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }} | ||||||
|  |         </p> | ||||||
|  |         <!-- eslint-disable vue/no-bare-strings-in-template --> | ||||||
|  |         <ul class="list-none text-n-slate-12 space-y-1"> | ||||||
|  |           <li><code class="px-1 rounded bg-n-slate-3">email</code></li> | ||||||
|  |           <li><code class="px-1 rounded bg-n-slate-3">first_name</code></li> | ||||||
|  |           <li><code class="px-1 rounded bg-n-slate-3">last_name</code></li> | ||||||
|  |         </ul> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </section> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,102 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { copyTextToClipboard } from 'shared/helpers/clipboard'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { useAccount } from 'dashboard/composables/useAccount'; | ||||||
|  | import NextButton from 'next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   fingerprint: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   spEntityId: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const { accountId } = useAccount(); | ||||||
|  |  | ||||||
|  | const acsUrl = computed(() => { | ||||||
|  |   const currentHost = window.location.origin; | ||||||
|  |   return `${currentHost}/omniauth/saml/callback?account_id=${accountId.value}`; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const allInfoItems = computed(() => [ | ||||||
|  |   { | ||||||
|  |     key: 'ACS_URL', | ||||||
|  |     label: t('SECURITY_SETTINGS.SAML.ACS_URL.LABEL'), | ||||||
|  |     value: acsUrl.value, | ||||||
|  |     tooltip: t('SECURITY_SETTINGS.SAML.ACS_URL.TOOLTIP'), | ||||||
|  |     show: true, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     key: 'SP_ENTITY_ID', | ||||||
|  |     label: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL'), | ||||||
|  |     value: props.spEntityId, | ||||||
|  |     tooltip: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.TOOLTIP'), | ||||||
|  |     show: !!props.spEntityId, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     key: 'FINGERPRINT', | ||||||
|  |     label: t('SECURITY_SETTINGS.SAML.FINGERPRINT.LABEL'), | ||||||
|  |     value: props.fingerprint, | ||||||
|  |     tooltip: t('SECURITY_SETTINGS.SAML.FINGERPRINT.TOOLTIP'), | ||||||
|  |     show: !!props.fingerprint, | ||||||
|  |   }, | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const visibleInfoItems = computed(() => | ||||||
|  |   allInfoItems.value.filter(item => item.show) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const handleCopy = async text => { | ||||||
|  |   await copyTextToClipboard(text); | ||||||
|  |   useAlert(t('SECURITY_SETTINGS.SAML.COPY_SUCCESS')); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="space-y-4"> | ||||||
|  |     <div class="flex items-center gap-2"> | ||||||
|  |       <h3 class="text-sm font-medium text-n-slate-12"> | ||||||
|  |         {{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }} | ||||||
|  |       </h3> | ||||||
|  |       <i | ||||||
|  |         v-tooltip.top="t('SECURITY_SETTINGS.SAML.INFO_SECTION.TOOLTIP')" | ||||||
|  |         class="i-lucide-info text-n-slate-10 w-4 h-4 cursor-help" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <section | ||||||
|  |       class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak" | ||||||
|  |     > | ||||||
|  |       <div | ||||||
|  |         v-for="item in visibleInfoItems" | ||||||
|  |         :key="item.key" | ||||||
|  |         class="ps-4 pe-1 py-1 flex justify-between items-center" | ||||||
|  |       > | ||||||
|  |         <div class="flex items-center gap-2"> | ||||||
|  |           <span class="text-n-slate-11 w-32 flex items-center gap-1"> | ||||||
|  |             {{ item.label }} | ||||||
|  |             <i | ||||||
|  |               v-tooltip.top="item.tooltip" | ||||||
|  |               class="i-lucide-info text-n-slate-9 w-3 h-3 cursor-help" | ||||||
|  |             /> | ||||||
|  |           </span> | ||||||
|  |           <span class="flex-1">{{ item.value }}</span> | ||||||
|  |         </div> | ||||||
|  |         <NextButton | ||||||
|  |           type="button" | ||||||
|  |           ghost | ||||||
|  |           sm | ||||||
|  |           slate | ||||||
|  |           icon="i-lucide-copy" | ||||||
|  |           @click="handleCopy(item.value)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </section> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { useRouter } from 'vue-router'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store'; | ||||||
|  | import { useAccount } from 'dashboard/composables/useAccount'; | ||||||
|  |  | ||||||
|  | import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue'; | ||||||
|  |  | ||||||
|  | const router = useRouter(); | ||||||
|  | const currentUser = useMapGetter('getCurrentUser'); | ||||||
|  |  | ||||||
|  | const isSuperAdmin = computed(() => { | ||||||
|  |   return currentUser.value.type === 'SuperAdmin'; | ||||||
|  | }); | ||||||
|  | const { accountId, isOnChatwootCloud } = useAccount(); | ||||||
|  |  | ||||||
|  | const i18nKey = computed(() => | ||||||
|  |   isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL' | ||||||
|  | ); | ||||||
|  | const openBilling = () => { | ||||||
|  |   router.push({ | ||||||
|  |     name: 'billing_settings_index', | ||||||
|  |     params: { accountId: accountId.value }, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="w-full max-w-[60rem] mx-auto h-full max-h-[28rem] grid place-content-center" | ||||||
|  |   > | ||||||
|  |     <BasePaywallModal | ||||||
|  |       class="mx-auto" | ||||||
|  |       feature-prefix="SECURITY_SETTINGS.SAML" | ||||||
|  |       :i18n-key="i18nKey" | ||||||
|  |       :is-super-admin="isSuperAdmin" | ||||||
|  |       :is-on-chatwoot-cloud="isOnChatwootCloud" | ||||||
|  |       @upgrade="openBilling" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,251 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed, onMounted, reactive } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { required } from '@vuelidate/validators'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { useAccount } from 'dashboard/composables/useAccount'; | ||||||
|  | import samlSettingsAPI from 'dashboard/api/samlSettings'; | ||||||
|  |  | ||||||
|  | import SectionLayout from '../../account/components/SectionLayout.vue'; | ||||||
|  | import WithLabel from 'v3/components/Form/WithLabel.vue'; | ||||||
|  | import TextInput from 'next/input/Input.vue'; | ||||||
|  | import TextArea from 'next/textarea/TextArea.vue'; | ||||||
|  | import Switch from 'next/switch/Switch.vue'; | ||||||
|  | import NextButton from 'next/button/Button.vue'; | ||||||
|  | import SamlInfoSection from './SamlInfoSection.vue'; | ||||||
|  | import SamlAttributeMap from './SamlAttributeMap.vue'; | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const { isCloudFeatureEnabled } = useAccount(); | ||||||
|  |  | ||||||
|  | const id = ref(null); | ||||||
|  | const fingerprint = ref(''); | ||||||
|  | const spEntityId = ref(''); | ||||||
|  | const isEnabled = ref(false); | ||||||
|  | const isSubmitting = ref(false); | ||||||
|  | const isLoading = ref(true); | ||||||
|  |  | ||||||
|  | const formState = reactive({ | ||||||
|  |   ssoUrl: '', | ||||||
|  |   certificate: '', | ||||||
|  |   idpEntityId: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const validations = { | ||||||
|  |   ssoUrl: { required }, | ||||||
|  |   certificate: { required }, | ||||||
|  |   idpEntityId: { required }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(validations, formState); | ||||||
|  |  | ||||||
|  | const hasFeature = computed(() => isCloudFeatureEnabled('saml')); | ||||||
|  |  | ||||||
|  | const ssoUrlError = computed(() => | ||||||
|  |   v$.value.ssoUrl.$error | ||||||
|  |     ? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR') | ||||||
|  |     : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const certificateError = computed(() => | ||||||
|  |   v$.value.certificate.$error | ||||||
|  |     ? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR') | ||||||
|  |     : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const idpEntityIdError = computed(() => | ||||||
|  |   v$.value.idpEntityId.$error | ||||||
|  |     ? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR') | ||||||
|  |     : '' | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const loadSamlSettings = async () => { | ||||||
|  |   if (!hasFeature.value) return; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     isLoading.value = true; | ||||||
|  |     const response = await samlSettingsAPI.get(); | ||||||
|  |     const settings = response.data; | ||||||
|  |  | ||||||
|  |     if (settings.sso_url) { | ||||||
|  |       id.value = settings.id; | ||||||
|  |       formState.ssoUrl = settings.sso_url; | ||||||
|  |       formState.certificate = settings.certificate || ''; | ||||||
|  |       spEntityId.value = settings.sp_entity_id || ''; | ||||||
|  |       formState.idpEntityId = settings.idp_entity_id || ''; | ||||||
|  |       fingerprint.value = settings.fingerprint || ''; | ||||||
|  |       isEnabled.value = formState.ssoUrl !== ''; | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     // If no settings exist (404), that's expected - just keep defaults | ||||||
|  |     if (error.response?.status !== 404) { | ||||||
|  |       useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING')); | ||||||
|  |     } | ||||||
|  |   } finally { | ||||||
|  |     isLoading.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const saveSamlSettings = async settings => { | ||||||
|  |   try { | ||||||
|  |     isSubmitting.value = true; | ||||||
|  |  | ||||||
|  |     if (isEnabled.value && formState.ssoUrl) { | ||||||
|  |       // Create or update settings based on existing id | ||||||
|  |       let response; | ||||||
|  |       if (id.value) { | ||||||
|  |         response = await samlSettingsAPI.update(settings); | ||||||
|  |       } else { | ||||||
|  |         response = await samlSettingsAPI.create(settings); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Update local state with response data including fingerprint and id | ||||||
|  |       if (response?.data) { | ||||||
|  |         id.value = response.data.id; | ||||||
|  |         fingerprint.value = response.data.fingerprint || ''; | ||||||
|  |         spEntityId.value = response.data.sp_entity_id || ''; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS')); | ||||||
|  |     } else { | ||||||
|  |       // Disable/delete settings | ||||||
|  |       await samlSettingsAPI.delete(); | ||||||
|  |       useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED')); | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     // Handle backend validation errors | ||||||
|  |     if (error.response?.data?.errors) { | ||||||
|  |       const errorMessages = error.response.data.errors; | ||||||
|  |       const firstError = Array.isArray(errorMessages) | ||||||
|  |         ? errorMessages[0] | ||||||
|  |         : errorMessages; | ||||||
|  |       useAlert(firstError); | ||||||
|  |     } else { | ||||||
|  |       useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR')); | ||||||
|  |     } | ||||||
|  |     throw error; | ||||||
|  |   } finally { | ||||||
|  |     isSubmitting.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSubmit = async () => { | ||||||
|  |   v$.value.$touch(); | ||||||
|  |   if (v$.value.$invalid) return; | ||||||
|  |  | ||||||
|  |   const settings = { | ||||||
|  |     sso_url: formState.ssoUrl, | ||||||
|  |     certificate: formState.certificate, | ||||||
|  |     idp_entity_id: formState.idpEntityId, | ||||||
|  |     role_mappings: {}, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   await saveSamlSettings(settings); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDisable = async () => { | ||||||
|  |   id.value = null; | ||||||
|  |   formState.ssoUrl = ''; | ||||||
|  |   formState.certificate = ''; | ||||||
|  |   spEntityId.value = ''; | ||||||
|  |   formState.idpEntityId = ''; | ||||||
|  |   fingerprint.value = ''; | ||||||
|  |  | ||||||
|  |   // the empty save will delete the SAML settings item | ||||||
|  |   await saveSamlSettings({}); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const toggleSaml = async () => { | ||||||
|  |   if (!isEnabled.value) { | ||||||
|  |     await handleDisable(); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   loadSamlSettings(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <SectionLayout | ||||||
|  |     :title="t('SECURITY_SETTINGS.SAML.TITLE')" | ||||||
|  |     :description="t('SECURITY_SETTINGS.SAML.NOTE')" | ||||||
|  |     :hide-content="!hasFeature || !isEnabled || isLoading" | ||||||
|  |   > | ||||||
|  |     <template #headerActions> | ||||||
|  |       <div class="flex justify-end"> | ||||||
|  |         <Switch | ||||||
|  |           v-model="isEnabled" | ||||||
|  |           :disabled="isLoading" | ||||||
|  |           @change="toggleSaml" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </template> | ||||||
|  |  | ||||||
|  |     <SamlInfoSection | ||||||
|  |       class="mb-5" | ||||||
|  |       :fingerprint="fingerprint" | ||||||
|  |       :sp-entity-id="spEntityId" | ||||||
|  |     /> | ||||||
|  |     <SamlAttributeMap class="mb-5" /> | ||||||
|  |  | ||||||
|  |     <form class="grid gap-5" @submit.prevent="handleSubmit"> | ||||||
|  |       <WithLabel | ||||||
|  |         name="ssoUrl" | ||||||
|  |         :label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')" | ||||||
|  |         :help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')" | ||||||
|  |         :has-error="v$.ssoUrl.$error" | ||||||
|  |         :error-message="ssoUrlError" | ||||||
|  |         required | ||||||
|  |       > | ||||||
|  |         <TextInput | ||||||
|  |           v-model="formState.ssoUrl" | ||||||
|  |           class="w-full" | ||||||
|  |           type="url" | ||||||
|  |           :placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')" | ||||||
|  |         /> | ||||||
|  |       </WithLabel> | ||||||
|  |  | ||||||
|  |       <WithLabel | ||||||
|  |         name="idpEntityId" | ||||||
|  |         :label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')" | ||||||
|  |         :help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')" | ||||||
|  |         :has-error="v$.idpEntityId.$error" | ||||||
|  |         :error-message="idpEntityIdError" | ||||||
|  |         required | ||||||
|  |       > | ||||||
|  |         <TextInput | ||||||
|  |           v-model="formState.idpEntityId" | ||||||
|  |           class="w-full" | ||||||
|  |           :placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')" | ||||||
|  |         /> | ||||||
|  |       </WithLabel> | ||||||
|  |  | ||||||
|  |       <WithLabel | ||||||
|  |         name="certificate" | ||||||
|  |         :label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')" | ||||||
|  |         :help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')" | ||||||
|  |         :has-error="v$.certificate.$error" | ||||||
|  |         :error-message="certificateError" | ||||||
|  |         required | ||||||
|  |       > | ||||||
|  |         <TextArea | ||||||
|  |           v-model="formState.certificate" | ||||||
|  |           class="w-full" | ||||||
|  |           rows="8" | ||||||
|  |           :placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')" | ||||||
|  |         /> | ||||||
|  |       </WithLabel> | ||||||
|  |  | ||||||
|  |       <div class="flex gap-2"> | ||||||
|  |         <NextButton | ||||||
|  |           blue | ||||||
|  |           type="submit" | ||||||
|  |           :is-loading="isSubmitting" | ||||||
|  |           :label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </form> | ||||||
|  |   </SectionLayout> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | import { frontendURL } from '../../../../helper/URLHelper'; | ||||||
|  | import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; | ||||||
|  | import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | ||||||
|  | import SettingsWrapper from '../SettingsWrapper.vue'; | ||||||
|  | import Index from './Index.vue'; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   routes: [ | ||||||
|  |     { | ||||||
|  |       path: frontendURL('accounts/:accountId/settings/security'), | ||||||
|  |       meta: { | ||||||
|  |         permissions: ['administrator'], | ||||||
|  |         installationTypes: [ | ||||||
|  |           INSTALLATION_TYPES.CLOUD, | ||||||
|  |           INSTALLATION_TYPES.ENTERPRISE, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |       component: SettingsWrapper, | ||||||
|  |       props: { | ||||||
|  |         headerTitle: 'SECURITY_SETTINGS.TITLE', | ||||||
|  |         icon: 'i-lucide-shield', | ||||||
|  |         showNewButton: false, | ||||||
|  |       }, | ||||||
|  |       children: [ | ||||||
|  |         { | ||||||
|  |           path: '', | ||||||
|  |           name: 'security_settings_index', | ||||||
|  |           component: Index, | ||||||
|  |           meta: { | ||||||
|  |             permissions: ['administrator'], | ||||||
|  |             featureFlag: FEATURE_FLAGS.SAML, | ||||||
|  |             installationTypes: [ | ||||||
|  |               INSTALLATION_TYPES.CLOUD, | ||||||
|  |               INSTALLATION_TYPES.ENTERPRISE, | ||||||
|  |             ], | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  | }; | ||||||
| @@ -23,6 +23,7 @@ import sla from './sla/sla.routes'; | |||||||
| import teams from './teams/teams.routes'; | import teams from './teams/teams.routes'; | ||||||
| import customRoles from './customRoles/customRole.routes'; | import customRoles from './customRoles/customRole.routes'; | ||||||
| import profile from './profile/profile.routes'; | import profile from './profile/profile.routes'; | ||||||
|  | import security from './security/security.routes'; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   routes: [ |   routes: [ | ||||||
| @@ -61,5 +62,6 @@ export default { | |||||||
|     ...teams.routes, |     ...teams.routes, | ||||||
|     ...customRoles.routes, |     ...customRoles.routes, | ||||||
|     ...profile.routes, |     ...profile.routes, | ||||||
|  |     ...security.routes, | ||||||
|   ], |   ], | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -103,6 +103,8 @@ en: | |||||||
|       invalid_value: Invalid value. The values provided for %{attribute_name} are invalid |       invalid_value: Invalid value. The values provided for %{attribute_name} are invalid | ||||||
|     custom_attribute_definition: |     custom_attribute_definition: | ||||||
|       key_conflict: The provided key is not allowed as it might conflict with default attributes. |       key_conflict: The provided key is not allowed as it might conflict with default attributes. | ||||||
|  |     account_saml_settings: | ||||||
|  |       invalid_certificate: must be a valid X.509 certificate in PEM format | ||||||
|   reports: |   reports: | ||||||
|     period: Reporting period %{since} to %{until} |     period: Reporting period %{since} to %{until} | ||||||
|     utc_warning: The report generated is in UTC timezone |     utc_warning: The report generated is in UTC timezone | ||||||
|   | |||||||
| @@ -7,11 +7,19 @@ class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseControl | |||||||
|  |  | ||||||
|   def create |   def create | ||||||
|     @saml_settings = Current.account.build_saml_settings(saml_settings_params) |     @saml_settings = Current.account.build_saml_settings(saml_settings_params) | ||||||
|     @saml_settings.save! |     if @saml_settings.save | ||||||
|  |       render :show | ||||||
|  |     else | ||||||
|  |       render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity | ||||||
|  |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def update |   def update | ||||||
|     @saml_settings.update!(saml_settings_params) |     if @saml_settings.update(saml_settings_params) | ||||||
|  |       render :show | ||||||
|  |     else | ||||||
|  |       render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity | ||||||
|  |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def destroy |   def destroy | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ module Enterprise::DeviseOverrides::PasswordsController | |||||||
|     if saml_user_attempting_password_auth?(params[:email]) |     if saml_user_attempting_password_auth?(params[:email]) | ||||||
|       render json: { |       render json: { | ||||||
|         success: false, |         success: false, | ||||||
|  |         message: I18n.t('messages.reset_password_saml_user'), | ||||||
|         errors: [I18n.t('messages.reset_password_saml_user')] |         errors: [I18n.t('messages.reset_password_saml_user')] | ||||||
|       }, status: :forbidden |       }, status: :forbidden | ||||||
|       return |       return | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ module Enterprise::DeviseOverrides::SessionsController | |||||||
|     if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token]) |     if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token]) | ||||||
|       render json: { |       render json: { | ||||||
|         success: false, |         success: false, | ||||||
|  |         message: I18n.t('messages.login_saml_user'), | ||||||
|         errors: [I18n.t('messages.login_saml_user')] |         errors: [I18n.t('messages.login_saml_user')] | ||||||
|       }, status: :unauthorized |       }, status: :unauthorized | ||||||
|       return |       return | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ class AccountSamlSettings < ApplicationRecord | |||||||
|   validates :sso_url, presence: true |   validates :sso_url, presence: true | ||||||
|   validates :certificate, presence: true |   validates :certificate, presence: true | ||||||
|   validates :idp_entity_id, presence: true |   validates :idp_entity_id, presence: true | ||||||
|  |   validate :certificate_must_be_valid_x509 | ||||||
|  |  | ||||||
|   before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation? |   before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation? | ||||||
|  |  | ||||||
| @@ -56,4 +57,12 @@ class AccountSamlSettings < ApplicationRecord | |||||||
|   def installation_name |   def installation_name | ||||||
|     GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot') |     GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot') | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def certificate_must_be_valid_x509 | ||||||
|  |     return if certificate.blank? | ||||||
|  |  | ||||||
|  |     OpenSSL::X509::Certificate.new(certificate) | ||||||
|  |   rescue OpenSSL::X509::CertificateError | ||||||
|  |     errors.add(:certificate, I18n.t('errors.account_saml_settings.invalid_certificate')) | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra