From 602c56d1c52ea86df63b1763ae05e1002f0ece0e Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Sun, 3 Aug 2025 18:41:41 -0800 Subject: [PATCH] feat: Add account-level SSO configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/controllers/api/v1/accounts_controller.rb | 9 + app/javascript/dashboard/featureFlags.js | 2 + .../i18n/locale/en/generalSettings.json | 44 +++ .../dashboard/i18n/locale/en/login.json | 3 + .../dashboard/settings/account/Index.vue | 6 + .../account/components/SSOConfiguration.vue | 267 ++++++++++++++++++ app/models/account.rb | 42 ++- app/models/campaign.rb | 2 +- app/models/concerns/sso_authenticatable.rb | 21 +- .../api/v1/models/_account.json.jbuilder | 1 + ...250718083735_add_sso_config_to_accounts.rb | 6 + db/schema.rb | 6 +- 12 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/account/components/SSOConfiguration.vue create mode 100644 db/migrate/20250718083735_add_sso_config_to_accounts.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 773126755..963b7dfb9 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 28b6b09b7..674d5d219 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -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, ]; diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index d924bffbd..024bbdda7 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -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", diff --git a/app/javascript/dashboard/i18n/locale/en/login.json b/app/javascript/dashboard/i18n/locale/en/login.json index ec5658db2..2d19da2fb 100644 --- a/app/javascript/dashboard/i18n/locale/en/login.json +++ b/app/javascript/dashboard/i18n/locale/en/login.json @@ -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" diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 69342858d..267ca0ad0 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -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 { +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/SSOConfiguration.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/SSOConfiguration.vue new file mode 100644 index 000000000..3499f8ef4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/SSOConfiguration.vue @@ -0,0 +1,267 @@ + + + diff --git a/app/models/account.rb b/app/models/account.rb index f8eb998f0..daa1d00b6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/campaign.rb b/app/models/campaign.rb index 2927181c6..0f297762a 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -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 diff --git a/app/models/concerns/sso_authenticatable.rb b/app/models/concerns/sso_authenticatable.rb index 98b50f336..53122245b 100644 --- a/app/models/concerns/sso_authenticatable.rb +++ b/app/models/concerns/sso_authenticatable.rb @@ -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) diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index efeff7db5..eeebba981 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -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 diff --git a/db/migrate/20250718083735_add_sso_config_to_accounts.rb b/db/migrate/20250718083735_add_sso_config_to_accounts.rb new file mode 100644 index 000000000..b5d449090 --- /dev/null +++ b/db/migrate/20250718083735_add_sso_config_to_accounts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 34637315b..c8b289a1f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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"