diff --git a/app/javascript/dashboard/api/samlSettings.js b/app/javascript/dashboard/api/samlSettings.js new file mode 100644 index 000000000..7c0f5b266 --- /dev/null +++ b/app/javascript/dashboard/api/samlSettings.js @@ -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(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 27736c3a2..ab6537031 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -494,6 +494,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'), diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 143094ae5..9d7997648 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -40,6 +40,7 @@ export const FEATURE_FLAGS = { CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup', CAPTAIN_V2: 'captain_integration_v2', + SAML: 'saml', }; export const PREMIUM_FEATURES = [ @@ -49,4 +50,5 @@ export const PREMIUM_FEATURES = [ FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.CAPTAIN_V2, + FEATURE_FLAGS.SAML, ]; diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js index ee61b0656..910a6bed6 100644 --- a/app/javascript/dashboard/helper/featureHelper.js +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = { team_management: 'https://chwt.app/hc/teams', webhook: 'https://chwt.app/hc/webhooks', billing: 'https://chwt.app/pricing', + saml: 'https://chwt.app/hc/saml', }; export function getHelpUrlForFeature(featureName) { diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index c95eada84..b81a47f4e 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -358,7 +358,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", @@ -390,6 +391,77 @@ }, "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": { "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", "NEW_ACCOUNT": "New Account", diff --git a/app/javascript/dashboard/routes/dashboard/settings/security/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/security/Index.vue new file mode 100644 index 000000000..0ac35c9e2 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/security/Index.vue @@ -0,0 +1,41 @@ + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlAttributeMap.vue b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlAttributeMap.vue new file mode 100644 index 000000000..442b426df --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlAttributeMap.vue @@ -0,0 +1,50 @@ + + + + + + + {{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }} + + + + + + + + {{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }} + + + + email + first_name + last_name + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlInfoSection.vue b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlInfoSection.vue new file mode 100644 index 000000000..66820cafa --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlInfoSection.vue @@ -0,0 +1,102 @@ + + + + + + + {{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }} + + + + + + + + {{ item.label }} + + + {{ item.value }} + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlPaywall.vue b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlPaywall.vue new file mode 100644 index 000000000..a08cce9e0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlPaywall.vue @@ -0,0 +1,41 @@ + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlSettings.vue new file mode 100644 index 000000000..dca12200b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/security/components/SamlSettings.vue @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/security/security.routes.js b/app/javascript/dashboard/routes/dashboard/settings/security/security.routes.js new file mode 100644 index 000000000..f9058d6bc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/security/security.routes.js @@ -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, + ], + }, + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index 967c6cd55..22173f66d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -23,6 +23,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: [ @@ -61,5 +62,6 @@ export default { ...teams.routes, ...customRoles.routes, ...profile.routes, + ...security.routes, ], }; diff --git a/config/locales/en.yml b/config/locales/en.yml index e958c63b9..d5345a411 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -103,6 +103,8 @@ en: invalid_value: Invalid value. The values provided for %{attribute_name} are invalid custom_attribute_definition: 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: period: Reporting period %{since} to %{until} utc_warning: The report generated is in UTC timezone diff --git a/enterprise/app/controllers/api/v1/accounts/saml_settings_controller.rb b/enterprise/app/controllers/api/v1/accounts/saml_settings_controller.rb index efeb61a2f..9f00138cb 100644 --- a/enterprise/app/controllers/api/v1/accounts/saml_settings_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/saml_settings_controller.rb @@ -7,11 +7,19 @@ class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseControl def create @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 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 def destroy diff --git a/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb index bd4daee2a..3a20e0d71 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb @@ -5,6 +5,7 @@ module Enterprise::DeviseOverrides::PasswordsController if saml_user_attempting_password_auth?(params[:email]) render json: { success: false, + message: I18n.t('messages.reset_password_saml_user'), errors: [I18n.t('messages.reset_password_saml_user')] }, status: :forbidden return diff --git a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb index ea456bdd1..adfc0413e 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb @@ -5,6 +5,7 @@ module Enterprise::DeviseOverrides::SessionsController if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token]) render json: { success: false, + message: I18n.t('messages.login_saml_user'), errors: [I18n.t('messages.login_saml_user')] }, status: :unauthorized return diff --git a/enterprise/app/models/account_saml_settings.rb b/enterprise/app/models/account_saml_settings.rb index 25e8aeffe..b2f7bfe7b 100644 --- a/enterprise/app/models/account_saml_settings.rb +++ b/enterprise/app/models/account_saml_settings.rb @@ -23,6 +23,7 @@ class AccountSamlSettings < ApplicationRecord validates :sso_url, presence: true validates :certificate, 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? @@ -56,4 +57,12 @@ class AccountSamlSettings < ApplicationRecord def installation_name GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot') 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
+ {{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }} +
email
first_name
last_name