Compare commits

...
Author SHA1 Message Date
Sojan JoseandClaude 602c56d1c5 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>
2025-08-03 18:41:41 -08:00
12 changed files with 404 additions and 5 deletions
@@ -47,6 +47,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.sso_config.merge!(sso_config_params) if sso_config_params.present? && sso_feature_enabled?
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -95,6 +96,14 @@ class Api::V1::AccountsController < Api::BaseController
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def sso_config_params
params.permit(sso_config: [:enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry])[:sso_config]
end
def sso_feature_enabled?
@account.feature_enabled?('sso')
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
+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>
+41 -1
View File
@@ -12,6 +12,7 @@
# locale :integer default("en")
# name :string not null
# settings :jsonb
# sso_config :jsonb not null
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
@@ -19,7 +20,8 @@
#
# Indexes
#
# index_accounts_on_status (status)
# index_accounts_on_sso_config (sso_config) USING gin
# index_accounts_on_status (status)
#
class Account < ApplicationRecord
@@ -55,6 +57,7 @@ class Account < ApplicationRecord
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :sso_config, :enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -158,6 +161,43 @@ class Account < ApplicationRecord
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
# SSO Configuration Methods
def sso_enabled?
ActiveModel::Type::Boolean.new.cast(sso_config['enabled'])
end
def sso_provider_name
sso_config['provider_name'].presence || 'SSO'
end
def sso_login_url
sso_config['login_url']
end
def sso_logout_url
sso_config['logout_url']
end
def sso_secret_key
sso_config['secret_key']
end
def sso_token_expiry
(sso_config['token_expiry'].presence || 5).to_i
end
def update_sso_config(config)
# Validate required fields if SSO is enabled
return false if ActiveModel::Type::Boolean.new.cast(config['enabled']) && (config['login_url'].blank? || config['secret_key'].blank?)
# Set default values
config['token_expiry'] = 5 if config['token_expiry'].blank?
config['provider_name'] = 'SSO' if config['provider_name'].blank?
self.sso_config = config
save
end
private
def notify_creation
+1 -1
View File
@@ -10,7 +10,7 @@
# enabled :boolean default(TRUE)
# message :text not null
# scheduled_at :datetime
# template_params :jsonb
# template_params :jsonb not null
# title :string not null
# trigger_only_during_business_hours :boolean default(FALSE)
# trigger_rules :jsonb
+20 -1
View File
@@ -2,8 +2,11 @@ module SsoAuthenticatable
extend ActiveSupport::Concern
def generate_sso_auth_token
return nil unless account&.sso_enabled?
token = SecureRandom.hex(32)
::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes)
expiry_minutes = account.sso_token_expiry.minutes
::Redis::Alfred.setex(sso_token_key(token), true, expiry_minutes)
token
end
@@ -16,14 +19,30 @@ module SsoAuthenticatable
end
def generate_sso_link
return nil unless account&.sso_enabled?
encoded_email = ERB::Util.url_encode(email)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
end
def generate_sso_link_with_impersonation
return nil unless account&.sso_enabled?
"#{generate_sso_link}&impersonation=true"
end
def sso_external_login_url
return nil unless account&.sso_enabled?
account.sso_login_url
end
def sso_external_logout_url
return nil unless account&.sso_enabled?
account.sso_logout_url
end
private
def sso_token_key(token)
@@ -1,4 +1,5 @@
json.settings resource.settings
json.sso_config resource.sso_config
json.created_at resource.created_at
if resource.custom_attributes.present?
json.custom_attributes do
@@ -0,0 +1,6 @@
class AddSsoConfigToAccounts < ActiveRecord::Migration[7.1]
def change
add_column :accounts, :sso_config, :jsonb, default: {}, null: false
add_index :accounts, :sso_config, using: :gin
end
end
+4 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -59,6 +59,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
t.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.jsonb "sso_config", default: {}, null: false
t.index ["sso_config"], name: "index_accounts_on_sso_config", using: :gin
t.index ["status"], name: "index_accounts_on_status"
end
@@ -237,7 +239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
t.jsonb "audience", default: []
t.datetime "scheduled_at", precision: nil
t.boolean "trigger_only_during_business_hours", default: false
t.jsonb "template_params"
t.jsonb "template_params", default: {}, null: false
t.index ["account_id"], name: "index_campaigns_on_account_id"
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"