feat: add SAML settings UI

This commit is contained in:
Shivam Mishra
2025-09-01 12:10:58 +05:30
parent b81683a4de
commit 2dbd43217c
6 changed files with 288 additions and 1 deletions
@@ -488,6 +488,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
icon: 'i-lucide-shield',
to: accountScopedRoute('security_settings_index'),
},
{
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
@@ -350,7 +350,8 @@
"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."
},
"DOCS": "Read docs"
"DOCS": "Read docs",
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -382,6 +383,39 @@
},
"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.",
"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.",
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "X.509 Certificate",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"SP_ENTITY_ID": {
"LABEL": "Service Provider Entity ID",
"HELP": "Unique identifier for this application as a service provider. Leave blank to use default.",
"PLACEHOLDER": "my-unique-sp"
},
"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 and Certificate are required fields"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",
@@ -0,0 +1,23 @@
<script setup>
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import SamlSettings from './components/SamlSettings.vue';
</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')"
feature-name="security"
/>
</template>
<template #body>
<SamlSettings />
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,183 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
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 samlSettingsAPI from 'dashboard/api/samlSettings';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const ssoUrl = ref('');
const certificate = ref('');
const spEntityId = ref('');
const roleMappings = ref({});
const isEnabled = ref(false);
const isSubmitting = ref(false);
const isLoading = ref(true);
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
const loadSamlSettings = async () => {
if (!hasFeature.value) return;
try {
isLoading.value = true;
const response = await samlSettingsAPI.get();
const settings = response.data;
if (settings.sso_url) {
ssoUrl.value = settings.sso_url;
certificate.value = settings.certificate || '';
spEntityId.value = settings.sp_entity_id || '';
roleMappings.value = settings.role_mappings || {};
isEnabled.value = ssoUrl.value !== '';
}
} 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 && ssoUrl.value) {
// Create or update settings
const existingSettings = await samlSettingsAPI.get().catch(() => null);
if (existingSettings?.data?.id) {
await samlSettingsAPI.update(settings);
} else {
await samlSettingsAPI.create(settings);
}
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
} else {
// Disable/delete settings
await samlSettingsAPI.delete();
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
}
} catch (error) {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
throw error;
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
if (!ssoUrl.value || !certificate.value) {
useAlert(t('SECURITY_SETTINGS.SAML.VALIDATION.REQUIRED_FIELDS'));
return;
}
const settings = {
sso_url: ssoUrl.value,
certificate: certificate.value,
sp_entity_id: spEntityId.value,
role_mappings: roleMappings.value,
};
await saveSamlSettings(settings);
};
const handleDisable = async () => {
ssoUrl.value = '';
certificate.value = '';
spEntityId.value = '';
roleMappings.value = {};
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"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch
v-model="isEnabled"
:disabled="isLoading"
@change="toggleSaml"
/>
</div>
</template>
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
required
>
<TextInput
v-model="ssoUrl"
class="w-full"
type="url"
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
required
/>
</WithLabel>
<WithLabel
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
required
>
<TextArea
v-model="certificate"
class="w-full"
rows="8"
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
required
/>
</WithLabel>
<WithLabel
:label="t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.HELP')"
>
<TextInput
v-model="spEntityId"
class="w-full"
:placeholder="t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.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,39 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
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: 'BILLING_SETTINGS.TITLE',
icon: 'credit-card-person',
showNewButton: false,
},
children: [
{
path: '',
name: 'security_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
],
},
],
};
@@ -22,6 +22,7 @@ import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
export default {
routes: [
@@ -59,5 +60,6 @@ export default {
...teams.routes,
...customRoles.routes,
...profile.routes,
...security.routes,
],
};