diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index bf3a7f221..974fb05e4 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -12,9 +12,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController return handle_mfa_verification if mfa_verification_request? return handle_sso_authentication if sso_authentication_request? - super do |resource| - return handle_mfa_required(resource) if resource&.mfa_enabled? - end + user = find_user_for_authentication + return handle_mfa_required(user) if user&.mfa_enabled? + + # Only proceed with standard authentication if no MFA is required + super end def render_create_success @@ -23,6 +25,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def find_user_for_authentication + return nil unless params[:email].present? && params[:password].present? + + normalized_email = params[:email].strip.downcase + user = User.from_email(normalized_email) + return nil unless user&.valid_password?(params[:password]) + return nil unless user.active_for_authentication? + + user + end + def mfa_verification_request? params[:mfa_token].present? end @@ -59,10 +72,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) end - def handle_mfa_required(resource) + def handle_mfa_required(user) render json: { mfa_required: true, - mfa_token: Mfa::TokenService.new(user: resource).generate_token + mfa_token: Mfa::TokenService.new(user: user).generate_token }, status: :partial_content end diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index ff242030a..e98e61c95 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController redirect_to new_super_admin_user_path, notice: notice end end - # - # def update - # super - # send_foo_updated_email(requested_resource) - # end + + def update + requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present? + super + end # Override this method to specify custom lookup behavior. # This will be used to set the resource for the `show`, `edit`, and `update` diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 8abdefd1a..753b617ef 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard SHOW_PAGE_ATTRIBUTES = %i[ id avatar_url - unconfirmed_email name type display_name email + unconfirmed_email created_at updated_at confirmed_at diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index a7db8480f..21e8dd1b2 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -555,10 +555,12 @@ "WROTE": "wrote", "YOU": "You", "SAVE": "Save note", + "ADD_NOTE": "Add contact note", "EXPAND": "Expand", "COLLAPSE": "Collapse", "NO_NOTES": "No notes, you can add notes from the contact details page.", - "EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above." + "EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.", + "CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one." } }, "EMPTY_STATE": { 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/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue index 66b6876b1..c828c60ef 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue @@ -1,21 +1,34 @@ diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js index 1b3844b08..917781bbf 100644 --- a/app/javascript/dashboard/store/modules/conversationStats.js +++ b/app/javascript/dashboard/store/modules/conversationStats.js @@ -29,9 +29,9 @@ const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1000); const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000); const superLongDebouncedFetchMetaData = debounce( fetchMetaData, - 2000, + 1500, false, - 5000 + 10000 ); export const actions = { 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 @@ + + + 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/enterprise/app/jobs/captain/documents/response_builder_job.rb b/enterprise/app/jobs/captain/documents/response_builder_job.rb index 5dacb416f..b22fa5bc1 100644 --- a/enterprise/app/jobs/captain/documents/response_builder_job.rb +++ b/enterprise/app/jobs/captain/documents/response_builder_job.rb @@ -33,7 +33,8 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob Captain::Llm::PaginatedFaqGeneratorService.new( document, pages_per_chunk: options[:pages_per_chunk], - max_pages: options[:max_pages] + max_pages: options[:max_pages], + language: document.account.locale_english_name ) end diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb index 18f9813ef..2d326f081 100644 --- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb @@ -8,6 +8,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService def initialize(document, options = {}) super() @document = document + @language = options[:language] || 'english' @pages_per_chunk = options[:pages_per_chunk] || DEFAULT_PAGES_PER_CHUNK @max_pages = options[:max_pages] # Optional limit from UI @total_pages_processed = 0 @@ -118,7 +119,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService end def page_chunk_prompt(start_page, end_page) - Captain::Llm::SystemPromptsService.paginated_faq_generator(start_page, end_page) + Captain::Llm::SystemPromptsService.paginated_faq_generator(start_page, end_page, @language) end def standard_chat_parameters @@ -128,7 +129,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService messages: [ { role: 'system', - content: Captain::Llm::SystemPromptsService.faq_generator + content: Captain::Llm::SystemPromptsService.faq_generator(@language) }, { role: 'user', diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index b8282beb1..ba8834c1f 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -206,7 +206,7 @@ class Captain::Llm::SystemPromptsService SYSTEM_PROMPT_MESSAGE end - def paginated_faq_generator(start_page, end_page) + def paginated_faq_generator(start_page, end_page, language = 'english') <<~PROMPT You are an expert technical documentation specialist tasked with creating comprehensive FAQs from a SPECIFIC SECTION of a document. @@ -226,6 +226,8 @@ class Captain::Llm::SystemPromptsService FAQ GENERATION GUIDELINES ════════════════════════════════════════════════════════ + **Language**: Generate the FAQs only in #{language}, use no other language + 1. **Comprehensive Extraction** • Extract ALL information that could generate FAQs from this section • Target 5-10 FAQs per page equivalent of rich content diff --git a/lib/tasks/mfa.rake b/lib/tasks/mfa.rake new file mode 100644 index 000000000..df2e8c425 --- /dev/null +++ b/lib/tasks/mfa.rake @@ -0,0 +1,65 @@ +module MfaTasks + def self.find_user_or_exit(email) + abort 'Error: Please provide an email address' if email.blank? + user = User.from_email(email) + abort "Error: User with email '#{email}' not found" unless user + user + end + + def self.reset_user_mfa(user) + user.update!( + otp_required_for_login: false, + otp_secret: nil, + otp_backup_codes: nil + ) + end + + def self.reset_single(args) + user = find_user_or_exit(args[:email]) + abort "MFA is already disabled for #{args[:email]}" if !user.otp_required_for_login? && user.otp_secret.nil? + reset_user_mfa(user) + puts "✓ MFA has been successfully reset for #{args[:email]}" + rescue StandardError => e + abort "Error resetting MFA: #{e.message}" + end + + def self.reset_all + print 'Are you sure you want to reset MFA for ALL users? This cannot be undone! (yes/no): ' + abort 'Operation cancelled' unless $stdin.gets.chomp.downcase == 'yes' + + affected_users = User.where(otp_required_for_login: true).or(User.where.not(otp_secret: nil)) + count = affected_users.count + abort 'No users have MFA enabled' if count.zero? + + puts "\nResetting MFA for #{count} user(s)..." + affected_users.find_each { |user| reset_user_mfa(user) } + puts "✓ MFA has been reset for #{count} user(s)" + end + + def self.generate_backup_codes(args) + user = find_user_or_exit(args[:email]) + abort "Error: MFA is not enabled for #{args[:email]}" unless user.otp_required_for_login? + + service = Mfa::ManagementService.new(user: user) + codes = service.generate_backup_codes! + puts "\nNew backup codes generated for #{args[:email]}:" + codes.each { |code| puts code } + end +end + +namespace :mfa do + desc 'Reset MFA for a specific user by email' + task :reset, [:email] => :environment do |_task, args| + MfaTasks.reset_single(args) + end + + desc 'Reset MFA for all users in the system' + task reset_all: :environment do + MfaTasks.reset_all + end + + desc 'Generate new backup codes for a user' + task :generate_backup_codes, [:email] => :environment do |_task, args| + MfaTasks.generate_backup_codes(args) + end +end diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb index 31f308c8e..8ee012670 100644 --- a/spec/controllers/devise_overrides/sessions_controller_spec.rb +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -40,6 +40,26 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do expect(json_response['mfa_token']).to be_present end + it 'does not return authentication tokens before MFA verification' do + post :create, params: { email: user.email, password: 'Test@123456' } + + expect(response).to have_http_status(:partial_content) + + # Check that no authentication headers are present + expect(response.headers['access-token']).to be_nil + expect(response.headers['uid']).to be_nil + expect(response.headers['client']).to be_nil + expect(response.headers['Authorization']).to be_nil + + # Check that no bearer token is present in any form + response.headers.each do |key, value| + expect(value.to_s).not_to include('Bearer') if key.downcase.include?('auth') + end + + json_response = response.parsed_body + expect(json_response['data']).to be_nil + end + context 'when verifying MFA' do let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token } diff --git a/spec/controllers/super_admin/users_controller_spec.rb b/spec/controllers/super_admin/users_controller_spec.rb index 12f1b69dc..894c9d425 100644 --- a/spec/controllers/super_admin/users_controller_spec.rb +++ b/spec/controllers/super_admin/users_controller_spec.rb @@ -66,4 +66,38 @@ RSpec.describe 'Super Admin Users API', type: :request do end end end + + describe 'PATCH /super_admin/users/:id' do + let!(:user) { create(:user) } + let(:request_path) { "/super_admin/users/#{user.id}" } + + before { sign_in(super_admin, scope: :super_admin) } + + it 'skips reconfirmation when confirmed_at is provided' do + ActiveJob::Base.queue_adapter.enqueued_jobs.clear + patch request_path, params: { user: { email: 'updated@example.com', confirmed_at: Time.current } } + + expect(response).to have_http_status(:redirect) + expect(user.reload.email).to eq('updated@example.com') + expect(user.reload.unconfirmed_email).to be_nil + + mail_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job| + job[:job].to_s == 'ActionMailer::MailDeliveryJob' + end + expect(mail_jobs.count).to eq(0) + end + + it 'does not skip reconfirmation when confirmed_at is blank' do + ActiveJob::Base.queue_adapter.enqueued_jobs.clear + patch request_path, params: { user: { email: 'updated-again@example.com' } } + + expect(response).to have_http_status(:redirect) + expect(user.reload.unconfirmed_email).to eq('updated-again@example.com') + + mail_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job| + job[:job].to_s == 'ActionMailer::MailDeliveryJob' + end + expect(mail_jobs.count).to be >= 1 + end + 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