diff --git a/.env.example b/.env.example index b7ba0920d..6e2b7fe56 100644 --- a/.env.example +++ b/.env.example @@ -155,6 +155,10 @@ TWITTER_ENVIRONMENT= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +#Linear Integration +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= + # Google OAuth GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= 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/.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 b/Gemfile index e8639a5bd..937aef4af 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.5.1' +gem 'slack-ruby-client', '~> 2.5.2' # for dialogflow integrations gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'grpc' diff --git a/Gemfile.lock b/Gemfile.lock index abb007178..e9a573816 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -747,7 +747,7 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - slack-ruby-client (2.5.1) + slack-ruby-client (2.5.2) faraday (>= 2.0) faraday-mashify faraday-multipart @@ -954,7 +954,7 @@ DEPENDENCIES sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) - slack-ruby-client (~> 2.5.1) + slack-ruby-client (~> 2.5.2) spring spring-watcher-listen squasher 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/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb new file mode 100644 index 000000000..c0688cefc --- /dev/null +++ b/app/controllers/linear/callbacks_controller.rb @@ -0,0 +1,70 @@ +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 + OAuth2::Client.new( + ENV.fetch('LINEAR_CLIENT_ID', nil), + ENV.fetch('LINEAR_CLIENT_SECRET', nil), + { + 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/helpers/linear/integration_helper.rb b/app/helpers/linear/integration_helper.rb new file mode 100644 index 000000000..19f16832d --- /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 ||= ENV.fetch('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/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 = () => {