diff --git a/Gemfile.lock b/Gemfile.lock index 141afc122..8d6132849 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -582,7 +582,7 @@ GEM uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.24) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 686ffaeec..655b3c890 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle def category_params params.require(:category).permit( - :name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id + :name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id ) end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2856c7817..2e53fa7c9 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def destroy authorize @conversation, :destroy? - ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip) + ::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform head :ok end diff --git a/app/controllers/api/v1/profile/sessions_controller.rb b/app/controllers/api/v1/profile/sessions_controller.rb new file mode 100644 index 000000000..72e9451eb --- /dev/null +++ b/app/controllers/api/v1/profile/sessions_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Profile::SessionsController < Api::BaseController + before_action :set_session, only: [:destroy] + + def index + @sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc) + @current_client_id = request.headers['client'] + end + + def destroy + if @session.current?(request.headers['client']) + render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity + return + end + + revoke_token!(@session.client_id) + @session.destroy! + head :ok + end + + private + + def set_session + @session = current_user.user_sessions.find(params[:id]) + end + + def revoke_token!(client_id) + tokens = current_user.tokens + tokens.delete(client_id) + current_user.update!(tokens: tokens) + end + + def active_token_client_ids + now = Time.current.to_i + (current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2f389049d..9dea4b4da 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include RequestExceptionHandler include Pundit::Authorization include SwitchLocale + include TrackSessionActivity skip_before_action :verify_authenticity_token diff --git a/app/controllers/concerns/track_session_activity.rb b/app/controllers/concerns/track_session_activity.rb new file mode 100644 index 000000000..f6a512922 --- /dev/null +++ b/app/controllers/concerns/track_session_activity.rb @@ -0,0 +1,22 @@ +module TrackSessionActivity + extend ActiveSupport::Concern + + included do + after_action :update_session_activity + end + + private + + def update_session_activity + return unless current_user + return if request.headers['client'].blank? + + UserSessionTrackingService.new( + user: current_user, + request: request, + client_id: request.headers['client'] + ).update_activity! + rescue StandardError => e + Rails.logger.warn "Session activity update failed: #{e.message}" + end +end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index bd7bb9b44..587b52c83 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -1,4 +1,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController + MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i + # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] @@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController user = find_user_for_authentication return handle_mfa_required(user) if user&.mfa_enabled? + return if user && enforce_session_limit_for_password_login(user) # Only proceed with standard authentication if no MFA is required super end def render_create_success + track_user_session unless @impersonation render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end @@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def handle_sso_authentication + return if !@impersonation && enforce_session_limit_for_password_login(@resource) + authenticate_resource_with_sso_token yield @resource if block_given? render_create_success @@ -65,7 +71,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def authenticate_resource_with_sso_token - @token = @resource.create_token + # DTA evicts the earliest-expiring token after save when at max_number_of_devices. + # The short-lived impersonation token would always be that one, so pre-evict to make room. + make_room_for_impersonation_token if @impersonation + @token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil) @resource.save! sign_in(:user, @resource, store: false, bypass: false) @@ -73,11 +82,21 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController @resource.invalidate_sso_auth_token(params[:sso_auth_token]) end + def make_room_for_impersonation_token + return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices + + oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first + @resource.tokens.delete(oldest_client_id) if oldest_client_id + end + def process_sso_auth_token return if params[:email].blank? user = User.from_email(params[:email]) - @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) + return unless user&.valid_sso_auth_token?(params[:sso_auth_token]) + + @resource = user + @impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token]) end def handle_mfa_required(user) @@ -103,6 +122,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def sign_in_mfa_user(user) + evict_oldest_session(user) if sessions_limit_reached?(user) @resource = user @token = @resource.create_token @resource.save! @@ -114,6 +134,103 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController def render_mfa_error(message_key, status = :bad_request) render json: { error: I18n.t(message_key) }, status: status end + + def sessions_limit_reached?(user) + active_token_count(user) >= MAX_SESSIONS + end + + def active_token_count(user) + now = Time.current.to_i + (user.tokens || {}).count { |_, v| v['expiry'].to_i > now } + end + + # Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients + # auto-evict instead of getting stuck on a UI they can't render. + def enforce_session_limit_for_password_login(user) + if revoking_sessions? + revoke_sessions_for_login(user) + return false + end + + return false unless sessions_limit_reached?(user) + + # Picker only when every token has a tracked session; partial tracking would + # show a misleading count, so fall through to silent eviction instead. + if browser_request? && user.user_sessions.count >= user.tokens.size + handle_sessions_limit_for_login(user) + true + else + evict_oldest_session(user) + false + end + end + + def browser_request? + request.user_agent.to_s.include?('Mozilla') + end + + def revoking_sessions? + params[:revoke_session_id].present? || params[:revoke_all_sessions].present? + end + + def revoke_sessions_for_login(user) + if params[:revoke_all_sessions].present? + user.tokens = {} + user.save! + user.user_sessions.destroy_all + elsif params[:revoke_session_id].present? + session = user.user_sessions.find_by(id: params[:revoke_session_id]) + return unless session + + user.tokens.delete(session.client_id) + user.save! + session.destroy! + end + end + + def evict_oldest_session(user) + # Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted. + return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size + + oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first + return evict_oldest_token(user) unless oldest_session + + user.tokens.delete(oldest_session.client_id) + user.save! + oldest_session.destroy! + end + + # Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped). + def evict_oldest_token(user) + return if user.tokens.blank? + + oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first + return unless oldest_client_id + + user.tokens.delete(oldest_client_id) + user.save! + end + + PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version + ip_address city country last_activity_at created_at].freeze + + def handle_sessions_limit_for_login(user) + sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) } + render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict + end + + def track_user_session + client_id = @token&.try(:client) || response.headers['client'] + return unless client_id.present? && @resource.present? + + UserSessionTrackingService.new( + user: @resource, + request: request, + client_id: client_id + ).create_or_update! + rescue StandardError => e + Rails.logger.warn "Session tracking failed: #{e.message}" + end end DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 53075a555..ed1a05376 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController :ExternalUserId, :ParentExternalUserId, :ProfileUsername, - :Username + :Username, + :ReferralBody, + :ReferralHeadline, + :ReferralSourceId, + :ReferralSourceType, + :ReferralSourceUrl, + :ReferralMediaId, + :ReferralMediaContentType, + :ReferralMediaUrl, + :ReferralNumMedia, + :ReferralCtwaClid ) end end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 1c27d8260..74bf903f5 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -12,6 +12,7 @@ class ConversationFinder 'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_desc' => %w[sort_on_waiting_since desc], 'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc], + 'unread' => %w[sort_on_unread desc], # To be removed in v3.5.0 'latest' => %w[sort_on_last_activity_at desc], diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 0c993ec59..64e46f0b7 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -17,15 +17,11 @@ module PortalHelper uri.to_s end - def generate_portal_bg_color(portal_color, theme) + def generate_portal_bg(portal_color, theme) base_color = theme == 'dark' ? 'black' : 'white' "color-mix(in srgb, #{portal_color} 20%, #{base_color})" end - def generate_portal_bg(portal_color, theme) - generate_portal_bg_color(portal_color, theme) - end - def generate_gradient_to_bottom(theme) base_color = theme == 'dark' ? '#151718' : 'white' "linear-gradient(to bottom, transparent, #{base_color})" @@ -41,6 +37,10 @@ module PortalHelper language_map[locale] || locale end + def html_lang_attribute(locale) + locale.to_s.tr('_', '-') + end + def theme_query_string(theme) theme.present? && theme != 'system' ? "?theme=#{theme}" : '' end @@ -97,6 +97,18 @@ module PortalHelper ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text end + # Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character. + def render_emoji_or_icon(value, color = nil) + return '' if value.blank? + + # Emojis are non-ascii; bare icon names match this safe charset. + return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/) + + icon_class = value.start_with?('i-') ? value : "i-ri-#{value}" + style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/) + tag.span(class: icon_class, style: style, 'aria-hidden': true) + end + def thumbnail_bg_color(username) colors = ['#6D95BA', '#A4C3C3', '#E19191'] return colors.sample if username.blank? diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index a1b15ee79..b9dc59964 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -106,4 +106,10 @@ export default { const urlData = endPoints('resetAccessToken'); return axios.post(urlData.url); }, + getSessions() { + return axios.get('/api/v1/profile/sessions'); + }, + revokeSession(id) { + return axios.delete(`/api/v1/profile/sessions/${id}`); + }, }; diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index c39a4cf9d..0b32c0bc2 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -40,6 +40,12 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/conversations`, { params }); } + getAttachments(contactId, page = 1) { + return axios.get(`${this.url}/${contactId}/attachments`, { + params: { page }, + }); + } + getContactableInboxes(contactId) { return axios.get(`${this.url}/${contactId}/contactable_inboxes`); } diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue index 351cc7071..44fafb6c5 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue @@ -128,9 +128,14 @@ const closeMobileSidebar = () => { @@ -179,9 +184,14 @@ const closeMobileSidebar = () => {
- +
+ +
+
+ +
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue index 039d2c709..1a9246e27 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue @@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);