diff --git a/app/javascript/dashboard/i18n/locale/en/login.json b/app/javascript/dashboard/i18n/locale/en/login.json
index ec5658db2..864c76359 100644
--- a/app/javascript/dashboard/i18n/locale/en/login.json
+++ b/app/javascript/dashboard/i18n/locale/en/login.json
@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
- "SUBMIT": "Login"
+ "SUBMIT": "Login",
+ "SAML": {
+ "LABEL": "Log in via SSO",
+ "TITLE": "Initiate Single Sign-on (SSO)",
+ "SUBTITLE": "Enter your work email to access your organization",
+ "BACK_TO_LOGIN": "Login via Password",
+ "WORK_EMAIL": {
+ "LABEL": "Work Email",
+ "PLACEHOLDER": "Enter your work email"
+ },
+ "SUBMIT": "Continue with SSO",
+ "API": {
+ "ERROR_MESSAGE": "SSO authentication failed"
+ }
+ }
}
}
diff --git a/app/javascript/shared/store/globalConfig.js b/app/javascript/shared/store/globalConfig.js
index 608a31ec1..8abeba123 100644
--- a/app/javascript/shared/store/globalConfig.js
+++ b/app/javascript/shared/store/globalConfig.js
@@ -1,3 +1,5 @@
+import { parseBoolean } from '@chatwoot/utils';
+
const {
API_CHANNEL_NAME: apiChannelName,
API_CHANNEL_THUMBNAIL: apiChannelThumbnail,
@@ -15,6 +17,7 @@ const {
LOGO: logo,
LOGO_DARK: logoDark,
PRIVACY_URL: privacyURL,
+ IS_ENTERPRISE: isEnterprise,
TERMS_URL: termsURL,
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
@@ -30,8 +33,8 @@ const state = {
chatwootInboxToken,
deploymentEnv,
createNewAccountFromDashboard,
- directUploadsEnabled: directUploadsEnabled === 'true',
- disableUserProfileUpdate: disableUserProfileUpdate === 'true',
+ directUploadsEnabled: parseBoolean(directUploadsEnabled),
+ disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate),
displayManifest,
gitSha,
hCaptchaSiteKey,
@@ -42,6 +45,7 @@ const state = {
privacyURL,
termsURL,
widgetBrandURL,
+ isEnterprise: parseBoolean(isEnterprise),
};
export const getters = {
diff --git a/app/javascript/v3/components/Form/Input.vue b/app/javascript/v3/components/Form/Input.vue
index 674b1a59a..4a0b0dc63 100644
--- a/app/javascript/v3/components/Form/Input.vue
+++ b/app/javascript/v3/components/Form/Input.vue
@@ -55,6 +55,7 @@ const model = defineModel({
+
+
+ {{ $t('LOGIN.SAML.LABEL') }}
+
+
diff --git a/app/javascript/v3/views/login/Saml.vue b/app/javascript/v3/views/login/Saml.vue
new file mode 100644
index 000000000..fc4d75494
--- /dev/null
+++ b/app/javascript/v3/views/login/Saml.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+ {{ t('LOGIN.SAML.TITLE') }}
+
+
+
+
+
+ {{ t('LOGIN.SAML.BACK_TO_LOGIN') }}
+
+
+
+
diff --git a/app/javascript/v3/views/routes.js b/app/javascript/v3/views/routes.js
index e1f14c78e..6b975e09b 100644
--- a/app/javascript/v3/views/routes.js
+++ b/app/javascript/v3/views/routes.js
@@ -1,6 +1,7 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import Login from './login/Index.vue';
+import SamlLogin from './login/Saml.vue';
import Signup from './auth/signup/Index.vue';
import ResetPassword from './auth/reset/password/Index.vue';
import Confirmation from './auth/confirmation/Index.vue';
@@ -20,6 +21,12 @@ export default [
authError: route.query.error,
}),
},
+ {
+ path: frontendURL('login/sso'),
+ name: 'sso_login',
+ component: SamlLogin,
+ meta: { requireEnterprise: true },
+ },
{
path: frontendURL('auth/signup'),
name: 'auth_signup',
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0d869c75f..ca3a9e950 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -36,6 +36,10 @@ en:
success: 'Channel reauthorized successfully'
not_required: 'Reauthorization is not required for this inbox'
invalid_channel: 'Invalid channel type for reauthorization'
+ auth:
+ saml:
+ invalid_email: 'Please enter a valid email address'
+ authentication_failed: 'Authentication failed. Please check your credentials and try again.'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
diff --git a/config/routes.rb b/config/routes.rb
index 244bd67bf..6a484b380 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -325,6 +325,9 @@ Rails.application.routes.draw do
resources :webhooks, only: [:create]
end
+ # Frontend API endpoint to trigger SAML authentication flow
+ post 'auth/saml_login', to: 'auth#saml_login'
+
resource :profile, only: [:show, :update] do
delete :avatar, on: :collection
member do
diff --git a/enterprise/app/controllers/api/v1/auth_controller.rb b/enterprise/app/controllers/api/v1/auth_controller.rb
new file mode 100644
index 000000000..a8eb7ad9d
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/auth_controller.rb
@@ -0,0 +1,49 @@
+class Api::V1::AuthController < Api::BaseController
+ skip_before_action :authenticate_user!, only: [:saml_login]
+ before_action :find_user_and_account, only: [:saml_login]
+
+ def saml_login
+ return if @account.nil?
+
+ saml_initiation_url = "/auth/saml?account_id=#{@account.id}"
+ redirect_to saml_initiation_url, status: :temporary_redirect
+ end
+
+ private
+
+ def find_user_and_account
+ return unless validate_email_presence
+
+ find_saml_enabled_account
+ end
+
+ def validate_email_presence
+ @email = params[:email]&.downcase&.strip
+ return true if @email.present?
+
+ render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request
+ false
+ end
+
+ def find_saml_enabled_account
+ user = User.from_email(@email)
+ return render_saml_error unless user
+
+ account_user = find_account_with_saml(user)
+ return render_saml_error unless account_user
+
+ @account = account_user.account
+ end
+
+ def find_account_with_saml(user)
+ user.account_users
+ .joins(account: :saml_settings)
+ .where.not(saml_settings: { sso_url: [nil, ''] })
+ .where.not(saml_settings: { certificate: [nil, ''] })
+ .find { |account_user| account_user.account.feature_enabled?('saml') }
+ end
+
+ def render_saml_error
+ render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
new file mode 100644
index 000000000..30367ab17
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Auth', type: :request do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, email: 'user@example.com') }
+
+ before do
+ account.enable_features('saml')
+ account.save!
+ end
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'POST /api/v1/auth/saml_login' do
+ context 'when email is blank' do
+ it 'returns bad request' do
+ post '/api/v1/auth/saml_login', params: { email: '' }
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ context 'when email is nil' do
+ it 'returns bad request' do
+ post '/api/v1/auth/saml_login', params: {}
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+
+ context 'when user does not exist' do
+ it 'returns unauthorized with generic message' do
+ post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user exists but has no SAML enabled accounts' do
+ before do
+ create(:account_user, user: user, account: account)
+ end
+
+ it 'returns unauthorized' do
+ post '/api/v1/auth/saml_login', params: { email: user.email }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user has account without SAML feature enabled' do
+ let(:saml_settings) { create(:account_saml_settings, account: account) }
+
+ before do
+ saml_settings
+ create(:account_user, user: user, account: account)
+ account.disable_features('saml')
+ account.save!
+ end
+
+ it 'returns unauthorized' do
+ post '/api/v1/auth/saml_login', params: { email: user.email }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user has valid SAML configuration' do
+ let(:saml_settings) do
+ create(:account_saml_settings, account: account)
+ end
+
+ before do
+ saml_settings
+ create(:account_user, user: user, account: account)
+ end
+
+ it 'redirects to SAML initiation URL' do
+ post '/api/v1/auth/saml_login', params: { email: user.email }
+
+ expect(response).to have_http_status(:temporary_redirect)
+ expect(response.location).to include("/auth/saml?account_id=#{account.id}")
+ end
+
+ it 'handles email case insensitivity' do
+ post '/api/v1/auth/saml_login', params: { email: user.email.upcase }
+
+ expect(response).to have_http_status(:temporary_redirect)
+ expect(response.location).to include("/auth/saml?account_id=#{account.id}")
+ end
+
+ it 'strips whitespace from email' do
+ post '/api/v1/auth/saml_login', params: { email: " #{user.email} " }
+
+ expect(response).to have_http_status(:temporary_redirect)
+ expect(response.location).to include("/auth/saml?account_id=#{account.id}")
+ end
+ end
+
+ context 'when user has multiple accounts with SAML' do
+ let(:account2) { create(:account) }
+ let(:saml_settings1) do
+ create(:account_saml_settings, account: account)
+ end
+ let(:saml_settings2) do
+ create(:account_saml_settings, account: account2)
+ end
+
+ before do
+ account2.enable_features('saml')
+ account2.save!
+ saml_settings1
+ saml_settings2
+ create(:account_user, user: user, account: account)
+ create(:account_user, user: user, account: account2)
+ end
+
+ it 'redirects to the first SAML enabled account' do
+ post '/api/v1/auth/saml_login', params: { email: user.email }
+
+ expect(response).to have_http_status(:temporary_redirect)
+ returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i
+ expect([account.id, account2.id]).to include(returned_account_id)
+ end
+ end
+ end
+end