diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 5df8b4e91..b030b79d6 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 4f02836a1..4e66ce0aa 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 }, { @@ -92,36 +108,15 @@ export const updateIntegration = async (req: Request, res: Response) => { * @returns */ export const deleteIntegration = async (req: Request, res: Response) => { - let deletedIntegration; + let integration; try { const { integrationId } = req.params; - deletedIntegration = await Integration.findOneAndDelete({ + integration = await Integration.findOneAndDelete({ _id: integrationId }); - if (!deletedIntegration) throw new Error('Failed to find integration'); - - const integrations = await Integration.find({ - workspace: deletedIntegration.workspace - }); - - if (integrations.length === 0) { - // case: no integrations left, deactivate bot - const bot = await Bot.findOneAndUpdate({ - workspace: deletedIntegration.workspace - }, { - isActive: false - }, { - new: true - }); - - if (bot) { - await BotKey.deleteOne({ - bot: bot._id - }); - } - } + if (!integration) throw new Error('Failed to find integration'); } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); @@ -129,8 +124,8 @@ export const deleteIntegration = async (req: Request, res: Response) => { message: 'Failed to delete integration' }); } - + return res.status(200).send({ - deletedIntegration + integration }); }; diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index f602f1729..1618e5e67 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 79187c692..d1252954e 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 483486343..25ec70313 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 bfe2887c6..9954943b8 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 6c1f9066e..e8e0d2910 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 397fb48b3..01e1d7ee3 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) + appId: { // (new) + // id of app in provider type: String, default: null }, - context: { - // netlify-specific context (deploy) - 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 231416588..bff56f09a 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 b8604fc61..d587bd2e5 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 605c5023e..3f88aa7e5 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 43746aee4..4ee991cdd 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 4f7ffd8b0..dc1ce6f78 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 a1aa5d94d..7cecb54c2 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 ce2904e38..3dc5a2656 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 000000000..2db68e61a 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 000000000..e27315238 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 000000000..7766738e4 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 000000000..cae945c29 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 d34ea2690..dccc42c0d 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 cc225f286..42229627c 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 60261043e..7f808eeb4 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 000000000..eb877f9bd 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 000000000..c6ce15d42 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 000000000..813735e9a 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 000000000..771ff9534 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 f3a814c7a..f769ae4d9 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 7359aa198..556e6995a 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 b53a52404..73518be96 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 895bf01d1..612286098 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 59b416c44..bf5388976 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/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 0ccb9da26..c63e443d2 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -1,5 +1,5 @@ --- -title: 'Overview' +title: "Overview" --- Integrations allow environment variables to be synced from Infisical into your local development workflow, CI/CD pipelines, and production infrastructure. @@ -14,6 +14,8 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [Heroku](/integrations/cloud/heroku) | Cloud | Available | | [Vercel](/integrations/cloud/vercel) | Cloud | Available | | [Netlify](/integrations/cloud/netlify) | Cloud | Available | +| [Render](/integrations/cloud/render) | Cloud | Available | +| [Fly.io](/integrations/cloud/flyio) | Cloud | Available | | [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | @@ -29,8 +31,6 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [Flask](/integrations/frameworks/flask) | Framework | Available | | [Laravel](/integrations/frameworks/laravel) | Framework | Available | | [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | -| [Render](/integrations/cloud/render) | Cloud | Coming soon | -| [Fly.io](/integrations/cloud/flyio) | Cloud | Coming soon | | AWS | Cloud | Coming soon | | GCP | Cloud | Coming soon | | Azure | Cloud | Coming soon | diff --git a/docs/mint.json b/docs/mint.json index f61b37a62..4ce1d6c03 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -27,7 +27,7 @@ "http://localhost:8080" ], "auth": { - "method": "api-key", + "method": "key", "name": "X-API-KEY" } }, diff --git a/frontend/public/images/integrations/Flyio.svg b/frontend/public/images/integrations/Flyio.svg new file mode 100644 index 000000000..0d0086b7e --- /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 000000000..39103f5e4 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 6a5596b8c..f2cb9cc2b 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 7c1fd3e94..3a261ea4b 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 4828ed52b..cc06bdd8e 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 { +interface IntegrationOption { + clientId: string; + clientSlug?: string; // vercel-integration specific + docsLink: string; + image: string; isAvailable: boolean; name: string; - type: string; - clientId: string; - docsLink: 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 832f5e87d..aadae1308 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 { +interface IntegrationOption { + clientId: string; + clientSlug?: string; // vercel-integration specific + docsLink: string; + image: string; isAvailable: boolean; name: string; - type: string; - clientId: string; - docsLink: 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 fc034637c..abec74f9d 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -13,29 +13,45 @@ 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; + integration: Integration; + integrations: Integration[]; + setIntegrations: any; + bot: any; + setBot: any; environments: Array<{ name: string; slug: string }>; + handleDeleteIntegration: (args: { integration: Integration }) => void; }; -const Integration = ({ integration, environments = [] }: Props) => { +const IntegrationTile = ({ + integration, + integrations, + bot, + setBot, + setIntegrations, + environments = [], + handleDeleteIntegration +}: Props) => { // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( environments.find(({ slug }) => slug === integration.environment) || { @@ -46,8 +62,7 @@ const Integration = ({ integration, environments = [] }: Props) => { 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 () => { @@ -55,21 +70,23 @@ const Integration = ({ integration, environments = [] }: Props) => { 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: @@ -79,9 +96,45 @@ const Integration = ({ integration, environments = [] }: Props) => { loadIntegration(); }, []); + + 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 appId = siteApp?.appId ?? null; + const owner = siteApp?.owner ?? null; + + // return updated integration + const updatedIntegration = await updateIntegration({ + integrationId: integration._id, + environment: integrationEnvironment.slug, + isActive: true, + app: integrationApp, + appId, + targetEnvironment: reformatTargetEnvironment(integrationTargetEnvironment), + owner + }); + + setIntegrations( + integrations.map((i) => i._id === updatedIntegration._id ? updatedIntegration : i) + ); + } 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': @@ -90,8 +143,8 @@ const Integration = ({ integration, environments = [] }: Props) => {
ENVIRONMENT
@@ -106,8 +159,8 @@ const Integration = ({ integration, environments = [] }: Props) => { ? ['Production', 'Deploy previews', 'Branch deploys', 'Local development'] : null } - isSelected={integrationContext} - onChange={setIntegrationContext} + isSelected={integrationTargetEnvironment} + onChange={setIntegrationTargetEnvironment} />
); @@ -172,38 +225,16 @@ const Integration = ({ integration, environments = [] }: Props) => { ) : (