feat: Add account-level SSO configuration

This implements Single Sign-On (SSO) functionality at the account level, allowing individual accounts to configure their own SSO settings.

Key changes:
- Added SSO configuration to account model with JSONB storage
- Implemented SSO settings UI in account settings panel
- Added feature flag to control SSO access (premium feature)
- Updated authentication flow to support account-level SSO
- Added proper validation and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sojan Jose
2025-08-03 18:41:41 -08:00
co-authored by Claude
parent 647a65d481
commit 602c56d1c5
12 changed files with 404 additions and 5 deletions
+2
View File
@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
SSO: 'sso',
};
export const PREMIUM_FEATURES = [
@@ -48,4 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SSO,
];
@@ -123,6 +123,50 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
}
},
"SSO": {
"TITLE": "Single Sign-On",
"DESCRIPTION": "Configure SSO authentication for your organization",
"FORM": {
"ENABLE_SSO": {
"LABEL": "Enable SSO",
"HELP": "Allow users to log in using Single Sign-On"
},
"PROVIDER_NAME": {
"LABEL": "Provider Name",
"PLACEHOLDER": "e.g., 'Okta', 'Azure AD', 'Google Workspace'",
"ERROR": "Please enter a provider name"
},
"LOGIN_URL": {
"LABEL": "SSO Login URL",
"PLACEHOLDER": "https://your-sso-provider.com/login",
"ERROR": "Please enter a valid SSO login URL",
"HELP": "Users will be redirected to this URL for authentication"
},
"LOGOUT_URL": {
"LABEL": "SSO Logout URL (Optional)",
"PLACEHOLDER": "https://your-sso-provider.com/logout",
"HELP": "Users will be redirected to this URL after logout"
},
"SECRET_KEY": {
"LABEL": "Secret Key",
"PLACEHOLDER": "Enter your SSO secret key",
"ERROR": "Please enter a secret key",
"HELP": "Secret key used to verify SSO tokens"
},
"TOKEN_EXPIRY": {
"LABEL": "Token Expiry (minutes)",
"PLACEHOLDER": "5",
"ERROR": "Please enter a valid expiry time",
"HELP": "How long SSO tokens remain valid"
},
"ERROR": "Please fix the form errors"
},
"SUBMIT": "Update SSO Settings",
"UPDATE": {
"SUCCESS": "SSO settings updated successfully",
"ERROR": "Failed to update SSO settings"
}
},
"UPDATE_CHATWOOT": "An update {latestChatwootVersion} for Chatwoot is available. Please update your instance.",
"LEARN_MORE": "Learn more",
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
@@ -20,6 +20,9 @@
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
},
"SSO": {
"BUTTON_TEXT": "Login with {provider}"
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
@@ -17,6 +17,7 @@ import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SSOConfiguration from './components/SSOConfiguration.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
@@ -28,6 +29,7 @@ export default {
AccountDelete,
AutoResolve,
AudioTranscription,
SSOConfiguration,
SectionLayout,
WithLabel,
NextInput,
@@ -77,6 +79,9 @@ export default {
FEATURE_FLAGS.CAPTAIN
);
},
showSSOConfig() {
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SSO);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
@@ -244,6 +249,7 @@ export default {
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<SSOConfiguration v-if="showSSOConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />
@@ -0,0 +1,267 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, url } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SectionLayout from './SectionLayout.vue';
export default {
components: {
WithLabel,
NextInput,
NextButton,
SectionLayout,
},
setup() {
const { accountId } = useAccount();
const v$ = useVuelidate();
return { accountId, v$ };
},
data() {
return {
ssoConfig: {
enabled: false,
provider_name: 'SSO',
login_url: '',
logout_url: '',
secret_key: '',
token_expiry: 5,
},
showSecretKey: false,
isUpdating: false,
};
},
validations() {
return {
ssoConfig: {
provider_name: {
required,
},
login_url: {
required: this.ssoConfig.enabled ? required : true,
url: this.ssoConfig.enabled ? url : true,
},
secret_key: {
required: this.ssoConfig.enabled ? required : true,
},
token_expiry: {
required,
},
},
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
}),
currentAccount() {
return this.getAccount(this.accountId) || {};
},
},
mounted() {
this.initializeSSO();
},
methods: {
initializeSSO() {
try {
const { sso_config } = this.currentAccount;
if (sso_config) {
this.ssoConfig = {
enabled: sso_config.enabled || false,
provider_name: sso_config.provider_name || 'SSO',
login_url: sso_config.login_url || '',
logout_url: sso_config.logout_url || '',
secret_key: sso_config.secret_key || '',
token_expiry: sso_config.token_expiry || 5,
};
}
} catch (error) {
// Ignore error
}
},
async updateSSO() {
this.v$.$touch();
if (this.v$.$invalid) {
useAlert(this.$t('GENERAL_SETTINGS.SSO.FORM.ERROR'));
return;
}
this.isUpdating = true;
try {
await this.$store.dispatch('accounts/update', {
sso_config: this.ssoConfig,
});
useAlert(this.$t('GENERAL_SETTINGS.SSO.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.SSO.UPDATE.ERROR'));
} finally {
this.isUpdating = false;
}
},
toggleSecretKey() {
this.showSecretKey = !this.showSecretKey;
},
},
};
</script>
<template>
<SectionLayout
:title="$t('GENERAL_SETTINGS.SSO.TITLE')"
:description="$t('GENERAL_SETTINGS.SSO.DESCRIPTION')"
>
<form class="grid gap-4" @submit.prevent="updateSSO">
<WithLabel :label="$t('GENERAL_SETTINGS.SSO.FORM.ENABLE_SSO.LABEL')">
<div class="flex items-center">
<input v-model="ssoConfig.enabled" type="checkbox" class="mr-2" />
<span>{{ $t('GENERAL_SETTINGS.SSO.FORM.ENABLE_SSO.HELP') }}</span>
</div>
</WithLabel>
<template v-if="ssoConfig.enabled">
<WithLabel
:has-error="v$.ssoConfig.provider_name.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.ERROR')"
>
<NextInput
v-model="ssoConfig.provider_name"
type="text"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.PLACEHOLDER')
"
@blur="v$.ssoConfig.provider_name.$touch"
/>
</WithLabel>
<WithLabel
:has-error="v$.ssoConfig.login_url.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.ERROR')"
>
<NextInput
v-model="ssoConfig.login_url"
type="url"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.PLACEHOLDER')"
@blur="v$.ssoConfig.login_url.$touch"
/>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.HELP') }}
</template>
</WithLabel>
<WithLabel :label="$t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.LABEL')">
<NextInput
v-model="ssoConfig.logout_url"
type="url"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.PLACEHOLDER')
"
/>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.HELP') }}
</template>
</WithLabel>
<WithLabel
:has-error="v$.ssoConfig.secret_key.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.ERROR')"
>
<div class="relative">
<NextInput
v-model="ssoConfig.secret_key"
:type="showSecretKey ? 'text' : 'password'"
class="w-full pr-10"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.PLACEHOLDER')
"
@blur="v$.ssoConfig.secret_key.$touch"
/>
<button
type="button"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
@click="toggleSecretKey"
>
<svg
v-if="showSecretKey"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"
/>
</svg>
<svg
v-else
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</button>
</div>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.HELP') }}
</template>
</WithLabel>
<WithLabel
:has-error="v$.ssoConfig.token_expiry.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.ERROR')"
>
<NextInput
v-model="ssoConfig.token_expiry"
type="number"
class="w-full"
min="1"
max="60"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.PLACEHOLDER')
"
@blur="v$.ssoConfig.token_expiry.$touch"
/>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.HELP') }}
</template>
</WithLabel>
</template>
<div>
<NextButton blue :is-loading="isUpdating" type="submit">
{{ $t('GENERAL_SETTINGS.SSO.SUBMIT') }}
</NextButton>
</div>
</form>
</SectionLayout>
</template>