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/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 index 3097b4921..1c703764b 100644 --- a/app/controllers/api/v2/accounts/live_reports_controller.rb +++ b/app/controllers/api/v2/accounts/live_reports_controller.rb @@ -2,6 +2,8 @@ class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseControll 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, @@ -31,6 +33,10 @@ class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseControll 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 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/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 = () => {