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/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/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 1ef6d9511..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,13 +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 + track_user_session unless @impersonation render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end @@ -54,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 @@ -66,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) @@ -74,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) @@ -104,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! @@ -116,6 +135,90 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController 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? 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/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 });