diff --git a/.eslintrc.js b/.eslintrc.js index a2932b2a9..6c867f557 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,8 @@ module.exports = { 'prettier', 'plugin:vue/vue3-recommended', 'plugin:vitest-globals/recommended', + // use recommended-legacy when upgrading the plugin to v4 + 'plugin:@intlify/vue-i18n/recommended', ], overrides: [ { @@ -229,6 +231,18 @@ module.exports = { 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], 'no-console': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'warn', + '@intlify/vue-i18n/no-unused-keys': [ + 'warn', + { + extensions: ['.js', '.vue'], + }, + ], + }, + settings: { + 'vue-i18n': { + localeDir: './app/javascript/*/i18n/**.json', + }, }, env: { browser: true, diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index d11fe6401..a01ba1093 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -16,7 +16,7 @@ on: jobs: nightly: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: get installer diff --git a/.gitignore b/.gitignore index 5eb883db0..77c4a4740 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ test/cypress/videos/* #ignore files under .vscode directory .vscode +.cursor # yalc for local testing .yalc diff --git a/Gemfile.lock b/Gemfile.lock index e9a573816..857319fc4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -561,7 +561,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.11) + rack (2.2.12) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -799,7 +799,7 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) - uri (0.13.0) + uri (1.0.3) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) diff --git a/app/assets/stylesheets/administrate/components/_buttons.scss b/app/assets/stylesheets/administrate/components/_buttons.scss index 7b2f62045..a0c3699ba 100644 --- a/app/assets/stylesheets/administrate/components/_buttons.scss +++ b/app/assets/stylesheets/administrate/components/_buttons.scss @@ -1,8 +1,8 @@ -button, -input[type="button"], -input[type="reset"], -input[type="submit"], -.button { +button:not(.reset-base), +input[type='button']:not(.reset-base), +input[type='reset']:not(.reset-base), +input[type='submit']:not(.reset-base), +.button:not(.reset-base) { appearance: none; background-color: $color-woot; border: 0; diff --git a/app/assets/stylesheets/administrate/custom_styles.scss b/app/assets/stylesheets/administrate/custom_styles.scss index 5e6d803d8..00f1a058c 100644 --- a/app/assets/stylesheets/administrate/custom_styles.scss +++ b/app/assets/stylesheets/administrate/custom_styles.scss @@ -10,7 +10,6 @@ .icon-container { margin-right: 2px; - } .value-container { diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2cd5281ff..138c2bd68 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :inbox, :contact, :contact_inbox, only: [:create] + ATTACHMENT_RESULTS_PER_PAGE = 100 + def index result = conversation_finder.perform @conversations = result[:conversations] @@ -24,7 +26,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def attachments + @attachments_count = @conversation.attachments.count @attachments = @conversation.attachments + .includes(:message) + .order(created_at: :desc) + .page(attachment_params[:page]) + .per(ATTACHMENT_RESULTS_PER_PAGE) end def show; end @@ -124,6 +131,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro params.permit(:priority) end + def attachment_params + params.permit(:page) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) # rubocop:disable Rails/SkipsModelValidations @conversation.update_column(:agent_last_seen_at, last_seen_at) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 814373c7e..4e5348e88 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,11 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end def teams teams = linear_processor_service.teams @@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def permitted_params params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) end + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') + end end diff --git a/app/controllers/api/v2/accounts/live_reports_controller.rb b/app/controllers/api/v2/accounts/live_reports_controller.rb new file mode 100644 index 000000000..1c703764b --- /dev/null +++ b/app/controllers/api/v2/accounts/live_reports_controller.rb @@ -0,0 +1,64 @@ +class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController + before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics] + before_action :set_group_scope, only: [:grouped_conversation_metrics] + + before_action :check_authorization + + def conversation_metrics + render json: { + open: @conversations.open.count, + unattended: @conversations.open.unattended.count, + unassigned: @conversations.open.unassigned.count, + pending: @conversations.pending.count + } + end + + def grouped_conversation_metrics + count_by_group = @conversations.open.group(@group_scope).count + unattended_by_group = @conversations.open.unattended.group(@group_scope).count + unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count + + group_metrics = count_by_group.map do |group_id, count| + metric = { + open: count, + unattended: unattended_by_group[group_id] || 0, + unassigned: unassigned_by_group[group_id] || 0 + } + metric[@group_scope] = group_id + metric + end + + render json: group_metrics + end + + private + + def check_authorization + authorize :report, :view? + end + + def set_group_scope + render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[ + team_id + assignee_id + ].include?(permitted_params[:group_by]) + + @group_scope = permitted_params[:group_by] + end + + def team + return unless permitted_params[:team_id] + + @team ||= Current.account.teams.find(permitted_params[:team_id]) + end + + def load_conversations + scope = Current.account.conversations + scope = scope.where(team_id: team.id) if team.present? + @conversations = scope + end + + def permitted_params + params.permit(:team_id, :group_by) + end +end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 3013ff3cc..a8ea8ae05 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -5,10 +5,11 @@ module SwitchLocale def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) - locale ||= locale_from_params + locale ||= params[:locale] + locale ||= locale_from_custom_domain # if locale is not set in account, let's use DEFAULT_LOCALE env variable - locale ||= locale_from_env_variable + locale ||= ENV.fetch('DEFAULT_LOCALE', nil) set_locale(locale, &) end @@ -32,26 +33,30 @@ module SwitchLocale end def set_locale(locale, &) - # if locale is empty, use default_locale - locale ||= I18n.default_locale + safe_locale = validate_and_get_locale(locale) # Ensure locale won't bleed into other requests # https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests - I18n.with_locale(locale, &) + I18n.with_locale(safe_locale, &) end - def locale_from_params - I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil + def validate_and_get_locale(locale) + return I18n.default_locale.to_s if locale.blank? + + available_locales = I18n.available_locales.map(&:to_s) + locale_without_variant = locale.split('_')[0] + + if available_locales.include?(locale) + locale + elsif available_locales.include?(locale_without_variant) + locale_without_variant + else + I18n.default_locale.to_s + end end def locale_from_account(account) return unless account - I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil - end - - def locale_from_env_variable - return unless ENV.fetch('DEFAULT_LOCALE', nil) - - I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil + account.locale end end diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb new file mode 100644 index 000000000..2eea49333 --- /dev/null +++ b/app/controllers/linear/callbacks_controller.rb @@ -0,0 +1,73 @@ +class Linear::CallbacksController < ApplicationController + include Linear::IntegrationHelper + + def show + @response = oauth_client.auth_code.get_token( + params[:code], + redirect_uri: "#{base_url}/linear/callback" + ) + + handle_response + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + private + + def oauth_client + app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil) + + OAuth2::Client.new( + app_id, + app_secret, + { + site: 'https://api.linear.app', + token_url: '/oauth/token', + authorize_url: '/oauth/authorize' + } + ) + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'linear', + settings: { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + scope: parsed_body['scope'] + } + ) + # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 + hook.save! + redirect_to linear_redirect_uri + rescue StandardError => e + Rails.logger.error("Linear callback error: #{e.message}") + redirect_to linear_redirect_uri + end + + def account + @account ||= Account.find(account_id) + end + + def account_id + return unless params[:state] + + verify_linear_token(params[:state]) + end + + def linear_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index f6c10f7c4..66b052b1e 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -1,4 +1,6 @@ class Public::Api::V1::Portals::BaseController < PublicController + include SwitchLocale + before_action :show_plain_layout before_action :set_color_scheme before_action :set_global_config @@ -27,14 +29,7 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - locale_without_variant = params[:locale].split('_')[0] - is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale]) - is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant) - if is_locale_available - @locale = params[:locale] - elsif is_locale_variant_available - @locale = locale_without_variant - end + @locale = validate_and_get_locale(params[:locale]) I18n.with_locale(@locale, &) end @@ -44,12 +39,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - @locale = if article.category.present? - article.category.locale - else - article.portal.default_locale - end - + article_locale = if article.category.present? + article.category.locale + else + article.portal.default_locale + end + @locale = validate_and_get_locale(article_locale) I18n.with_locale(@locale, &) end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b8f3bd9a9..43157fa0e 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,6 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController %w[AZURE_APP_ID AZURE_APP_SECRET] when 'email' ['MAILER_INBOUND_EMAIL_DOMAIN'] + when 'linear' + %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index b566551f4..f7b04a167 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard # COLLECTION_FILTERS = { # open: ->(resources) { resources.where(open: true) } # }.freeze - COLLECTION_FILTERS = {}.freeze + COLLECTION_FILTERS = { + active: ->(resources) { resources.where(status: :active) }, + suspended: ->(resources) { resources.where(status: :suspended) }, + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) } + }.freeze # Overwrite this method to customize how accounts are displayed # across all pages of the admin dashboard. diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 6b2129eed..8abdefd1a 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard # COLLECTION_FILTERS = { # open: ->(resources) { resources.where(open: true) } # }.freeze - COLLECTION_FILTERS = {}.freeze + COLLECTION_FILTERS = { + super_admin: ->(resources) { resources.where(type: 'SuperAdmin') }, + confirmed: ->(resources) { resources.where.not(confirmed_at: nil) }, + unconfirmed: ->(resources) { resources.where(confirmed_at: nil) }, + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) } + }.freeze # Overwrite this method to customize how users are displayed # across all pages of the admin dashboard. diff --git a/app/helpers/filter_helper.rb b/app/helpers/filters/filter_helper.rb similarity index 90% rename from app/helpers/filter_helper.rb rename to app/helpers/filters/filter_helper.rb index 9b5cac684..fe03dae28 100644 --- a/app/helpers/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -1,4 +1,4 @@ -module FilterHelper +module Filters::FilterHelper def build_condition_query(model_filters, query_hash, current_index) current_filter = model_filters[query_hash['attribute_key']] @@ -89,4 +89,18 @@ module FilterHelper operator = condition['query_operator'].upcase raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator) end + + def conversation_status_values(values) + return Conversation.statuses.values if values.include?('all') + + values.map { |x| Conversation.statuses[x.to_sym] } + end + + def conversation_priority_values(values) + values.map { |x| Conversation.priorities[x.to_sym] } + end + + def message_type_values(values) + values.map { |x| Message.message_types[x.to_sym] } + end end diff --git a/app/helpers/linear/integration_helper.rb b/app/helpers/linear/integration_helper.rb new file mode 100644 index 000000000..67df836ce --- /dev/null +++ b/app/helpers/linear/integration_helper.rb @@ -0,0 +1,47 @@ +module Linear::IntegrationHelper + # Generates a signed JWT token for Linear integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_linear_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Linear token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Linear JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_linear_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Linear token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index c32cb98d2..ae50055fb 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -14,6 +14,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue'; import { setColorTheme } from './helper/themeHelper'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import { useAccount } from 'dashboard/composables/useAccount'; +import { useFontSize } from 'dashboard/composables/useFontSize'; import { registerSubscription, verifyServiceWorkerExistence, @@ -37,8 +38,15 @@ export default { const router = useRouter(); const store = useStore(); const { accountId } = useAccount(); + // Use the font size composable (it automatically sets up the watcher) + const { currentFontSize } = useFontSize(); - return { router, store, currentAccountId: accountId }; + return { + router, + store, + currentAccountId: accountId, + currentFontSize, + }; }, data() { return { diff --git a/app/javascript/dashboard/api/captain/bulkActions.js b/app/javascript/dashboard/api/captain/bulkActions.js new file mode 100644 index 000000000..fd69a1108 --- /dev/null +++ b/app/javascript/dashboard/api/captain/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainBulkActionsAPI extends ApiClient { + constructor() { + super('captain/bulk_actions', { accountScoped: true }); + } +} + +export default new CaptainBulkActionsAPI(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 8b9eacf3f..39546096f 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -137,6 +137,10 @@ class ConversationApi extends ApiClient { requestCopilot(conversationId, body) { return axios.post(`${this.url}/${conversationId}/copilot`, body); } + + getInboxAssistant(conversationId) { + return axios.get(`${this.url}/${conversationId}/inbox_assistant`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss index d35c1cfc9..9367e8b2d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_base.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss @@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) { } input[type='file'] { - @apply bg-white dark:bg-slate-800 leading-[1.15] mb-4; + @apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4; } // Select @@ -141,11 +141,16 @@ code { @apply text-xs border-0; &.hljs { - @apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5; + @apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5; .hljs-number, .hljs-string { @apply text-red-800 dark:text-red-400; } + + .hljs-name, + .hljs-tag { + @apply text-n-slate-11; + } } } diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue index 3169dbc3e..184e8bd10 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue @@ -22,7 +22,7 @@ const handleButtonClick = () => {