diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 5df8b4e910..b030b79d6f 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -1,8 +1,11 @@ import { Request, Response } from 'express'; +import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { readFileSync } from 'fs'; -import { IntegrationAuth, Integration } from '../../models'; +import { + Integration, + IntegrationAuth, + Bot +} from '../../models'; import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables'; import { IntegrationService } from '../../services'; import { getApps, revokeAccess } from '../../integrations'; @@ -44,7 +47,7 @@ export const oAuthExchange = async ( environment: environments[0].slug, }); } catch (err) { - Sentry.setUser(null); + Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ message: 'Failed to get OAuth2 code-token exchange' @@ -56,6 +59,67 @@ export const oAuthExchange = async ( }); }; +/** + * Save integration access token as part of integration [integration] for workspace with id [workspaceId] + * @param req + * @param res + */ +export const saveIntegrationAccessToken = async ( + req: Request, + res: Response +) => { + // TODO: refactor + let integrationAuth; + try { + const { + workspaceId, + accessToken, + integration + }: { + workspaceId: string; + accessToken: string; + integration: string; + } = req.body; + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + workspace: new Types.ObjectId(workspaceId), + integration + }, { + workspace: new Types.ObjectId(workspaceId), + integration + }, { + new: true, + upsert: true + }); + + const bot = await Bot.findOne({ + workspace: new Types.ObjectId(workspaceId), + isActive: true + }); + + if (!bot) throw new Error('Bot must be enabled to save integration access token'); + + // encrypt and save integration access token + integrationAuth = await IntegrationService.setIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString(), + accessToken, + accessExpiresAt: undefined + }); + + if (!integrationAuth) throw new Error('Failed to save integration access token'); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to save access token for integration' + }); + } + + return res.status(200).send({ + integrationAuth + }); +} + /** * Return list of applications allowed for integration with integration authorization id [integrationAuthId] * @param req @@ -70,7 +134,7 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { accessToken: req.accessToken }); } catch (err) { - Sentry.setUser(null); + Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ message: 'Failed to get integration authorization applications' @@ -89,15 +153,14 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { * @returns */ export const deleteIntegrationAuth = async (req: Request, res: Response) => { + let integrationAuth; try { - const { integrationAuthId } = req.params; - - await revokeAccess({ + integrationAuth = await revokeAccess({ integrationAuth: req.integrationAuth, accessToken: req.accessToken }); } catch (err) { - Sentry.setUser(null); + Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ message: 'Failed to delete integration authorization' @@ -105,6 +168,6 @@ export const deleteIntegrationAuth = async (req: Request, res: Response) => { } return res.status(200).send({ - message: 'Successfully deleted integration authorization' + integrationAuth }); } \ No newline at end of file diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 6fcb02b5ad..4e66ce0aad 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -1,25 +1,43 @@ import { Request, Response } from 'express'; -import { readFileSync } from 'fs'; import * as Sentry from '@sentry/node'; -import { Integration, Bot, BotKey } from '../../models'; +import { + Integration, + Workspace, + Bot, + BotKey +} from '../../models'; import { EventService } from '../../services'; import { eventPushSecrets } from '../../events'; -interface Key { - encryptedKey: string; - nonce: string; -} +/** + * Create/initialize an (empty) integration for integration authorization + * @param req + * @param res + * @returns + */ +export const createIntegration = async (req: Request, res: Response) => { + let integration; + try { + // initialize new integration after saving integration access token + integration = await new Integration({ + workspace: req.integrationAuth.workspace._id, + isActive: false, + app: null, + environment: req.integrationAuth.workspace?.environments[0].slug, + integration: req.integrationAuth.integration, + integrationAuth: req.integrationAuth._id + }).save(); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to create integration' + }); + } -interface PushSecret { - ciphertextKey: string; - ivKey: string; - tagKey: string; - hashKey: string; - ciphertextValue: string; - ivValue: string; - tagValue: string; - hashValue: string; - type: 'shared' | 'personal'; + return res.status(200).send({ + integration + }); } /** @@ -36,13 +54,12 @@ export const updateIntegration = async (req: Request, res: Response) => { try { const { - app, environment, isActive, - target, // vercel-specific integration param - context, // netlify-specific integration param - siteId, // netlify-specific integration param - owner // github-specific integration param + app, + appId, + targetEnvironment, + owner, // github-specific integration param } = req.body; integration = await Integration.findOneAndUpdate( @@ -53,9 +70,8 @@ export const updateIntegration = async (req: Request, res: Response) => { environment, isActive, app, - target, - context, - siteId, + appId, + targetEnvironment, owner }, { @@ -101,27 +117,6 @@ export const deleteIntegration = async (req: Request, res: Response) => { }); if (!integration) throw new Error('Failed to find integration'); - - const integrations = await Integration.find({ - workspace: integration.workspace - }); - - if (integrations.length === 0) { - // case: no integrations left, deactivate bot - const bot = await Bot.findOneAndUpdate({ - workspace: integration.workspace - }, { - isActive: false - }, { - new: true - }); - - if (bot) { - await BotKey.deleteOne({ - bot: bot._id - }); - } - } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index f602f17291..1618e5e677 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -127,7 +127,6 @@ const syncIntegrationsHelper = async ({ }) => { let integrations; try { - integrations = await Integration.find({ workspace: workspaceId, isActive: true, @@ -142,7 +141,7 @@ const syncIntegrationsHelper = async ({ workspaceId: integration.workspace.toString(), environment: integration.environment }); - + const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth); if (!integrationAuth) throw new Error('Failed to find integration auth'); @@ -316,7 +315,7 @@ const setIntegrationAuthAccessHelper = async ({ }: { integrationAuthId: string; accessToken: string; - accessExpiresAt: Date; + accessExpiresAt: Date | undefined; }) => { let integrationAuth; try { diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 79187c6926..d1252954ee 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -7,9 +7,13 @@ import { INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_RENDER_API_URL, + INTEGRATION_FLYIO_API_URL } from '../variables'; /** @@ -29,10 +33,11 @@ const getApps = async ({ }) => { interface App { name: string; - siteId?: string; + appId?: string; + owner?: string; } - let apps: App[]; // TODO: add type and define payloads for apps + let apps: App[]; try { switch (integrationAuth.integration) { case INTEGRATION_HEROKU: @@ -48,13 +53,21 @@ const getApps = async ({ break; case INTEGRATION_NETLIFY: apps = await getAppsNetlify({ - integrationAuth, accessToken }); break; case INTEGRATION_GITHUB: apps = await getAppsGithub({ - integrationAuth, + accessToken + }); + break; + case INTEGRATION_RENDER: + apps = await getAppsRender({ + accessToken + }); + break; + case INTEGRATION_FLYIO: + apps = await getAppsFlyio({ accessToken }); break; @@ -69,7 +82,7 @@ const getApps = async ({ }; /** - * Return list of names of apps for Heroku integration + * Return list of apps for Heroku integration * @param {Object} obj * @param {String} obj.accessToken - access token for Heroku API * @returns {Object[]} apps - names of Heroku apps @@ -141,17 +154,15 @@ const getAppsVercel = async ({ }; /** - * Return list of names of sites for Netlify integration + * Return list of sites for Netlify integration * @param {Object} obj * @param {String} obj.accessToken - access token for Netlify API * @returns {Object[]} apps - names of Netlify sites * @returns {String} apps.name - name of Netlify site */ const getAppsNetlify = async ({ - integrationAuth, accessToken }: { - integrationAuth: IIntegrationAuth; accessToken: string; }) => { let apps; @@ -166,7 +177,7 @@ const getAppsNetlify = async ({ apps = res.map((a: any) => ({ name: a.name, - siteId: a.site_id + appId: a.site_id })); } catch (err) { Sentry.setUser(null); @@ -178,17 +189,15 @@ const getAppsNetlify = async ({ }; /** - * Return list of names of repositories for Github integration + * Return list of repositories for Github integration * @param {Object} obj * @param {String} obj.accessToken - access token for Netlify API * @returns {Object[]} apps - names of Netlify sites * @returns {String} apps.name - name of Netlify site */ const getAppsGithub = async ({ - integrationAuth, accessToken }: { - integrationAuth: IIntegrationAuth; accessToken: string; }) => { let apps; @@ -220,4 +229,94 @@ const getAppsGithub = async ({ return apps; }; +/** + * Return list of services for Render integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Render API + * @returns {Object[]} apps - names and ids of Render services + * @returns {String} apps.name - name of Render service + * @returns {String} apps.appId - id of Render service + */ +const getAppsRender = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps: any; + try { + const res = ( + await axios.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + ).data; + + apps = res + .map((a: any) => ({ + name: a.service.name, + appId: a.service.id + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Render services'); + } + + return apps; +} + +/** + * Return list of apps for Fly.io integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Fly.io API + * @returns {Object[]} apps - names and ids of Fly.io apps + * @returns {String} apps.name - name of Fly.io apps + */ +const getAppsFlyio = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps; + try { + const query = ` + query($role: String) { + apps(type: "container", first: 400, role: $role) { + nodes { + id + name + hostname + } + } + } + `; + + const res = (await axios({ + url: INTEGRATION_FLYIO_API_URL, + method: 'post', + headers: { + 'Authorization': 'Bearer ' + accessToken + }, + data: { + query, + variables: { + role: null + } + } + })).data.data.apps.nodes; + + apps = res + .map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Fly.io apps'); + } + + return apps; +} + export { getApps }; diff --git a/backend/src/integrations/revoke.ts b/backend/src/integrations/revoke.ts index 4834863433..25ec703135 100644 --- a/backend/src/integrations/revoke.ts +++ b/backend/src/integrations/revoke.ts @@ -1,6 +1,11 @@ -import axios from 'axios'; import * as Sentry from '@sentry/node'; -import { IIntegrationAuth, IntegrationAuth, Integration } from '../models'; +import { + IIntegrationAuth, + IntegrationAuth, + Integration, + Bot, + BotKey +} from '../models'; import { INTEGRATION_HEROKU, INTEGRATION_VERCEL, @@ -15,6 +20,7 @@ const revokeAccess = async ({ integrationAuth: IIntegrationAuth; accessToken: string; }) => { + let deletedIntegrationAuth; try { // add any integration-specific revocation logic switch (integrationAuth.integration) { @@ -28,7 +34,7 @@ const revokeAccess = async ({ break; } - const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ + deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ _id: integrationAuth._id }); @@ -42,6 +48,8 @@ const revokeAccess = async ({ Sentry.captureException(err); throw new Error('Failed to delete integration authorization'); } + + return deletedIntegrationAuth; }; export { revokeAccess }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index bfe2887c61..9954943b8d 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,4 +1,4 @@ -import axios, { AxiosError } from 'axios'; +import axios from 'axios'; import * as Sentry from '@sentry/node'; import { Octokit } from '@octokit/rest'; // import * as sodium from 'libsodium-wrappers'; @@ -10,9 +10,13 @@ import { INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_RENDER_API_URL, + INTEGRATION_FLYIO_API_URL } from '../variables'; import { access, appendFile } from 'fs'; @@ -21,8 +25,6 @@ import { access, appendFile } from 'fs'; * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {IIntegrationAuth} obj.integrationAuth - integration auth details - * @param {Object} obj.app - app in integration - * @param {Object} obj.target - (optional) target (environment) in integration * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) * @param {String} obj.accessToken - access token for integration */ @@ -69,6 +71,20 @@ const syncSecrets = async ({ accessToken }); break; + case INTEGRATION_RENDER: + await syncSecretsRender({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_FLYIO: + await syncSecretsFlyio({ + integration, + secrets, + accessToken + }); + break; } } catch (err) { Sentry.setUser(null); @@ -78,10 +94,11 @@ const syncSecrets = async ({ }; /** - * Sync/push [secrets] to Heroku [app] + * Sync/push [secrets] to Heroku app named [integration.app] * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Heroku integration */ const syncSecretsHeroku = async ({ integration, @@ -129,7 +146,7 @@ const syncSecretsHeroku = async ({ }; /** - * Sync/push [secrets] to Heroku [app] + * Sync/push [secrets] to Vercel project named [integration.app] * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) @@ -174,7 +191,7 @@ const syncSecretsVercel = async ({ )) .data .envs - .filter((secret: VercelSecret) => secret.target.includes(integration.target)) + .filter((secret: VercelSecret) => secret.target.includes(integration.targetEnvironment)) .map(async (secret: VercelSecret) => (await axios.get( `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, { @@ -201,7 +218,7 @@ const syncSecretsVercel = async ({ key: key, value: secrets[key], type: 'encrypted', - target: [integration.target] + target: [integration.targetEnvironment] }); } }); @@ -216,7 +233,7 @@ const syncSecretsVercel = async ({ key: key, value: secrets[key], type: 'encrypted', - target: [integration.target] + target: [integration.targetEnvironment] }); } } else { @@ -226,7 +243,7 @@ const syncSecretsVercel = async ({ key: key, value: res[key].value, type: 'encrypted', - target: [integration.target], + target: [integration.targetEnvironment], }); } }); @@ -287,11 +304,12 @@ const syncSecretsVercel = async ({ } /** - * Sync/push [secrets] to Netlify site [app] + * Sync/push [secrets] to Netlify site with id [integration.appId] * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {IIntegrationAuth} obj.integrationAuth - integration auth details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {Object} obj.accessToken - access token for Netlify integration */ const syncSecretsNetlify = async ({ integration, @@ -323,7 +341,7 @@ const syncSecretsNetlify = async ({ const getParams = new URLSearchParams({ context_name: 'all', // integration.context or all - site_id: integration.siteId + site_id: integration.appId }); const res = (await axios.get( @@ -354,7 +372,7 @@ const syncSecretsNetlify = async ({ key, values: [{ value: secrets[key], - context: integration.context + context: integration.targetEnvironment }] }); } else { @@ -365,15 +383,15 @@ const syncSecretsNetlify = async ({ [value.context]: value }), {}); - if (integration.context in contexts) { + if (integration.targetEnvironment in contexts) { // case: Netlify secret value exists in integration context - if (secrets[key] !== contexts[integration.context].value) { + if (secrets[key] !== contexts[integration.targetEnvironment].value) { // case: Infisical and Netlify secret values are different // -> update Netlify secret context and value updateSecrets.push({ key, values: [{ - context: integration.context, + context: integration.targetEnvironment, value: secrets[key] }] }); @@ -384,7 +402,7 @@ const syncSecretsNetlify = async ({ updateSecrets.push({ key, values: [{ - context: integration.context, + context: integration.targetEnvironment, value: secrets[key] }] }); @@ -402,7 +420,7 @@ const syncSecretsNetlify = async ({ const numberOfValues = res[key].values.length; res[key].values.forEach((value: NetlifyValue) => { - if (value.context === integration.context) { + if (value.context === integration.targetEnvironment) { if (numberOfValues <= 1) { // case: Netlify secret value has less than 1 context -> delete secret deleteSecrets.push(key); @@ -412,7 +430,7 @@ const syncSecretsNetlify = async ({ key, values: [{ id: value.id, - context: integration.context, + context: integration.targetEnvironment, value: value.value }] }); @@ -423,7 +441,7 @@ const syncSecretsNetlify = async ({ }); const syncParams = new URLSearchParams({ - site_id: integration.siteId + site_id: integration.appId }); if (newSecrets.length > 0) { @@ -492,11 +510,12 @@ const syncSecretsNetlify = async ({ } /** - * Sync/push [secrets] to GitHub [repo] + * Sync/push [secrets] to GitHub repo with name [integration.app] * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {IIntegrationAuth} obj.integrationAuth - integration auth details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for GitHub integration */ const syncSecretsGitHub = async ({ integration, @@ -605,4 +624,175 @@ const syncSecretsGitHub = async ({ } }; +/** + * Sync/push [secrets] to Render service with id [integration.appId] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Render integration + */ +const syncSecretsRender = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + await axios.put( + `${INTEGRATION_RENDER_API_URL}/v1/services/${integration.appId}/env-vars`, + Object.keys(secrets).map((key) => ({ + key, + value: secrets[key] + })), + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Render'); + } +} + +/** + * Sync/push [secrets] to Fly.io app + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Render integration + */ +const syncSecretsFlyio = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + // set secrets + const SetSecrets = ` + mutation($input: SetSecretsInput!) { + setSecrets(input: $input) { + release { + id + version + reason + description + user { + id + email + name + } + evaluationId + createdAt + } + } + } + `; + + await axios({ + url: INTEGRATION_FLYIO_API_URL, + method: 'post', + headers: { + 'Authorization': 'Bearer ' + accessToken + }, + data: { + query: SetSecrets, + variables: { + input: { + appId: integration.app, + secrets: Object.entries(secrets).map(([key, value]) => ({ key, value })) + } + } + } + }); + + // get secrets + interface FlyioSecret { + name: string; + digest: string; + createdAt: string; + } + + const GetSecrets = `query ($appName: String!) { + app(name: $appName) { + secrets { + name + digest + createdAt + } + } + }`; + + const getSecretsRes = (await axios({ + method: 'post', + url: INTEGRATION_FLYIO_API_URL, + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + data: { + query: GetSecrets, + variables: { + appName: integration.app + } + } + })).data.data.app.secrets; + + const deleteSecretsKeys = getSecretsRes + .filter((secret: FlyioSecret) => !(secret.name in secrets)) + .map((secret: FlyioSecret) => secret.name); + + // unset (delete) secrets + const DeleteSecrets = `mutation($input: UnsetSecretsInput!) { + unsetSecrets(input: $input) { + release { + id + version + reason + description + user { + id + email + name + } + evaluationId + createdAt + } + } + }`; + + await axios({ + method: 'post', + url: INTEGRATION_FLYIO_API_URL, + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + data: { + query: DeleteSecrets, + variables: { + input: { + appId: integration.app, + keys: deleteSecretsKeys + } + } + } + }); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Fly.io'); + } +} + export { syncSecrets }; \ No newline at end of file diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 6c1f9066ef..e8e0d29102 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,10 +1,12 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { IntegrationAuth } from '../models'; +import { IntegrationAuth, IWorkspace } from '../models'; import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; import { UnauthorizedRequestError } from '../utils/errors'; +type req = 'params' | 'body' | 'query'; + /** * Validate if user on request is a member of workspace with proper roles associated * with the integration authorization on request params. @@ -14,17 +16,21 @@ import { UnauthorizedRequestError } from '../utils/errors'; */ const requireIntegrationAuthorizationAuth = ({ acceptedRoles, - attachAccessToken = true + attachAccessToken = true, + location = 'params' }: { acceptedRoles: string[]; attachAccessToken?: boolean; + location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const { integrationAuthId } = req.params; + const { integrationAuthId } = req[location]; const integrationAuth = await IntegrationAuth.findOne({ _id: integrationAuthId - }).select( + }) + .populate<{ workspace: IWorkspace }>('workspace') + .select( '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' ); @@ -34,7 +40,7 @@ const requireIntegrationAuthorizationAuth = ({ await validateMembership({ userId: req.user._id.toString(), - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace._id.toString(), acceptedRoles }); diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 397fb48b31..01e1d7ee3f 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -3,7 +3,9 @@ import { INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, - INTEGRATION_GITHUB + INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO } from '../variables'; export interface IIntegration { @@ -12,20 +14,19 @@ export interface IIntegration { environment: string; isActive: boolean; app: string; - target: string; - context: string; - siteId: string; owner: string; - integration: 'heroku' | 'vercel' | 'netlify' | 'github'; + targetEnvironment: string; + appId: string; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio'; integrationAuth: Types.ObjectId; } const integrationSchema = new Schema( { workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace', - required: true + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true }, environment: { type: String, @@ -40,18 +41,13 @@ const integrationSchema = new Schema( type: String, default: null }, - target: { - // vercel-specific target (environment) - type: String, - default: null - }, - context: { - // netlify-specific context (deploy) + appId: { // (new) + // id of app in provider type: String, default: null }, - siteId: { - // netlify-specific site (app) id + targetEnvironment: { // (new) + // target environment type: String, default: null }, @@ -66,7 +62,9 @@ const integrationSchema = new Schema( INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, - INTEGRATION_GITHUB + INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO ], required: true }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 2314165888..bff56f09ac 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -9,7 +9,7 @@ import { export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -24,8 +24,9 @@ export interface IIntegrationAuth { const integrationAuthSchema = new Schema( { workspace: { - type: Schema.Types.ObjectId, - required: true + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true }, integration: { type: String, diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index b8604fc610..d587bd2e5c 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -3,12 +3,27 @@ const router = express.Router(); import { requireAuth, requireIntegrationAuth, + requireIntegrationAuthorizationAuth, validateRequest } from '../../middleware'; import { ADMIN, MEMBER } from '../../variables'; import { body, param } from 'express-validator'; import { integrationController } from '../../controllers/v1'; +router.post( // new: add new integration + '/', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + body('integrationAuthId').exists().trim(), + validateRequest, + integrationController.createIntegration +); + router.patch( '/:integrationId', requireAuth({ @@ -18,12 +33,11 @@ router.patch( acceptedRoles: [ADMIN, MEMBER] }), param('integrationId').exists().trim(), + body('isActive').exists().isBoolean(), body('app').exists().trim(), body('environment').exists().trim(), - body('isActive').exists().isBoolean(), - body('target').exists(), - body('context').exists(), - body('siteId').exists(), + body('appId').exists(), + body('targetEnvironment').exists(), body('owner').exists(), validateRequest, integrationController.updateIntegration diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 605c5023e8..3f88aa7e5d 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -34,6 +34,22 @@ router.post( integrationAuthController.oAuthExchange ); +router.post( + '/access-token', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + body('workspaceId').exists().trim().notEmpty(), + body('accessToken').exists().trim().notEmpty(), + body('integration').exists().trim().notEmpty(), + validateRequest, + integrationAuthController.saveIntegrationAccessToken +); + router.get( '/:integrationAuthId/apps', requireAuth({ diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 43746aee4a..4ee991cdde 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -122,7 +122,7 @@ class IntegrationService { * @param {Object} obj * @param {String} obj.integrationAuthId - id of integration auth * @param {String} obj.accessToken - access token - * @param {String} obj.accessExpiresAt - expiration date of access token + * @param {Date} obj.accessExpiresAt - expiration date of access token * @returns {IntegrationAuth} - updated integration auth */ static async setIntegrationAuthAccess({ @@ -132,7 +132,7 @@ class IntegrationService { }: { integrationAuthId: string; accessToken: string; - accessExpiresAt: Date; + accessExpiresAt: Date | undefined; }) { return await setIntegrationAuthAccessHelper({ integrationAuthId, diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 4f7ffd8b0f..dc1ce6f783 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -10,6 +10,8 @@ import { INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, @@ -19,6 +21,8 @@ import { INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, + INTEGRATION_RENDER_API_URL, + INTEGRATION_FLYIO_API_URL, INTEGRATION_OPTIONS } from './integration'; import { @@ -56,6 +60,8 @@ export { INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, @@ -65,6 +71,8 @@ export { INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, + INTEGRATION_RENDER_API_URL, + INTEGRATION_FLYIO_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_ADD_SECRETS, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index a1aa5d94d3..7cecb54c2c 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -10,11 +10,15 @@ const INTEGRATION_HEROKU = 'heroku'; const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_NETLIFY = 'netlify'; const INTEGRATION_GITHUB = 'github'; +const INTEGRATION_RENDER = 'render'; +const INTEGRATION_FLYIO = 'flyio'; const INTEGRATION_SET = new Set([ INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, - INTEGRATION_GITHUB + INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO ]); // integration types @@ -32,23 +36,25 @@ const INTEGRATION_GITHUB_TOKEN_URL = const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com'; +const INTEGRATION_RENDER_API_URL = 'https://api.render.com'; +const INTEGRATION_FLYIO_API_URL = 'https://api.fly.io/graphql'; const INTEGRATION_OPTIONS = [ { name: 'Heroku', slug: 'heroku', - image: 'Heroku', + image: 'Heroku.png', isAvailable: true, - type: 'oauth2', + type: 'oauth', clientId: CLIENT_ID_HEROKU, docsLink: '' }, { name: 'Vercel', slug: 'vercel', - image: 'Vercel', + image: 'Vercel.png', isAvailable: true, - type: 'vercel', + type: 'oauth', clientId: '', clientSlug: CLIENT_SLUG_VERCEL, docsLink: '' @@ -56,26 +62,43 @@ const INTEGRATION_OPTIONS = [ { name: 'Netlify', slug: 'netlify', - image: 'Netlify', + image: 'Netlify.png', isAvailable: true, - type: 'oauth2', + type: 'oauth', clientId: CLIENT_ID_NETLIFY, docsLink: '' }, { name: 'GitHub', slug: 'github', - image: 'GitHub', + image: 'GitHub.png', isAvailable: true, - type: 'oauth2', + type: 'oauth', clientId: CLIENT_ID_GITHUB, docsLink: '' - + }, + { + name: 'Render', + slug: 'render', + image: 'Render.png', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' + }, + { + name: 'Fly.io', + slug: 'flyio', + image: 'Flyio.svg', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' }, { name: 'Google Cloud Platform', slug: 'gcp', - image: 'Google Cloud Platform', + image: 'Google Cloud Platform.png', isAvailable: false, type: '', clientId: '', @@ -84,7 +107,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Amazon Web Services', slug: 'aws', - image: 'Amazon Web Services', + image: 'Amazon Web Services.png', isAvailable: false, type: '', clientId: '', @@ -93,7 +116,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Microsoft Azure', slug: 'azure', - image: 'Microsoft Azure', + image: 'Microsoft Azure.png', isAvailable: false, type: '', clientId: '', @@ -102,7 +125,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Travis CI', slug: 'travisci', - image: 'Travis CI', + image: 'Travis CI.png', isAvailable: false, type: '', clientId: '', @@ -111,7 +134,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Circle CI', slug: 'circleci', - image: 'Circle CI', + image: 'Circle CI.png', isAvailable: false, type: '', clientId: '', @@ -124,6 +147,8 @@ export { INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_HEROKU_TOKEN_URL, @@ -133,5 +158,7 @@ export { INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, + INTEGRATION_RENDER_API_URL, + INTEGRATION_FLYIO_API_URL, INTEGRATION_OPTIONS }; diff --git a/docs/getting-started/dashboard/integrations.mdx b/docs/getting-started/dashboard/integrations.mdx index ce2904e38a..3dc5a26569 100644 --- a/docs/getting-started/dashboard/integrations.mdx +++ b/docs/getting-started/dashboard/integrations.mdx @@ -10,4 +10,4 @@ We're still early with integrations, but expect more soon. View all available integrations and their guides -![integrations](../../images/project-integrations.png) +![integrations](../../images/integrations.png) diff --git a/docs/images/integrations-flyio-auth.png b/docs/images/integrations-flyio-auth.png new file mode 100644 index 0000000000..2db68e61ad Binary files /dev/null and b/docs/images/integrations-flyio-auth.png differ diff --git a/docs/images/integrations-flyio-dashboard.png b/docs/images/integrations-flyio-dashboard.png new file mode 100644 index 0000000000..e27315238d Binary files /dev/null and b/docs/images/integrations-flyio-dashboard.png differ diff --git a/docs/images/integrations-flyio-token.png b/docs/images/integrations-flyio-token.png new file mode 100644 index 0000000000..7766738e45 Binary files /dev/null and b/docs/images/integrations-flyio-token.png differ diff --git a/docs/images/integrations-flyio.png b/docs/images/integrations-flyio.png new file mode 100644 index 0000000000..cae945c292 Binary files /dev/null and b/docs/images/integrations-flyio.png differ diff --git a/docs/images/integrations-github.png b/docs/images/integrations-github.png index d34ea26907..dccc42c0db 100644 Binary files a/docs/images/integrations-github.png and b/docs/images/integrations-github.png differ diff --git a/docs/images/integrations-heroku.png b/docs/images/integrations-heroku.png index cc225f2860..42229627c9 100644 Binary files a/docs/images/integrations-heroku.png and b/docs/images/integrations-heroku.png differ diff --git a/docs/images/integrations-netlify.png b/docs/images/integrations-netlify.png index 60261043e3..7f808eeb48 100644 Binary files a/docs/images/integrations-netlify.png and b/docs/images/integrations-netlify.png differ diff --git a/docs/images/integrations-render-auth.png b/docs/images/integrations-render-auth.png new file mode 100644 index 0000000000..eb877f9bdf Binary files /dev/null and b/docs/images/integrations-render-auth.png differ diff --git a/docs/images/integrations-render-dashboard.png b/docs/images/integrations-render-dashboard.png new file mode 100644 index 0000000000..c6ce15d420 Binary files /dev/null and b/docs/images/integrations-render-dashboard.png differ diff --git a/docs/images/integrations-render-token.png b/docs/images/integrations-render-token.png new file mode 100644 index 0000000000..813735e9a0 Binary files /dev/null and b/docs/images/integrations-render-token.png differ diff --git a/docs/images/integrations-render.png b/docs/images/integrations-render.png new file mode 100644 index 0000000000..771ff95343 Binary files /dev/null and b/docs/images/integrations-render.png differ diff --git a/docs/images/integrations-vercel.png b/docs/images/integrations-vercel.png index f3a814c7a3..f769ae4d92 100644 Binary files a/docs/images/integrations-vercel.png and b/docs/images/integrations-vercel.png differ diff --git a/docs/images/integrations.png b/docs/images/integrations.png index 7359aa1981..556e6995a4 100644 Binary files a/docs/images/integrations.png and b/docs/images/integrations.png differ diff --git a/docs/integrations/cloud/flyio.mdx b/docs/integrations/cloud/flyio.mdx index b53a524041..73518be960 100644 --- a/docs/integrations/cloud/flyio.mdx +++ b/docs/integrations/cloud/flyio.mdx @@ -2,4 +2,34 @@ title: "Fly.io" --- -Coming soon. +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for Fly.io + +Obtain a Fly.io access token in Access Tokens + +![integrations fly dashboard](../../images/integrations-flyio-dashboard.png) +![integrations fly token](../../images/integrations-flyio-token.png) + +Press on the Fly.io tile and input your Fly.io access token to grant Infisical access to your Fly.io account. + +![integrations fly authorization](../../images/integrations-flyio-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to which Fly.io app and press start integration to start syncing secrets to Fly.io. + +![integrations fly](../../images/integrations-flyio.png) diff --git a/docs/integrations/cloud/render.mdx b/docs/integrations/cloud/render.mdx index 895bf01d17..612286098e 100644 --- a/docs/integrations/cloud/render.mdx +++ b/docs/integrations/cloud/render.mdx @@ -2,4 +2,34 @@ title: "Render" --- -Coming soon. +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your Render API Key + +Obtain a Render API Key in your Render Account Settings > API Keys. + +![integrations render dashboard](../../images/integrations-render-dashboard.png) +![integrations render token](../../images/integrations-render-token.png) + +Press on the Render tile and input your Render API Key to grant Infisical access to your Render account. + +![integrations render authorization](../../images/integrations-render-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to which Render service and press start integration to start syncing secrets to Render. + +![integrations heroku](../../images/integrations-render.png) diff --git a/docs/integrations/cloud/vercel.mdx b/docs/integrations/cloud/vercel.mdx index 59b416c446..bf5388976d 100644 --- a/docs/integrations/cloud/vercel.mdx +++ b/docs/integrations/cloud/vercel.mdx @@ -20,4 +20,4 @@ Press on the Vercel tile and grant Infisical access to your Vercel account. Select which Infisical environment secrets you want to sync to which Vercel app and environment. Lastly, press start integration to start syncing secrets to Vercel. -![integrations vercel](../../images/integrations-vercel.png) \ No newline at end of file +![integrations vercel](../../images/integrations-vercel.png) diff --git a/frontend/public/images/integrations/Flyio.svg b/frontend/public/images/integrations/Flyio.svg new file mode 100644 index 0000000000..0d0086b7ef --- /dev/null +++ b/frontend/public/images/integrations/Flyio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/images/integrations/Render.png b/frontend/public/images/integrations/Render.png new file mode 100644 index 0000000000..39103f5e46 Binary files /dev/null and b/frontend/public/images/integrations/Render.png differ diff --git a/frontend/src/components/basic/dialog/ActivateBotDialog.tsx b/frontend/src/components/basic/dialog/ActivateBotDialog.tsx index 6a5596b8c9..f2cb9cc2b0 100644 --- a/frontend/src/components/basic/dialog/ActivateBotDialog.tsx +++ b/frontend/src/components/basic/dialog/ActivateBotDialog.tsx @@ -4,12 +4,23 @@ import { Dialog, Transition } from '@headlessui/react'; import Button from '../buttons/Button'; +interface IntegrationOption { + clientId: string; + clientSlug?: string; // vercel-integration specific + docsLink: string; + image: string; + isAvailable: boolean; + name: string; + slug: string; + type: string; +} + type Props = { isOpen: boolean; closeModal: () => void; - selectedIntegrationOption: never[] | null; + selectedIntegrationOption: IntegrationOption | null; handleBotActivate: () => Promise; - handleIntegrationOption: (arg: { integrationOption: never[] }) => void; + integrationOptionPress: (integrationOption: IntegrationOption) => void; }; const ActivateBotDialog = ({ @@ -17,7 +28,7 @@ const ActivateBotDialog = ({ closeModal, selectedIntegrationOption, handleBotActivate, - handleIntegrationOption + integrationOptionPress }: Props) => { const { t } = useTranslation(); @@ -28,10 +39,10 @@ const ActivateBotDialog = ({ // type check if (!selectedIntegrationOption) return; - // 2. start integration - await handleIntegrationOption({ - integrationOption: selectedIntegrationOption - }); + + // 2. start integration or probe for PAT + integrationOptionPress(selectedIntegrationOption); + } catch (err) { console.log(err); } diff --git a/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx b/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx index 7c1fd3e94c..3a261ea4bc 100644 --- a/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx +++ b/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx @@ -1,45 +1,60 @@ -import { Fragment } from "react"; +import { Fragment, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; +import Button from "../buttons/Button"; import InputField from "../InputField"; +interface IntegrationOption { + clientId: string; + clientSlug?: string; // vercel-integration specific + docsLink: string; + image: string; + isAvailable: boolean; + name: string; + slug: string; + type: string; +} + type Props = { isOpen: boolean; closeModal: () => void; - selectedIntegrationOption: string; - handleBotActivate: () => void; - handleIntegrationOption: (arg:{integrationOption:string})=>void; + selectedIntegrationOption: IntegrationOption | null + handleIntegrationOption: (arg:{ + integrationOption: IntegrationOption, + accessToken?: string; +})=>void; }; const IntegrationAccessTokenDialog = ({ isOpen, closeModal, selectedIntegrationOption, - handleBotActivate, handleIntegrationOption }:Props) => { - + const [accessToken, setAccessToken] = useState(''); // eslint-disable-next-line @typescript-eslint/no-unused-vars const submit = async () => { try { - // 1. activate bot - await handleBotActivate(); - - // 2. start integration - await handleIntegrationOption({ - integrationOption: selectedIntegrationOption - }); + if (selectedIntegrationOption && accessToken !== '') { + handleIntegrationOption({ + integrationOption: selectedIntegrationOption, + accessToken + }); + closeModal(); + setAccessToken(''); + } } catch (err) { console.log(err); } - - closeModal(); } return (
- + { + console.log('onClose'); + closeModal(); + }}> - Grant Infisical access to your secrets + {`Enter your ${selectedIntegrationOption?.name} API Key`}

- Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over. + {`This integration requires you to obtain an API key from ${selectedIntegrationOption?.name ?? ''} and store it with Infisical.`}

- {/*
diff --git a/frontend/src/components/integrations/CloudIntegration.tsx b/frontend/src/components/integrations/CloudIntegration.tsx index 4828ed52ba..cc06bdd8ea 100644 --- a/frontend/src/components/integrations/CloudIntegration.tsx +++ b/frontend/src/components/integrations/CloudIntegration.tsx @@ -1,38 +1,42 @@ import Image from 'next/image'; -import { useRouter } from 'next/router'; import { faCheck, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import deleteIntegrationAuth from '../../pages/api/integrations/DeleteIntegrationAuth'; -interface CloudIntegrationOption { - isAvailable: boolean; - name: string; - type: string; +interface IntegrationOption { clientId: string; + clientSlug?: string; // vercel-integration specific docsLink: string; + image: string; + isAvailable: boolean; + name: string; slug: string; + type: string; } - interface IntegrationAuth { _id: string; integration: string; + workspace: string; + createdAt: string; + updatedAt: string; } interface Props { - cloudIntegrationOption: CloudIntegrationOption; - setSelectedIntegrationOption: (cloudIntegration: CloudIntegrationOption) => void; - integrationOptionPress: (cloudIntegrationOption: CloudIntegrationOption) => void; + cloudIntegrationOption: IntegrationOption; + setSelectedIntegrationOption: (cloudIntegration: IntegrationOption) => void; + integrationOptionPress: (cloudIntegrationOption: IntegrationOption) => void; integrationAuths: IntegrationAuth[]; + handleDeleteIntegrationAuth: (args: { integrationAuth: IntegrationAuth }) => void; } const CloudIntegration = ({ cloudIntegrationOption, setSelectedIntegrationOption, integrationOptionPress, - integrationAuths + integrationAuths, + handleDeleteIntegrationAuth }: Props) => { - const router = useRouter(); return integrationAuths ? (
null} @@ -51,7 +55,7 @@ const CloudIntegration = ({ key={cloudIntegrationOption.name} > integration logo authorization.integration) - .includes(cloudIntegrationOption.name.toLowerCase()) && ( + .includes(cloudIntegrationOption.slug) && (
null} role="button" tabIndex={0} - onClick={(event) => { + onClick={async (event) => { event.stopPropagation(); - deleteIntegrationAuth({ + const deletedIntegrationAuth = await deleteIntegrationAuth({ integrationAuthId: integrationAuths .filter( (authorization) => - authorization.integration === cloudIntegrationOption.name.toLowerCase() + authorization.integration === cloudIntegrationOption.slug ) .map((authorization) => authorization._id)[0] }); - router.reload(); + handleDeleteIntegrationAuth({ + integrationAuth: deletedIntegrationAuth + }); }} className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" > diff --git a/frontend/src/components/integrations/CloudIntegrationSection.tsx b/frontend/src/components/integrations/CloudIntegrationSection.tsx index 832f5e87da..aadae1308d 100644 --- a/frontend/src/components/integrations/CloudIntegrationSection.tsx +++ b/frontend/src/components/integrations/CloudIntegrationSection.tsx @@ -2,20 +2,31 @@ import { useTranslation } from 'next-i18next'; import CloudIntegration from './CloudIntegration'; -interface CloudIntegrationOption { - isAvailable: boolean; - name: string; - type: string; +interface IntegrationOption { clientId: string; + clientSlug?: string; // vercel-integration specific docsLink: string; + image: string; + isAvailable: boolean; + name: string; slug: string; + type: string; +} + +interface IntegrationAuth { + _id: string; + integration: string; + workspace: string; + createdAt: string; + updatedAt: string; } interface Props { - cloudIntegrationOptions: CloudIntegrationOption[]; + cloudIntegrationOptions: IntegrationOption[]; setSelectedIntegrationOption: () => void; - integrationOptionPress: () => void; - integrationAuths: any; + integrationOptionPress: (integrationOption: IntegrationOption) => void; + integrationAuths: IntegrationAuth[]; + handleDeleteIntegrationAuth: (args: { integrationAuth: IntegrationAuth }) => void; } const CloudIntegrationSection = ({ @@ -23,6 +34,7 @@ const CloudIntegrationSection = ({ setSelectedIntegrationOption, integrationOptionPress, integrationAuths, + handleDeleteIntegrationAuth }: Props) => { const { t } = useTranslation(); @@ -45,6 +57,7 @@ const CloudIntegrationSection = ({ setSelectedIntegrationOption={setSelectedIntegrationOption} integrationOptionPress={integrationOptionPress} integrationAuths={integrationAuths} + handleDeleteIntegrationAuth={handleDeleteIntegrationAuth} key={`cloud-integration-${cloudIntegrationOption.slug}`} /> ))} diff --git a/frontend/src/components/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index acba9fdf41..abec74f9d4 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -13,39 +13,44 @@ import deleteIntegration from '../../pages/api/integrations/DeleteIntegration'; import getIntegrationApps from '../../pages/api/integrations/GetIntegrationApps'; import updateIntegration from '../../pages/api/integrations/updateIntegration'; -interface TIntegration { +interface Integration { _id: string; - app?: string; - target?: string; + isActive: boolean; + app: string | null; + appId: string | null; + createdAt: string; + updatedAt: string; environment: string; integration: string; + targetEnvironment: string; + workspace: string; integrationAuth: string; - isActive: boolean; - context: string; } interface IntegrationApp { name: string; - siteId?: string; + appId?: string; owner?: string; } type Props = { - integration: TIntegration; - integrations: TIntegration[]; + integration: Integration; + integrations: Integration[]; setIntegrations: any; bot: any; setBot: any; environments: Array<{ name: string; slug: string }>; + handleDeleteIntegration: (args: { integration: Integration }) => void; }; -const Integration = ({ +const IntegrationTile = ({ integration, integrations, bot, setBot, setIntegrations, - environments = [] + environments = [], + handleDeleteIntegration }: Props) => { // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( @@ -57,8 +62,7 @@ const Integration = ({ const router = useRouter(); const [apps, setApps] = useState([]); // integration app objects const [integrationApp, setIntegrationApp] = useState(''); // integration app name - const [integrationTarget, setIntegrationTarget] = useState(''); // vercel-specific integration param - const [integrationContext, setIntegrationContext] = useState(''); // netlify-specific integration param + const [integrationTargetEnvironment, setIntegrationTargetEnvironment] = useState(''); useEffect(() => { const loadIntegration = async () => { @@ -66,21 +70,23 @@ const Integration = ({ const tempApps: [IntegrationApp] = await getIntegrationApps({ integrationAuthId: integration.integrationAuth }); - + setApps(tempApps); setIntegrationApp(integration.app ? integration.app : tempApps[0].name); switch (integration.integration) { case 'vercel': - setIntegrationTarget( - integration?.target - ? integration.target.charAt(0).toUpperCase() + integration.target.substring(1) - : 'Development' + setIntegrationTargetEnvironment( + integration?.targetEnvironment + ? integration.targetEnvironment.charAt(0).toUpperCase() + integration.targetEnvironment.substring(1) + : 'Development' ); break; case 'netlify': - setIntegrationContext( - integration?.context ? contextNetlifyMapping[integration.context] : 'Local development' + setIntegrationTargetEnvironment( + integration?.targetEnvironment + ? contextNetlifyMapping[integration.targetEnvironment] + : 'Local development' ); break; default: @@ -92,22 +98,30 @@ const Integration = ({ }, []); const handleStartIntegration = async () => { + const reformatTargetEnvironment = (targetEnvironment: string) => { + switch (integration.integration) { + case 'vercel': + return targetEnvironment.toLowerCase(); + case 'netlify': + return reverseContextNetlifyMapping[targetEnvironment]; + default: + return null; + } + } + try { const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined - const siteId = siteApp?.siteId ?? null; + const appId = siteApp?.appId ?? null; const owner = siteApp?.owner ?? null; // return updated integration const updatedIntegration = await updateIntegration({ integrationId: integration._id, environment: integrationEnvironment.slug, - app: integrationApp, isActive: true, - target: integrationTarget ? integrationTarget.toLowerCase() : null, - context: integrationContext - ? reverseContextNetlifyMapping[integrationContext] - : null, - siteId, + app: integrationApp, + appId, + targetEnvironment: reformatTargetEnvironment(integrationTargetEnvironment), owner }); @@ -119,30 +133,8 @@ const Integration = ({ } } - const handleDeleteIntegration = async () => { - try { - const deletedIntegration = await deleteIntegration({ - integrationId: integration._id - }); - - const newIntegrations = integrations.filter((i) => i._id !== deletedIntegration._id); - setIntegrations(newIntegrations); - - if (newIntegrations.length < 1) { - // case: no integrations left - setBot({ - ...bot, - isActive: false - }) - } - - } catch (err) { - console.error(err); - } - } - // eslint-disable-next-line @typescript-eslint/no-shadow - const renderIntegrationSpecificParams = (integration: TIntegration) => { + const renderIntegrationSpecificParams = (integration: Integration) => { try { switch (integration.integration) { case 'vercel': @@ -151,8 +143,8 @@ const Integration = ({
ENVIRONMENT
@@ -167,8 +159,8 @@ const Integration = ({ ? ['Production', 'Deploy previews', 'Branch deploys', 'Local development'] : null } - isSelected={integrationContext} - onChange={setIntegrationContext} + isSelected={integrationTargetEnvironment} + onChange={setIntegrationTargetEnvironment} />
); @@ -240,7 +232,9 @@ const Integration = ({ )}
) : (
); -export default ProjectIntegrationSection; +export default ProjectIntegrationSection; \ No newline at end of file diff --git a/frontend/src/pages/api/bot/setBotActiveStatus.ts b/frontend/src/pages/api/bot/setBotActiveStatus.ts index 857de01c73..0cb84081c9 100644 --- a/frontend/src/pages/api/bot/setBotActiveStatus.ts +++ b/frontend/src/pages/api/bot/setBotActiveStatus.ts @@ -8,7 +8,7 @@ interface BotKey { interface Props { botId: string; isActive: boolean; - botKey: BotKey; + botKey?: BotKey; } /** diff --git a/frontend/src/pages/api/integrations/DeleteIntegrationAuth.ts b/frontend/src/pages/api/integrations/DeleteIntegrationAuth.ts index ee4b75c0d6..091c615cfe 100644 --- a/frontend/src/pages/api/integrations/DeleteIntegrationAuth.ts +++ b/frontend/src/pages/api/integrations/DeleteIntegrationAuth.ts @@ -17,7 +17,7 @@ const deleteIntegrationAuth = ({ integrationAuthId }: Props) => } }).then(async (res) => { if (res && res.status === 200) { - return res; + return (await res.json()).integrationAuth; } console.log('Failed to delete an integration authorization'); return undefined; diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts new file mode 100644 index 0000000000..6b37f1281f --- /dev/null +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -0,0 +1,31 @@ +import SecurityClient from '@app/components/utilities/SecurityClient'; + +interface Props { + integrationAuthId: string; +} +/** + * This route creates a new integration based on the integration authorization with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.accessToken - id of integration authorization for which to create the integration + * @returns + */ +const createIntegration = ({ + integrationAuthId +}: Props) => + SecurityClient.fetchCall('/api/v1/integration', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + integrationAuthId + }) + }).then(async (res) => { + if (res && res.status === 200) { + return (await res.json()).integration; + } + console.log('Failed to create integration'); + return undefined; + }); + +export default createIntegration; \ No newline at end of file diff --git a/frontend/src/pages/api/integrations/saveIntegrationAccessToken.ts b/frontend/src/pages/api/integrations/saveIntegrationAccessToken.ts new file mode 100644 index 0000000000..184780dca6 --- /dev/null +++ b/frontend/src/pages/api/integrations/saveIntegrationAccessToken.ts @@ -0,0 +1,42 @@ +import SecurityClient from '@app/components/utilities/SecurityClient'; + +interface Props { + workspaceId: string | null; + integration: string | undefined; + accessToken: string; +} +/** + * This route creates a new integration authorization for integration [integration] + * that requires the user to input their access token manually (e.g. Render). It + * saves access token [accessToken] under that integration for workspace with id + * [workspaceId]. + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace to authorize integration for + * @param {String} obj.integration - integration + * @param {String} obj.accessToken - access token to save + * @returns + */ +const saveIntegrationAccessToken = ({ + workspaceId, + integration, + accessToken +}: Props) => + SecurityClient.fetchCall(`/api/v1/integration-auth/access-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + workspaceId, + integration, + accessToken + }) + }).then(async (res) => { + if (res && res.status === 200) { + return (await res.json()).integrationAuth; + } + console.log('Failed to save integration access token'); + return undefined; + }); + +export default saveIntegrationAccessToken; diff --git a/frontend/src/pages/api/integrations/updateIntegration.ts b/frontend/src/pages/api/integrations/updateIntegration.ts index ce0110a7e6..53f9441142 100644 --- a/frontend/src/pages/api/integrations/updateIntegration.ts +++ b/frontend/src/pages/api/integrations/updateIntegration.ts @@ -6,32 +6,29 @@ import SecurityClient from '@app/components/utilities/SecurityClient'; * [environment] to the integration [app] with active state [isActive] * @param {Object} obj * @param {String} obj.integrationId - id of integration - * @param {String} obj.app - name of app - * @param {String} obj.environment - project environment to push secrets from * @param {Boolean} obj.isActive - active state - * @param {String} obj.target - (optional) target (environment) for Vercel integration - * @param {String} obj.context - (optional) context (environment) for Netlify integration - * @param {String} obj.siteId - (optional) app (site_id) for Netlify integration + * @param {String} obj.environment - project environment to push secrets from + * @param {String} obj.app - name of app + * @param {String} obj.appId - (optional) app ID for integration + * @param {String} obj.targetEnvironment - target environment for integration * @param {String} obj.owner - (optional) owner login of repo for GitHub integration * @returns */ const updateIntegration = ({ integrationId, - app, - environment, isActive, - target, - context, - siteId, + environment, + app, + appId, + targetEnvironment, owner }: { integrationId: string; - app: string; - environment: string; isActive: boolean; - target: string | null; - context: string | null; - siteId: string | null; + environment: string; + app: string; + appId: string | null; + targetEnvironment: string | null; owner: string | null; }) => SecurityClient.fetchCall(`/api/v1/integration/${integrationId}`, { @@ -43,9 +40,8 @@ const updateIntegration = ({ app, environment, isActive, - target, - context, - siteId, + appId, + targetEnvironment, owner }) }).then(async (res) => { diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index 16e60677db..a1a4c4cd91 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'next-i18next'; import frameworkIntegrationOptions from 'public/json/frameworkIntegrations.json'; import ActivateBotDialog from '@app/components/basic/dialog/ActivateBotDialog'; +import IntegrationAccessTokenDialog from '@app/components/basic/dialog/IntegrationAccessTokenDialog'; import CloudIntegrationSection from '@app/components/integrations/CloudIntegrationSection'; import FrameworkIntegrationSection from '@app/components/integrations/FrameworkIntegrationSection'; import IntegrationSection from '@app/components/integrations/IntegrationSection'; @@ -19,27 +20,63 @@ import { } from '../../components/utilities/cryptography/crypto'; import getBot from '../api/bot/getBot'; import setBotActiveStatus from '../api/bot/setBotActiveStatus'; +import createIntegration from '../api/integrations/createIntegration'; +import deleteIntegration from '../api/integrations/DeleteIntegration'; import getIntegrationOptions from '../api/integrations/GetIntegrationOptions'; import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations'; import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations'; +import saveIntegrationAccessToken from '../api/integrations/saveIntegrationAccessToken'; import getAWorkspace from '../api/workspace/getAWorkspace'; import getLatestFileKey from '../api/workspace/getLatestFileKey'; +interface IntegrationAuth { + _id: string; + integration: string; + workspace: string; + createdAt: string; + updatedAt: string; +} + +interface Integration { + _id: string; + isActive: boolean; + app: string | null; + appId: string | null; + createdAt: string; + updatedAt: string; + environment: string; + integration: string; + targetEnvironment: string; + workspace: string; + integrationAuth: string; +} + +interface IntegrationOption { + clientId: string; + clientSlug?: string; // vercel-integration specific + docsLink: string; + image: string; + isAvailable: boolean; + name: string; + slug: string; + type: string; +} + export default function Integrations() { const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]); - const [integrationAuths, setIntegrationAuths] = useState([]); + const [integrationAuths, setIntegrationAuths] = useState([]); const [environments, setEnvironments] = useState< { name: string; slug: string; }[] >([]); - const [integrations, setIntegrations] = useState([]); + const [integrations, setIntegrations] = useState([]); // TODO: These will have its type when migratiing towards react-query const [bot, setBot] = useState(null); const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); - // const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); - const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); + const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(false); + const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); const router = useRouter(); const workspaceId = router.query.id as string; @@ -72,7 +109,7 @@ export default function Integrations() { // get project bot setBot(await getBot({ workspaceId })); } catch (err) { - console.log(err); + console.error(err); } })(); }, []); @@ -118,7 +155,7 @@ export default function Integrations() { ( await setBotActiveStatus({ botId: bot._id, - isActive: !bot.isActive, + isActive: true, botKey }) ).bot @@ -128,9 +165,9 @@ export default function Integrations() { console.error(err); } }; - + /** - * Start integration for a given integration option [integrationOption] + * Handle integration option authorization for a given integration option [integrationOption] * @param {Object} obj * @param {Object} obj.integrationOption - an integration option * @param {String} obj.name @@ -138,45 +175,68 @@ export default function Integrations() { * @param {String} obj.docsLink * @returns */ - const handleIntegrationOption = async ({ integrationOption }: { integrationOption: any }) => { + const handleIntegrationOption = async ({ + integrationOption, + accessToken + }: { + integrationOption: IntegrationOption, + accessToken?: string; + }) => { try { - // generate CSRF token for OAuth2 code-token exchange integrations - const state = crypto.randomBytes(16).toString('hex'); - localStorage.setItem('latestCSRFToken', state); - - switch (integrationOption.name) { - case 'Heroku': - window.location.assign( - `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}` - ); - break; - case 'Vercel': - window.location.assign( - `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}` - ); - break; - case 'Netlify': - window.location.assign( - `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify` - ); - break; - case 'GitHub': - window.location.assign( - `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}` - ); - break; - default: - break; - // case 'Fly.io': - // console.log('fly.io'); - // setIntegrationAccessTokenDialogOpen(true); - // break; + if (integrationOption.type === 'oauth') { + // integration is of type OAuth + + // generate CSRF token for OAuth2 code-token exchange integrations + const state = crypto.randomBytes(16).toString('hex'); + localStorage.setItem('latestCSRFToken', state); + + switch (integrationOption.slug) { + case 'heroku': + window.location.assign( + `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}` + ); + break; + case 'vercel': + window.location.assign( + `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}` + ); + break; + case 'netlify': + window.location.assign( + `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify` + ); + break; + case 'github': + window.location.assign( + `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}` + ); + break; + default: + break; + } + return; + } if (integrationOption.type === 'pat') { + // integration is of type personal access token + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: integrationOption.slug, + accessToken: accessToken ?? '' + }); + + setIntegrationAuths([...integrationAuths, integrationAuth]) + + const integration = await createIntegration({ + integrationAuthId: integrationAuth._id + }); + + setIntegrations([...integrations, integration]); + return; } } catch (err) { console.log(err); } }; - + /** * Open dialog to activate bot if bot is not active. * Otherwise, start integration [integrationOption] @@ -186,20 +246,94 @@ export default function Integrations() { * @param {String} integrationOption.docsLink * @returns */ - const integrationOptionPress = (integrationOption: any) => { + const integrationOptionPress = async (integrationOption: IntegrationOption) => { try { - if (bot.isActive) { - // case: bot is active -> proceed with integration + const integrationAuthX = integrationAuths.find((integrationAuth) => integrationAuth.integration === integrationOption.slug); + + if (!integrationAuthX) { + // case: integration has not been authorized before + + if (integrationOption.type === 'pat') { + // case: integration requires user to input their personal access token for that integration + setIntegrationAccessTokenDialogOpen(true); + return; + } + + // case: integration does not require user to input their personal access token (i.e. it's an OAuth2 integration) handleIntegrationOption({ integrationOption }); return; } - - // case: bot is not active -> open modal to activate bot - setIsActivateBotDialogOpen(true); + + // case: integration has been authorized before + // -> create new integration + const integration = await createIntegration({ + integrationAuthId: integrationAuthX._id + }); + + setIntegrations([...integrations, integration]); } catch (err) { console.error(err); } }; + + /** + * Handle deleting integration authorization [integrationAuth] and corresponding integrations from state where applicable + * @param {Object} obj + * @param {IntegrationAuth} obj.integrationAuth - integrationAuth to delete + */ + const handleDeleteIntegrationAuth = async ({ integrationAuth: deletedIntegrationAuth }: { integrationAuth: IntegrationAuth }) => { + try { + const newIntegrations = integrations.filter((integration) => integration.integrationAuth !== deletedIntegrationAuth._id); + setIntegrationAuths(integrationAuths.filter((integrationAuth) => integrationAuth._id !== deletedIntegrationAuth._id)); + setIntegrations(newIntegrations); + + // handle updating bot + if (newIntegrations.length < 1) { + // case: no integrations left + setBot( + ( + await setBotActiveStatus({ + botId: bot._id, + isActive: false + }) + ).bot + ); + } + } catch (err) { + console.error(err); + } + } + + /** + * Handle deleting integration [integration] + * @param {Object} obj + * @param {Integration} obj.integration - integration to delete + */ + const handleDeleteIntegration = async ({ integration }: { integration: Integration }) => { + try { + const deletedIntegration = await deleteIntegration({ + integrationId: integration._id + }); + + const newIntegrations = integrations.filter((i) => i._id !== deletedIntegration._id); + setIntegrations(newIntegrations); + + // handle updating bot + if (newIntegrations.length < 1) { + // case: no integrations left + setBot( + ( + await setBotActiveStatus({ + botId: bot._id, + isActive: false + }) + ).bot + ); + } + } catch (err) { + console.error(err); + } + } return (
@@ -217,28 +351,37 @@ export default function Integrations() { closeModal={() => setIsActivateBotDialogOpen(false)} selectedIntegrationOption={selectedIntegrationOption} handleBotActivate={handleBotActivate} - handleIntegrationOption={handleIntegrationOption} + integrationOptionPress={integrationOptionPress} /> - {/* setIntegrationAccessTokenDialogOpen(false)} selectedIntegrationOption={selectedIntegrationOption} - handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} - /> */} + + /> {cloudIntegrationOptions.length > 0 && bot ? ( { + if (!bot.isActive) { + // case: bot is not active -> open modal to activate bot + setIsActivateBotDialogOpen(true); + return; + } + integrationOptionPress(integrationOption) + }} integrationAuths={integrationAuths} + handleDeleteIntegrationAuth={handleDeleteIntegrationAuth} /> ) : (