diff --git a/Gemfile.lock b/Gemfile.lock index 141afc122..4fdcf804a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -170,7 +170,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) benchmark (0.4.1) - bigdecimal (3.3.1) + bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -274,8 +274,8 @@ GEM dry-logic (~> 1.5) dry-types (~> 1.8) zeitwerk (~> 2.6) - dry-types (1.8.3) - bigdecimal (~> 3.0) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -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) @@ -598,14 +598,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.5) - nokogiri (1.19.3) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.6) auth-sanitizer (~> 0.2, >= 0.2.1) @@ -627,7 +627,7 @@ GEM rack (>= 1.2, < 4) snaky_hash (~> 2.0, >= 2.0.5) version_gem (~> 1.1, >= 1.1.11) - oj (3.16.10) + oj (3.17.3) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.4) @@ -674,7 +674,7 @@ GEM opentelemetry-api (~> 1.0) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.8.0) ast (~> 2.4.1) diff --git a/VERSION_CW b/VERSION_CW index 0fb7a35b6..fb0557132 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.2 +4.15.1 diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index ecd6f06ea..712a24608 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder return if unsupported_file_type?(attachment['type']) params = attachment_params(attachment) + # During Meta's sticker webhook transition, a sticker message carries both an `image` + # and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it + # isn't attached twice, while still storing legitimate duplicate attachments of other types. + return if duplicate_sticker?(attachment, params[:external_url]) + attachment_obj = @message.attachments.new(params.except(:remote_file_url)) attachment_obj.save! if facebook_reel?(attachment) @@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder elsif params[:remote_file_url] attach_file(attachment_obj, params[:remote_file_url]) end + fetch_attachment_links(attachment_obj) + update_attachment_file_type(attachment_obj) + end + + def fetch_attachment_links(attachment_obj) fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' - update_attachment_file_type(attachment_obj) end def attach_file(attachment, file_url) @@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder # Facebook may send attachment types that don't directly match our file_type enum. # Map known aliases to their canonical enum values. - FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze + FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze def normalize_file_type(type) sym = type.to_sym FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym) end + def duplicate_sticker?(attachment, url) + return false unless attachment['type'].to_sym == :sticker + return false if url.blank? + + @message.attachments.any? { |existing| existing.external_url == url } + end + # Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than # direct video URLs. Downloading these yields HTML, not video content. def facebook_reel?(attachment) 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/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index eafda0fe2..bc1082583 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController render json: error, status: error_status end end + +Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController') 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/captain/messageReports.js b/app/javascript/dashboard/api/captain/messageReports.js new file mode 100644 index 000000000..2df1e5747 --- /dev/null +++ b/app/javascript/dashboard/api/captain/messageReports.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class MessageReports extends ApiClient { + constructor() { + super('captain/message_reports', { accountScoped: true }); + } +} + +export default new MessageReports(); 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/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index bab45bcb5..55b620d1a 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -16,6 +16,7 @@ class ArticlesAPI extends PortalsAPI { authorId, categorySlug, sort, + query, }) { const url = getArticleSearchURL({ pageNumber, @@ -25,6 +26,7 @@ class ArticlesAPI extends PortalsAPI { authorId, categorySlug, sort, + query, host: this.url, }); diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index 784edce6c..4a67e5182 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -145,6 +145,12 @@ --black-alpha-2: 0, 0, 0, 0.04; --border-blue: 39, 129, 246, 0.5; --white-alpha: 255, 255, 255, 0.8; + + // Voice call widget - light mode + --call-widget: 33, 34, 38, 0.95; + --call-widget-border: 255, 255, 255, 0.1; + --call-widget-text: 237, 238, 240, 1; + --call-widget-sub-text: 173, 177, 184, 1; } .dark { @@ -291,6 +297,12 @@ --border-blue: 39, 129, 246, 0.5; --border-container: 255, 255, 255, 0; --white-alpha: 255, 255, 255, 0.1; + + // Voice call widget - dark mode + --call-widget: 50, 53, 61, 1; + --call-widget-border: 255, 255, 255, 0.07; + --call-widget-text: 237, 238, 240, 1; + --call-widget-sub-text: 173, 177, 184, 1; } } // NEXT COLORS END diff --git a/app/javascript/dashboard/components-next/Companies/CompanyCreateDialog.vue b/app/javascript/dashboard/components-next/Companies/CompanyCreateDialog.vue index b3847030b..4b9d0b466 100644 --- a/app/javascript/dashboard/components-next/Companies/CompanyCreateDialog.vue +++ b/app/javascript/dashboard/components-next/Companies/CompanyCreateDialog.vue @@ -26,6 +26,13 @@ const resetForm = () => { form.description = ''; }; +const open = (company = {}) => { + form.name = company.name || ''; + form.domain = company.domain || ''; + form.description = company.description || ''; + dialogRef.value?.open(); +}; + const handleConfirm = () => { if (isFormInvalid.value) return; @@ -45,7 +52,7 @@ const onSuccess = () => { closeDialog(); }; -defineExpose({ dialogRef, onSuccess }); +defineExpose({ dialogRef, onSuccess, open });