diff --git a/backend/src/services/integrationService.ts b/backend/src/services/integrationService.ts index 83addafa52..f198ffacbc 100644 --- a/backend/src/services/integrationService.ts +++ b/backend/src/services/integrationService.ts @@ -15,6 +15,7 @@ import { } from '@crowd/common' import { NangoIntegration, + connectNangoIntegration, createNangoConnection, createNangoGithubConnection, deleteNangoConnection, @@ -60,6 +61,7 @@ import { import { getOrganizations } from '../serverless/integrations/usecases/linkedin/getOrganizations' import getToken from '../serverless/integrations/usecases/nango/getToken' import { getIntegrationRunWorkerEmitter } from '../serverless/utils/queueService' +import { JiraIntegrationData } from '../types/jiraTypes' import { encryptData } from '../utils/crypto' import { IServiceOptions } from './IServiceOptions' @@ -1618,35 +1620,119 @@ export default class IntegrationService { } /** - * Adds/updates Jira integration + * Adds/updates Jira integration (using nango) * @param integrationData to create the integration object * @returns integration object + * @remarks + * Supports the following authentication methods: + * 1. Jira Cloud (basic auth): Requires URL, username, and password (API key) + * 2. Jira Data Center (PAT): Requires URL and optionally a Personal Access Token + * 3. Jira Data Center (basic auth): Requires URL, username, and password (API key) */ - async jiraConnectOrUpdate(integrationData) { + async jiraConnectOrUpdate(integrationData: JiraIntegrationData) { const transaction = await SequelizeRepository.createTransaction(this.options) let integration: any + let connectionId: string try { + const constructNangoConnectionPayload = ( + integrationData: JiraIntegrationData, + ): Record => { + let jiraIntegrationType: NangoIntegration + // nangoPayload is different for each integration + // check https://github.com/NangoHQ/nango/blob/bf0aa529ad3b6af1c72ca6a30ccdde7a3e47d064/packages/providers/providers.yaml#L5007 + let nangoPayload: any + const ATLASSIAN_CLOUD_SUFFIX = '.atlassian.net' as const + + const baseUrl = integrationData.url.trim() + const hostname = new URL(baseUrl).hostname + const isCloudUrl = hostname.endsWith(ATLASSIAN_CLOUD_SUFFIX) + const subdomain = isCloudUrl ? hostname.split(ATLASSIAN_CLOUD_SUFFIX)[0] : null + + if (isCloudUrl && integrationData.username && integrationData.apiToken) { + jiraIntegrationType = NangoIntegration.JIRA_CLOUD_BASIC + nangoPayload = { + params: { + subdomain, + }, + credentials: { + username: integrationData.username, + password: integrationData.apiToken, + }, + } + return { jiraIntegrationType, nangoPayload } + } + + if (!isCloudUrl && integrationData.username && integrationData.apiToken) { + jiraIntegrationType = NangoIntegration.JIRA_DATA_CENTER_BASIC + nangoPayload = { + params: { + baseUrl, + }, + credentials: { + username: integrationData.username, + password: integrationData.apiToken, + }, + } + return { jiraIntegrationType, nangoPayload } + } + + jiraIntegrationType = NangoIntegration.JIRA_DATA_CENTER_API_KEY + nangoPayload = { + params: { + baseUrl, + }, + credentials: { + apiKey: integrationData.personalAccessToken, + }, + } + + return { jiraIntegrationType, nangoPayload } + } + + const { jiraIntegrationType, nangoPayload } = constructNangoConnectionPayload(integrationData) + this.options.log.info( + `jira integration type determined: ${jiraIntegrationType}, starting nango connection...`, + ) + connectionId = await connectNangoIntegration(jiraIntegrationType, nangoPayload) + + if (integrationData.projects && integrationData.projects.length > 0) { + await setNangoMetadata(jiraIntegrationType, connectionId, { + projectIdsToSync: integrationData.projects.map((project) => project.toUpperCase()), + }) + } + integration = await this.createOrUpdate( { + id: connectionId, platform: PlatformType.JIRA, settings: { url: integrationData.url, auth: { username: integrationData.username, - personalAccessToken: integrationData.personalAccessToken, - apiToken: integrationData.apiToken, + personalAccessToken: integrationData.personalAccessToken + ? encryptData(integrationData.personalAccessToken) + : null, + apiToken: integrationData.apiToken ? encryptData(integrationData.apiToken) : null, }, + nangoIntegrationName: jiraIntegrationType, projects: integrationData.projects.map((project) => project.toUpperCase()), }, status: 'done', }, transaction, ) - + await startNangoSync(jiraIntegrationType, connectionId) await SequelizeRepository.commitTransaction(transaction) - } catch (err) { + } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) - throw err + if (error instanceof TypeError && error.message.includes('Invalid URL')) { + this.options.log.error(`Invalid url: ${integrationData.url}`) + throw new Error400(this.options.language, 'errors.jira.invalidUrl') + } + if (error && error.message.includes('credentials')) { + throw new Error400(this.options.language, 'errors.jira.invalidCredentials') + } + throw error } return integration } diff --git a/backend/src/types/jiraTypes.ts b/backend/src/types/jiraTypes.ts new file mode 100644 index 0000000000..5b31ce1b58 --- /dev/null +++ b/backend/src/types/jiraTypes.ts @@ -0,0 +1,7 @@ +export interface JiraIntegrationData { + url: string + username?: string + personalAccessToken?: string + apiToken?: string + projects?: string[] +} diff --git a/frontend/src/config/integrations/jira/components/jira-connect.vue b/frontend/src/config/integrations/jira/components/jira-connect.vue index 00d1cf704c..fd74db2995 100644 --- a/frontend/src/config/integrations/jira/components/jira-connect.vue +++ b/frontend/src/config/integrations/jira/components/jira-connect.vue @@ -1,18 +1,14 @@