mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +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