Add Render, Fly.io integrations and reduce integrations page reloads

This commit is contained in:
Tuan Dang
2023-01-22 00:09:37 +07:00
parent 06803519e6
commit 0c351c0925
45 changed files with 1096 additions and 350 deletions
@@ -1,8 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Types } from 'mongoose';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import axios from 'axios'; import {
import { readFileSync } from 'fs'; Integration,
import { IntegrationAuth, Integration } from '../../models'; IntegrationAuth,
Bot
} from '../../models';
import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables'; import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables';
import { IntegrationService } from '../../services'; import { IntegrationService } from '../../services';
import { getApps, revokeAccess } from '../../integrations'; import { getApps, revokeAccess } from '../../integrations';
@@ -44,7 +47,7 @@ export const oAuthExchange = async (
environment: environments[0].slug, environment: environments[0].slug,
}); });
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser({ email: req.user.email });
Sentry.captureException(err); Sentry.captureException(err);
return res.status(400).send({ return res.status(400).send({
message: 'Failed to get OAuth2 code-token exchange' 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] * Return list of applications allowed for integration with integration authorization id [integrationAuthId]
* @param req * @param req
@@ -70,7 +134,7 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => {
accessToken: req.accessToken accessToken: req.accessToken
}); });
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser({ email: req.user.email });
Sentry.captureException(err); Sentry.captureException(err);
return res.status(400).send({ return res.status(400).send({
message: 'Failed to get integration authorization applications' message: 'Failed to get integration authorization applications'
@@ -89,15 +153,14 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => {
* @returns * @returns
*/ */
export const deleteIntegrationAuth = async (req: Request, res: Response) => { export const deleteIntegrationAuth = async (req: Request, res: Response) => {
let integrationAuth;
try { try {
const { integrationAuthId } = req.params; integrationAuth = await revokeAccess({
await revokeAccess({
integrationAuth: req.integrationAuth, integrationAuth: req.integrationAuth,
accessToken: req.accessToken accessToken: req.accessToken
}); });
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser({ email: req.user.email });
Sentry.captureException(err); Sentry.captureException(err);
return res.status(400).send({ return res.status(400).send({
message: 'Failed to delete integration authorization' message: 'Failed to delete integration authorization'
@@ -105,6 +168,6 @@ export const deleteIntegrationAuth = async (req: Request, res: Response) => {
} }
return res.status(200).send({ return res.status(200).send({
message: 'Successfully deleted integration authorization' integrationAuth
}); });
} }
@@ -1,25 +1,43 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Integration, Bot, BotKey } from '../../models'; import {
Integration,
Workspace,
Bot,
BotKey
} from '../../models';
import { EventService } from '../../services'; import { EventService } from '../../services';
import { eventPushSecrets } from '../../events'; import { eventPushSecrets } from '../../events';
interface Key { /**
encryptedKey: string; * Create/initialize an (empty) integration for integration authorization
nonce: string; * @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 { return res.status(200).send({
ciphertextKey: string; integration
ivKey: string; });
tagKey: string;
hashKey: string;
ciphertextValue: string;
ivValue: string;
tagValue: string;
hashValue: string;
type: 'shared' | 'personal';
} }
/** /**
@@ -36,13 +54,12 @@ export const updateIntegration = async (req: Request, res: Response) => {
try { try {
const { const {
app,
environment, environment,
isActive, isActive,
target, // vercel-specific integration param app,
context, // netlify-specific integration param appId,
siteId, // netlify-specific integration param targetEnvironment,
owner // github-specific integration param owner, // github-specific integration param
} = req.body; } = req.body;
integration = await Integration.findOneAndUpdate( integration = await Integration.findOneAndUpdate(
@@ -53,9 +70,8 @@ export const updateIntegration = async (req: Request, res: Response) => {
environment, environment,
isActive, isActive,
app, app,
target, appId,
context, targetEnvironment,
siteId,
owner owner
}, },
{ {
@@ -101,27 +117,6 @@ export const deleteIntegration = async (req: Request, res: Response) => {
}); });
if (!integration) throw new Error('Failed to find integration'); 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) { } catch (err) {
Sentry.setUser({ email: req.user.email }); Sentry.setUser({ email: req.user.email });
Sentry.captureException(err); Sentry.captureException(err);
+1 -2
View File
@@ -127,7 +127,6 @@ const syncIntegrationsHelper = async ({
}) => { }) => {
let integrations; let integrations;
try { try {
integrations = await Integration.find({ integrations = await Integration.find({
workspace: workspaceId, workspace: workspaceId,
isActive: true, isActive: true,
@@ -316,7 +315,7 @@ const setIntegrationAuthAccessHelper = async ({
}: { }: {
integrationAuthId: string; integrationAuthId: string;
accessToken: string; accessToken: string;
accessExpiresAt: Date; accessExpiresAt: Date | undefined;
}) => { }) => {
let integrationAuth; let integrationAuth;
try { try {
+112 -13
View File
@@ -7,9 +7,13 @@ import {
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL
} from '../variables'; } from '../variables';
/** /**
@@ -29,10 +33,11 @@ const getApps = async ({
}) => { }) => {
interface App { interface App {
name: string; name: string;
siteId?: string; appId?: string;
owner?: string;
} }
let apps: App[]; // TODO: add type and define payloads for apps let apps: App[];
try { try {
switch (integrationAuth.integration) { switch (integrationAuth.integration) {
case INTEGRATION_HEROKU: case INTEGRATION_HEROKU:
@@ -48,13 +53,21 @@ const getApps = async ({
break; break;
case INTEGRATION_NETLIFY: case INTEGRATION_NETLIFY:
apps = await getAppsNetlify({ apps = await getAppsNetlify({
integrationAuth,
accessToken accessToken
}); });
break; break;
case INTEGRATION_GITHUB: case INTEGRATION_GITHUB:
apps = await getAppsGithub({ apps = await getAppsGithub({
integrationAuth, accessToken
});
break;
case INTEGRATION_RENDER:
apps = await getAppsRender({
accessToken
});
break;
case INTEGRATION_FLYIO:
apps = await getAppsFlyio({
accessToken accessToken
}); });
break; 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 {Object} obj
* @param {String} obj.accessToken - access token for Heroku API * @param {String} obj.accessToken - access token for Heroku API
* @returns {Object[]} apps - names of Heroku apps * @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 {Object} obj
* @param {String} obj.accessToken - access token for Netlify API * @param {String} obj.accessToken - access token for Netlify API
* @returns {Object[]} apps - names of Netlify sites * @returns {Object[]} apps - names of Netlify sites
* @returns {String} apps.name - name of Netlify site * @returns {String} apps.name - name of Netlify site
*/ */
const getAppsNetlify = async ({ const getAppsNetlify = async ({
integrationAuth,
accessToken accessToken
}: { }: {
integrationAuth: IIntegrationAuth;
accessToken: string; accessToken: string;
}) => { }) => {
let apps; let apps;
@@ -166,7 +177,7 @@ const getAppsNetlify = async ({
apps = res.map((a: any) => ({ apps = res.map((a: any) => ({
name: a.name, name: a.name,
siteId: a.site_id appId: a.site_id
})); }));
} catch (err) { } catch (err) {
Sentry.setUser(null); 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 {Object} obj
* @param {String} obj.accessToken - access token for Netlify API * @param {String} obj.accessToken - access token for Netlify API
* @returns {Object[]} apps - names of Netlify sites * @returns {Object[]} apps - names of Netlify sites
* @returns {String} apps.name - name of Netlify site * @returns {String} apps.name - name of Netlify site
*/ */
const getAppsGithub = async ({ const getAppsGithub = async ({
integrationAuth,
accessToken accessToken
}: { }: {
integrationAuth: IIntegrationAuth;
accessToken: string; accessToken: string;
}) => { }) => {
let apps; let apps;
@@ -220,4 +229,94 @@ const getAppsGithub = async ({
return apps; 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 }; export { getApps };
+11 -3
View File
@@ -1,6 +1,11 @@
import axios from 'axios';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { IIntegrationAuth, IntegrationAuth, Integration } from '../models'; import {
IIntegrationAuth,
IntegrationAuth,
Integration,
Bot,
BotKey
} from '../models';
import { import {
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
@@ -15,6 +20,7 @@ const revokeAccess = async ({
integrationAuth: IIntegrationAuth; integrationAuth: IIntegrationAuth;
accessToken: string; accessToken: string;
}) => { }) => {
let deletedIntegrationAuth;
try { try {
// add any integration-specific revocation logic // add any integration-specific revocation logic
switch (integrationAuth.integration) { switch (integrationAuth.integration) {
@@ -28,7 +34,7 @@ const revokeAccess = async ({
break; break;
} }
const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
_id: integrationAuth._id _id: integrationAuth._id
}); });
@@ -42,6 +48,8 @@ const revokeAccess = async ({
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to delete integration authorization'); throw new Error('Failed to delete integration authorization');
} }
return deletedIntegrationAuth;
}; };
export { revokeAccess }; export { revokeAccess };
+211 -21
View File
@@ -1,4 +1,4 @@
import axios, { AxiosError } from 'axios'; import axios from 'axios';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
// import * as sodium from 'libsodium-wrappers'; // import * as sodium from 'libsodium-wrappers';
@@ -10,9 +10,13 @@ import {
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL
} from '../variables'; } from '../variables';
import { access, appendFile } from 'fs'; import { access, appendFile } from 'fs';
@@ -21,8 +25,6 @@ import { access, appendFile } from 'fs';
* @param {Object} obj * @param {Object} obj
* @param {IIntegration} obj.integration - integration details * @param {IIntegration} obj.integration - integration details
* @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 {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 * @param {String} obj.accessToken - access token for integration
*/ */
@@ -69,6 +71,20 @@ const syncSecrets = async ({
accessToken accessToken
}); });
break; break;
case INTEGRATION_RENDER:
await syncSecretsRender({
integration,
secrets,
accessToken
});
break;
case INTEGRATION_FLYIO:
await syncSecretsFlyio({
integration,
secrets,
accessToken
});
break;
} }
} catch (err) { } catch (err) {
Sentry.setUser(null); 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 {Object} obj
* @param {IIntegration} obj.integration - integration details * @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 {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 ({ const syncSecretsHeroku = async ({
integration, 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 {Object} obj
* @param {IIntegration} obj.integration - integration details * @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 {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 .data
.envs .envs
.filter((secret: VercelSecret) => secret.target.includes(integration.target)) .filter((secret: VercelSecret) => secret.target.includes(integration.targetEnvironment))
.map(async (secret: VercelSecret) => (await axios.get( .map(async (secret: VercelSecret) => (await axios.get(
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
{ {
@@ -201,7 +218,7 @@ const syncSecretsVercel = async ({
key: key, key: key,
value: secrets[key], value: secrets[key],
type: 'encrypted', type: 'encrypted',
target: [integration.target] target: [integration.targetEnvironment]
}); });
} }
}); });
@@ -216,7 +233,7 @@ const syncSecretsVercel = async ({
key: key, key: key,
value: secrets[key], value: secrets[key],
type: 'encrypted', type: 'encrypted',
target: [integration.target] target: [integration.targetEnvironment]
}); });
} }
} else { } else {
@@ -226,7 +243,7 @@ const syncSecretsVercel = async ({
key: key, key: key,
value: res[key].value, value: res[key].value,
type: 'encrypted', 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 {Object} obj
* @param {IIntegration} obj.integration - integration details * @param {IIntegration} obj.integration - integration details
* @param {IIntegrationAuth} obj.integrationAuth - integration auth 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.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 ({ const syncSecretsNetlify = async ({
integration, integration,
@@ -323,7 +341,7 @@ const syncSecretsNetlify = async ({
const getParams = new URLSearchParams({ const getParams = new URLSearchParams({
context_name: 'all', // integration.context or all context_name: 'all', // integration.context or all
site_id: integration.siteId site_id: integration.appId
}); });
const res = (await axios.get( const res = (await axios.get(
@@ -354,7 +372,7 @@ const syncSecretsNetlify = async ({
key, key,
values: [{ values: [{
value: secrets[key], value: secrets[key],
context: integration.context context: integration.targetEnvironment
}] }]
}); });
} else { } else {
@@ -365,15 +383,15 @@ const syncSecretsNetlify = async ({
[value.context]: value [value.context]: value
}), {}); }), {});
if (integration.context in contexts) { if (integration.targetEnvironment in contexts) {
// case: Netlify secret value exists in integration context // 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 // case: Infisical and Netlify secret values are different
// -> update Netlify secret context and value // -> update Netlify secret context and value
updateSecrets.push({ updateSecrets.push({
key, key,
values: [{ values: [{
context: integration.context, context: integration.targetEnvironment,
value: secrets[key] value: secrets[key]
}] }]
}); });
@@ -384,7 +402,7 @@ const syncSecretsNetlify = async ({
updateSecrets.push({ updateSecrets.push({
key, key,
values: [{ values: [{
context: integration.context, context: integration.targetEnvironment,
value: secrets[key] value: secrets[key]
}] }]
}); });
@@ -402,7 +420,7 @@ const syncSecretsNetlify = async ({
const numberOfValues = res[key].values.length; const numberOfValues = res[key].values.length;
res[key].values.forEach((value: NetlifyValue) => { res[key].values.forEach((value: NetlifyValue) => {
if (value.context === integration.context) { if (value.context === integration.targetEnvironment) {
if (numberOfValues <= 1) { if (numberOfValues <= 1) {
// case: Netlify secret value has less than 1 context -> delete secret // case: Netlify secret value has less than 1 context -> delete secret
deleteSecrets.push(key); deleteSecrets.push(key);
@@ -412,7 +430,7 @@ const syncSecretsNetlify = async ({
key, key,
values: [{ values: [{
id: value.id, id: value.id,
context: integration.context, context: integration.targetEnvironment,
value: value.value value: value.value
}] }]
}); });
@@ -423,7 +441,7 @@ const syncSecretsNetlify = async ({
}); });
const syncParams = new URLSearchParams({ const syncParams = new URLSearchParams({
site_id: integration.siteId site_id: integration.appId
}); });
if (newSecrets.length > 0) { 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 {Object} obj
* @param {IIntegration} obj.integration - integration details * @param {IIntegration} obj.integration - integration details
* @param {IIntegrationAuth} obj.integrationAuth - integration auth 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.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 ({ const syncSecretsGitHub = async ({
integration, 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 }; export { syncSecrets };
@@ -1,10 +1,12 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { IntegrationAuth } from '../models'; import { IntegrationAuth, IWorkspace } from '../models';
import { IntegrationService } from '../services'; import { IntegrationService } from '../services';
import { validateMembership } from '../helpers/membership'; import { validateMembership } from '../helpers/membership';
import { UnauthorizedRequestError } from '../utils/errors'; import { UnauthorizedRequestError } from '../utils/errors';
type req = 'params' | 'body' | 'query';
/** /**
* Validate if user on request is a member of workspace with proper roles associated * Validate if user on request is a member of workspace with proper roles associated
* with the integration authorization on request params. * with the integration authorization on request params.
@@ -14,17 +16,21 @@ import { UnauthorizedRequestError } from '../utils/errors';
*/ */
const requireIntegrationAuthorizationAuth = ({ const requireIntegrationAuthorizationAuth = ({
acceptedRoles, acceptedRoles,
attachAccessToken = true attachAccessToken = true,
location = 'params'
}: { }: {
acceptedRoles: string[]; acceptedRoles: string[];
attachAccessToken?: boolean; attachAccessToken?: boolean;
location?: req;
}) => { }) => {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const { integrationAuthId } = req.params; const { integrationAuthId } = req[location];
const integrationAuth = await IntegrationAuth.findOne({ const integrationAuth = await IntegrationAuth.findOne({
_id: integrationAuthId _id: integrationAuthId
}).select( })
.populate<{ workspace: IWorkspace }>('workspace')
.select(
'+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt'
); );
@@ -34,7 +40,7 @@ const requireIntegrationAuthorizationAuth = ({
await validateMembership({ await validateMembership({
userId: req.user._id.toString(), userId: req.user._id.toString(),
workspaceId: integrationAuth.workspace.toString(), workspaceId: integrationAuth.workspace._id.toString(),
acceptedRoles acceptedRoles
}); });
+16 -18
View File
@@ -3,7 +3,9 @@ import {
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO
} from '../variables'; } from '../variables';
export interface IIntegration { export interface IIntegration {
@@ -12,20 +14,19 @@ export interface IIntegration {
environment: string; environment: string;
isActive: boolean; isActive: boolean;
app: string; app: string;
target: string;
context: string;
siteId: string;
owner: string; owner: string;
integration: 'heroku' | 'vercel' | 'netlify' | 'github'; targetEnvironment: string;
appId: string;
integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio';
integrationAuth: Types.ObjectId; integrationAuth: Types.ObjectId;
} }
const integrationSchema = new Schema<IIntegration>( const integrationSchema = new Schema<IIntegration>(
{ {
workspace: { workspace: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Workspace', ref: 'Workspace',
required: true required: true
}, },
environment: { environment: {
type: String, type: String,
@@ -40,18 +41,13 @@ const integrationSchema = new Schema<IIntegration>(
type: String, type: String,
default: null default: null
}, },
target: { appId: { // (new)
// vercel-specific target (environment) // id of app in provider
type: String, type: String,
default: null default: null
}, },
context: { targetEnvironment: { // (new)
// netlify-specific context (deploy) // target environment
type: String,
default: null
},
siteId: {
// netlify-specific site (app) id
type: String, type: String,
default: null default: null
}, },
@@ -66,7 +62,9 @@ const integrationSchema = new Schema<IIntegration>(
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO
], ],
required: true required: true
}, },
+4 -3
View File
@@ -9,7 +9,7 @@ import {
export interface IIntegrationAuth { export interface IIntegrationAuth {
_id: Types.ObjectId; _id: Types.ObjectId;
workspace: Types.ObjectId; workspace: Types.ObjectId;
integration: 'heroku' | 'vercel' | 'netlify' | 'github'; integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio';
teamId: string; teamId: string;
accountId: string; accountId: string;
refreshCiphertext?: string; refreshCiphertext?: string;
@@ -24,8 +24,9 @@ export interface IIntegrationAuth {
const integrationAuthSchema = new Schema<IIntegrationAuth>( const integrationAuthSchema = new Schema<IIntegrationAuth>(
{ {
workspace: { workspace: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
required: true ref: 'Workspace',
required: true
}, },
integration: { integration: {
type: String, type: String,
+18 -4
View File
@@ -3,12 +3,27 @@ const router = express.Router();
import { import {
requireAuth, requireAuth,
requireIntegrationAuth, requireIntegrationAuth,
requireIntegrationAuthorizationAuth,
validateRequest validateRequest
} from '../../middleware'; } from '../../middleware';
import { ADMIN, MEMBER } from '../../variables'; import { ADMIN, MEMBER } from '../../variables';
import { body, param } from 'express-validator'; import { body, param } from 'express-validator';
import { integrationController } from '../../controllers/v1'; 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( router.patch(
'/:integrationId', '/:integrationId',
requireAuth({ requireAuth({
@@ -18,12 +33,11 @@ router.patch(
acceptedRoles: [ADMIN, MEMBER] acceptedRoles: [ADMIN, MEMBER]
}), }),
param('integrationId').exists().trim(), param('integrationId').exists().trim(),
body('isActive').exists().isBoolean(),
body('app').exists().trim(), body('app').exists().trim(),
body('environment').exists().trim(), body('environment').exists().trim(),
body('isActive').exists().isBoolean(), body('appId').exists(),
body('target').exists(), body('targetEnvironment').exists(),
body('context').exists(),
body('siteId').exists(),
body('owner').exists(), body('owner').exists(),
validateRequest, validateRequest,
integrationController.updateIntegration integrationController.updateIntegration
+16
View File
@@ -34,6 +34,22 @@ router.post(
integrationAuthController.oAuthExchange 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( router.get(
'/:integrationAuthId/apps', '/:integrationAuthId/apps',
requireAuth({ requireAuth({
+2 -2
View File
@@ -122,7 +122,7 @@ class IntegrationService {
* @param {Object} obj * @param {Object} obj
* @param {String} obj.integrationAuthId - id of integration auth * @param {String} obj.integrationAuthId - id of integration auth
* @param {String} obj.accessToken - access token * @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 * @returns {IntegrationAuth} - updated integration auth
*/ */
static async setIntegrationAuthAccess({ static async setIntegrationAuthAccess({
@@ -132,7 +132,7 @@ class IntegrationService {
}: { }: {
integrationAuthId: string; integrationAuthId: string;
accessToken: string; accessToken: string;
accessExpiresAt: Date; accessExpiresAt: Date | undefined;
}) { }) {
return await setIntegrationAuthAccessHelper({ return await setIntegrationAuthAccessHelper({
integrationAuthId, integrationAuthId,
+8
View File
@@ -10,6 +10,8 @@ import {
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL,
@@ -19,6 +21,8 @@ import {
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL,
INTEGRATION_OPTIONS INTEGRATION_OPTIONS
} from './integration'; } from './integration';
import { import {
@@ -56,6 +60,8 @@ export {
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL,
@@ -65,6 +71,8 @@ export {
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL,
EVENT_PUSH_SECRETS, EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS, EVENT_PULL_SECRETS,
ACTION_ADD_SECRETS, ACTION_ADD_SECRETS,
+42 -15
View File
@@ -10,11 +10,15 @@ const INTEGRATION_HEROKU = 'heroku';
const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_VERCEL = 'vercel';
const INTEGRATION_NETLIFY = 'netlify'; const INTEGRATION_NETLIFY = 'netlify';
const INTEGRATION_GITHUB = 'github'; const INTEGRATION_GITHUB = 'github';
const INTEGRATION_RENDER = 'render';
const INTEGRATION_FLYIO = 'flyio';
const INTEGRATION_SET = new Set([ const INTEGRATION_SET = new Set([
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO
]); ]);
// integration types // integration types
@@ -32,23 +36,25 @@ const INTEGRATION_GITHUB_TOKEN_URL =
const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com';
const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com';
const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.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 = [ const INTEGRATION_OPTIONS = [
{ {
name: 'Heroku', name: 'Heroku',
slug: 'heroku', slug: 'heroku',
image: 'Heroku', image: 'Heroku.png',
isAvailable: true, isAvailable: true,
type: 'oauth2', type: 'oauth',
clientId: CLIENT_ID_HEROKU, clientId: CLIENT_ID_HEROKU,
docsLink: '' docsLink: ''
}, },
{ {
name: 'Vercel', name: 'Vercel',
slug: 'vercel', slug: 'vercel',
image: 'Vercel', image: 'Vercel.png',
isAvailable: true, isAvailable: true,
type: 'vercel', type: 'oauth',
clientId: '', clientId: '',
clientSlug: CLIENT_SLUG_VERCEL, clientSlug: CLIENT_SLUG_VERCEL,
docsLink: '' docsLink: ''
@@ -56,26 +62,43 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Netlify', name: 'Netlify',
slug: 'netlify', slug: 'netlify',
image: 'Netlify', image: 'Netlify.png',
isAvailable: true, isAvailable: true,
type: 'oauth2', type: 'oauth',
clientId: CLIENT_ID_NETLIFY, clientId: CLIENT_ID_NETLIFY,
docsLink: '' docsLink: ''
}, },
{ {
name: 'GitHub', name: 'GitHub',
slug: 'github', slug: 'github',
image: 'GitHub', image: 'GitHub.png',
isAvailable: true, isAvailable: true,
type: 'oauth2', type: 'oauth',
clientId: CLIENT_ID_GITHUB, clientId: CLIENT_ID_GITHUB,
docsLink: '' 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', name: 'Google Cloud Platform',
slug: 'gcp', slug: 'gcp',
image: 'Google Cloud Platform', image: 'Google Cloud Platform.png',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -84,7 +107,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Amazon Web Services', name: 'Amazon Web Services',
slug: 'aws', slug: 'aws',
image: 'Amazon Web Services', image: 'Amazon Web Services.png',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -93,7 +116,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Microsoft Azure', name: 'Microsoft Azure',
slug: 'azure', slug: 'azure',
image: 'Microsoft Azure', image: 'Microsoft Azure.png',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -102,7 +125,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Travis CI', name: 'Travis CI',
slug: 'travisci', slug: 'travisci',
image: 'Travis CI', image: 'Travis CI.png',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -111,7 +134,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Circle CI', name: 'Circle CI',
slug: 'circleci', slug: 'circleci',
image: 'Circle CI', image: 'Circle CI.png',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -124,6 +147,8 @@ export {
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL,
@@ -133,5 +158,7 @@ export {
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL,
INTEGRATION_OPTIONS INTEGRATION_OPTIONS
}; };
@@ -10,4 +10,4 @@ We're still early with integrations, but expect more soon.
View all available integrations and their guides View all available integrations and their guides
</Card> </Card>
![integrations](../../images/project-integrations.png) ![integrations](../../images/integrations.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 418 KiB

+31 -1
View File
@@ -2,4 +2,34 @@
title: "Fly.io" 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)
<Info>
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.
</Info>
## 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)
+31 -1
View File
@@ -2,4 +2,34 @@
title: "Render" 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)
<Info>
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.
</Info>
## 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)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -4,12 +4,23 @@ import { Dialog, Transition } from '@headlessui/react';
import Button from '../buttons/Button'; 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 = { type Props = {
isOpen: boolean; isOpen: boolean;
closeModal: () => void; closeModal: () => void;
selectedIntegrationOption: never[] | null; selectedIntegrationOption: IntegrationOption | null;
handleBotActivate: () => Promise<void>; handleBotActivate: () => Promise<void>;
handleIntegrationOption: (arg: { integrationOption: never[] }) => void; integrationOptionPress: (integrationOption: IntegrationOption) => void;
}; };
const ActivateBotDialog = ({ const ActivateBotDialog = ({
@@ -17,7 +28,7 @@ const ActivateBotDialog = ({
closeModal, closeModal,
selectedIntegrationOption, selectedIntegrationOption,
handleBotActivate, handleBotActivate,
handleIntegrationOption integrationOptionPress
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -28,10 +39,10 @@ const ActivateBotDialog = ({
// type check // type check
if (!selectedIntegrationOption) return; if (!selectedIntegrationOption) return;
// 2. start integration
await handleIntegrationOption({ // 2. start integration or probe for PAT
integrationOption: selectedIntegrationOption integrationOptionPress(selectedIntegrationOption);
});
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
@@ -1,45 +1,60 @@
import { Fragment } from "react"; import { Fragment, useState } from "react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import Button from "../buttons/Button";
import InputField from "../InputField"; 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 = { type Props = {
isOpen: boolean; isOpen: boolean;
closeModal: () => void; closeModal: () => void;
selectedIntegrationOption: string; selectedIntegrationOption: IntegrationOption | null
handleBotActivate: () => void; handleIntegrationOption: (arg:{
handleIntegrationOption: (arg:{integrationOption:string})=>void; integrationOption: IntegrationOption,
accessToken?: string;
})=>void;
}; };
const IntegrationAccessTokenDialog = ({ const IntegrationAccessTokenDialog = ({
isOpen, isOpen,
closeModal, closeModal,
selectedIntegrationOption, selectedIntegrationOption,
handleBotActivate,
handleIntegrationOption handleIntegrationOption
}:Props) => { }:Props) => {
const [accessToken, setAccessToken] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const submit = async () => { const submit = async () => {
try { try {
// 1. activate bot if (selectedIntegrationOption && accessToken !== '') {
await handleBotActivate(); handleIntegrationOption({
integrationOption: selectedIntegrationOption,
// 2. start integration accessToken
await handleIntegrationOption({ });
integrationOption: selectedIntegrationOption closeModal();
}); setAccessToken('');
}
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
closeModal();
} }
return ( return (
<div> <div>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={() => {
console.log('onClose');
closeModal();
}}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@@ -67,28 +82,30 @@ const IntegrationAccessTokenDialog = ({
as="h3" as="h3"
className="text-lg font-medium leading-6 text-gray-400" className="text-lg font-medium leading-6 text-gray-400"
> >
Grant Infisical access to your secrets {`Enter your ${selectedIntegrationOption?.name} API Key`}
</Dialog.Title> </Dialog.Title>
<div className="mt-2 mb-2"> <div className="mt-2 mb-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
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.`}
</p> </p>
</div> </div>
<div className="mt-6 max-w-max"> <div className="mt-6 max-w-max">
{/* <Button
onButtonPressed={submit}
color="mineshaft"
text="Grant access"
size="md"
/> */}
<InputField <InputField
label="Access token" label="API Key"
onChangeHandler={() => {}} onChangeHandler={setAccessToken}
type="varName" type="varName"
value="Hello" value={accessToken}
placeholder="" placeholder=""
isRequired isRequired
/> />
<div className="mt-4">
<Button
onButtonPressed={submit}
color="mineshaft"
text="Connect"
size="md"
/>
</div>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
@@ -1,38 +1,42 @@
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router';
import { faCheck, faX } from '@fortawesome/free-solid-svg-icons'; import { faCheck, faX } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import deleteIntegrationAuth from '../../pages/api/integrations/DeleteIntegrationAuth'; import deleteIntegrationAuth from '../../pages/api/integrations/DeleteIntegrationAuth';
interface CloudIntegrationOption { interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean; isAvailable: boolean;
name: string; name: string;
type: string;
clientId: string;
docsLink: string;
slug: string; slug: string;
type: string;
} }
interface IntegrationAuth { interface IntegrationAuth {
_id: string; _id: string;
integration: string; integration: string;
workspace: string;
createdAt: string;
updatedAt: string;
} }
interface Props { interface Props {
cloudIntegrationOption: CloudIntegrationOption; cloudIntegrationOption: IntegrationOption;
setSelectedIntegrationOption: (cloudIntegration: CloudIntegrationOption) => void; setSelectedIntegrationOption: (cloudIntegration: IntegrationOption) => void;
integrationOptionPress: (cloudIntegrationOption: CloudIntegrationOption) => void; integrationOptionPress: (cloudIntegrationOption: IntegrationOption) => void;
integrationAuths: IntegrationAuth[]; integrationAuths: IntegrationAuth[];
handleDeleteIntegrationAuth: (args: { integrationAuth: IntegrationAuth }) => void;
} }
const CloudIntegration = ({ const CloudIntegration = ({
cloudIntegrationOption, cloudIntegrationOption,
setSelectedIntegrationOption, setSelectedIntegrationOption,
integrationOptionPress, integrationOptionPress,
integrationAuths integrationAuths,
handleDeleteIntegrationAuth
}: Props) => { }: Props) => {
const router = useRouter();
return integrationAuths ? ( return integrationAuths ? (
<div <div
onKeyDown={() => null} onKeyDown={() => null}
@@ -51,7 +55,7 @@ const CloudIntegration = ({
key={cloudIntegrationOption.name} key={cloudIntegrationOption.name}
> >
<Image <Image
src={`/images/integrations/${cloudIntegrationOption.name}.png`} src={`/images/integrations/${cloudIntegrationOption.image}`}
height={70} height={70}
width={70} width={70}
alt="integration logo" alt="integration logo"
@@ -71,24 +75,26 @@ const CloudIntegration = ({
{cloudIntegrationOption.isAvailable && {cloudIntegrationOption.isAvailable &&
integrationAuths integrationAuths
.map((authorization) => authorization.integration) .map((authorization) => authorization.integration)
.includes(cloudIntegrationOption.name.toLowerCase()) && ( .includes(cloudIntegrationOption.slug) && (
<div className="absolute group z-40 top-0 right-0 flex flex-row"> <div className="absolute group z-40 top-0 right-0 flex flex-row">
<div <div
onKeyDown={() => null} onKeyDown={() => null}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(event) => { onClick={async (event) => {
event.stopPropagation(); event.stopPropagation();
deleteIntegrationAuth({ const deletedIntegrationAuth = await deleteIntegrationAuth({
integrationAuthId: integrationAuths integrationAuthId: integrationAuths
.filter( .filter(
(authorization) => (authorization) =>
authorization.integration === cloudIntegrationOption.name.toLowerCase() authorization.integration === cloudIntegrationOption.slug
) )
.map((authorization) => authorization._id)[0] .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" 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"
> >
@@ -2,20 +2,31 @@ import { useTranslation } from 'next-i18next';
import CloudIntegration from './CloudIntegration'; import CloudIntegration from './CloudIntegration';
interface CloudIntegrationOption { interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean; isAvailable: boolean;
name: string; name: string;
type: string;
clientId: string;
docsLink: string;
slug: string; slug: string;
type: string;
}
interface IntegrationAuth {
_id: string;
integration: string;
workspace: string;
createdAt: string;
updatedAt: string;
} }
interface Props { interface Props {
cloudIntegrationOptions: CloudIntegrationOption[]; cloudIntegrationOptions: IntegrationOption[];
setSelectedIntegrationOption: () => void; setSelectedIntegrationOption: () => void;
integrationOptionPress: () => void; integrationOptionPress: (integrationOption: IntegrationOption) => void;
integrationAuths: any; integrationAuths: IntegrationAuth[];
handleDeleteIntegrationAuth: (args: { integrationAuth: IntegrationAuth }) => void;
} }
const CloudIntegrationSection = ({ const CloudIntegrationSection = ({
@@ -23,6 +34,7 @@ const CloudIntegrationSection = ({
setSelectedIntegrationOption, setSelectedIntegrationOption,
integrationOptionPress, integrationOptionPress,
integrationAuths, integrationAuths,
handleDeleteIntegrationAuth
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -45,6 +57,7 @@ const CloudIntegrationSection = ({
setSelectedIntegrationOption={setSelectedIntegrationOption} setSelectedIntegrationOption={setSelectedIntegrationOption}
integrationOptionPress={integrationOptionPress} integrationOptionPress={integrationOptionPress}
integrationAuths={integrationAuths} integrationAuths={integrationAuths}
handleDeleteIntegrationAuth={handleDeleteIntegrationAuth}
key={`cloud-integration-${cloudIntegrationOption.slug}`} key={`cloud-integration-${cloudIntegrationOption.slug}`}
/> />
))} ))}
@@ -13,39 +13,44 @@ import deleteIntegration from '../../pages/api/integrations/DeleteIntegration';
import getIntegrationApps from '../../pages/api/integrations/GetIntegrationApps'; import getIntegrationApps from '../../pages/api/integrations/GetIntegrationApps';
import updateIntegration from '../../pages/api/integrations/updateIntegration'; import updateIntegration from '../../pages/api/integrations/updateIntegration';
interface TIntegration { interface Integration {
_id: string; _id: string;
app?: string; isActive: boolean;
target?: string; app: string | null;
appId: string | null;
createdAt: string;
updatedAt: string;
environment: string; environment: string;
integration: string; integration: string;
targetEnvironment: string;
workspace: string;
integrationAuth: string; integrationAuth: string;
isActive: boolean;
context: string;
} }
interface IntegrationApp { interface IntegrationApp {
name: string; name: string;
siteId?: string; appId?: string;
owner?: string; owner?: string;
} }
type Props = { type Props = {
integration: TIntegration; integration: Integration;
integrations: TIntegration[]; integrations: Integration[];
setIntegrations: any; setIntegrations: any;
bot: any; bot: any;
setBot: any; setBot: any;
environments: Array<{ name: string; slug: string }>; environments: Array<{ name: string; slug: string }>;
handleDeleteIntegration: (args: { integration: Integration }) => void;
}; };
const Integration = ({ const IntegrationTile = ({
integration, integration,
integrations, integrations,
bot, bot,
setBot, setBot,
setIntegrations, setIntegrations,
environments = [] environments = [],
handleDeleteIntegration
}: Props) => { }: Props) => {
// set initial environment. This find will only execute when component is mounting // set initial environment. This find will only execute when component is mounting
const [integrationEnvironment, setIntegrationEnvironment] = useState<Props['environments'][0]>( const [integrationEnvironment, setIntegrationEnvironment] = useState<Props['environments'][0]>(
@@ -57,8 +62,7 @@ const Integration = ({
const router = useRouter(); const router = useRouter();
const [apps, setApps] = useState<IntegrationApp[]>([]); // integration app objects const [apps, setApps] = useState<IntegrationApp[]>([]); // integration app objects
const [integrationApp, setIntegrationApp] = useState(''); // integration app name const [integrationApp, setIntegrationApp] = useState(''); // integration app name
const [integrationTarget, setIntegrationTarget] = useState(''); // vercel-specific integration param const [integrationTargetEnvironment, setIntegrationTargetEnvironment] = useState('');
const [integrationContext, setIntegrationContext] = useState(''); // netlify-specific integration param
useEffect(() => { useEffect(() => {
const loadIntegration = async () => { const loadIntegration = async () => {
@@ -72,15 +76,17 @@ const Integration = ({
switch (integration.integration) { switch (integration.integration) {
case 'vercel': case 'vercel':
setIntegrationTarget( setIntegrationTargetEnvironment(
integration?.target integration?.targetEnvironment
? integration.target.charAt(0).toUpperCase() + integration.target.substring(1) ? integration.targetEnvironment.charAt(0).toUpperCase() + integration.targetEnvironment.substring(1)
: 'Development' : 'Development'
); );
break; break;
case 'netlify': case 'netlify':
setIntegrationContext( setIntegrationTargetEnvironment(
integration?.context ? contextNetlifyMapping[integration.context] : 'Local development' integration?.targetEnvironment
? contextNetlifyMapping[integration.targetEnvironment]
: 'Local development'
); );
break; break;
default: default:
@@ -92,22 +98,30 @@ const Integration = ({
}, []); }, []);
const handleStartIntegration = async () => { const handleStartIntegration = async () => {
const reformatTargetEnvironment = (targetEnvironment: string) => {
switch (integration.integration) {
case 'vercel':
return targetEnvironment.toLowerCase();
case 'netlify':
return reverseContextNetlifyMapping[targetEnvironment];
default:
return null;
}
}
try { try {
const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined 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; const owner = siteApp?.owner ?? null;
// return updated integration // return updated integration
const updatedIntegration = await updateIntegration({ const updatedIntegration = await updateIntegration({
integrationId: integration._id, integrationId: integration._id,
environment: integrationEnvironment.slug, environment: integrationEnvironment.slug,
app: integrationApp,
isActive: true, isActive: true,
target: integrationTarget ? integrationTarget.toLowerCase() : null, app: integrationApp,
context: integrationContext appId,
? reverseContextNetlifyMapping[integrationContext] targetEnvironment: reformatTargetEnvironment(integrationTargetEnvironment),
: null,
siteId,
owner 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 // eslint-disable-next-line @typescript-eslint/no-shadow
const renderIntegrationSpecificParams = (integration: TIntegration) => { const renderIntegrationSpecificParams = (integration: Integration) => {
try { try {
switch (integration.integration) { switch (integration.integration) {
case 'vercel': case 'vercel':
@@ -151,8 +143,8 @@ const Integration = ({
<div className="text-gray-400 text-xs font-semibold mb-2 w-60">ENVIRONMENT</div> <div className="text-gray-400 text-xs font-semibold mb-2 w-60">ENVIRONMENT</div>
<ListBox <ListBox
data={!integration.isActive ? ['Development', 'Preview', 'Production'] : null} data={!integration.isActive ? ['Development', 'Preview', 'Production'] : null}
isSelected={integrationTarget} isSelected={integrationTargetEnvironment}
onChange={setIntegrationTarget} onChange={setIntegrationTargetEnvironment}
isFull isFull
/> />
</div> </div>
@@ -167,8 +159,8 @@ const Integration = ({
? ['Production', 'Deploy previews', 'Branch deploys', 'Local development'] ? ['Production', 'Deploy previews', 'Branch deploys', 'Local development']
: null : null
} }
isSelected={integrationContext} isSelected={integrationTargetEnvironment}
onChange={setIntegrationContext} onChange={setIntegrationTargetEnvironment}
/> />
</div> </div>
); );
@@ -240,7 +232,9 @@ const Integration = ({
)} )}
<div className="opacity-50 hover:opacity-100 duration-200 ml-2"> <div className="opacity-50 hover:opacity-100 duration-200 ml-2">
<Button <Button
onButtonPressed={() => handleDeleteIntegration()} onButtonPressed={() => handleDeleteIntegration({
integration
})}
color="red" color="red"
size="icon-md" size="icon-md"
icon={faX} icon={faX}
@@ -251,4 +245,4 @@ const Integration = ({
); );
}; };
export default Integration; export default IntegrationTile;
@@ -1,6 +1,4 @@
import guidGenerator from '@app/components/utilities/randomId'; import IntegrationTile from './Integration';
import Integration from './Integration';
interface Props { interface Props {
integrations: any; integrations: any;
@@ -8,16 +6,21 @@ interface Props {
bot: any; bot: any;
setBot: any; setBot: any;
environments: Array<{ name: string; slug: string }>; environments: Array<{ name: string; slug: string }>;
handleDeleteIntegration: (args: { integration: Integration }) => void;
} }
interface IntegrationType { interface Integration {
_id: string; _id: string;
app?: string; isActive: boolean;
app: string | null;
appId: string | null;
createdAt: string;
updatedAt: string;
environment: string; environment: string;
integration: string; integration: string;
targetEnvironment: string;
workspace: string;
integrationAuth: string; integrationAuth: string;
isActive: boolean;
context: string;
} }
const ProjectIntegrationSection = ({ const ProjectIntegrationSection = ({
@@ -25,7 +28,8 @@ const ProjectIntegrationSection = ({
setIntegrations, setIntegrations,
bot, bot,
setBot, setBot,
environments = [] environments = [],
handleDeleteIntegration
}: Props) => }: Props) =>
integrations.length > 0 ? ( integrations.length > 0 ? (
<div className="mb-12"> <div className="mb-12">
@@ -35,17 +39,21 @@ const ProjectIntegrationSection = ({
Manage integrations with third-party services. Manage integrations with third-party services.
</p> </p>
</div> </div>
{integrations.map((integration: IntegrationType) => ( {integrations.map((integration: Integration) => {
<Integration console.log('IntegrationSection integration: ', integration);
key={guidGenerator()} return (
integration={integration} <IntegrationTile
integrations={integrations} key={`integration-${integration._id.toString()}`}
bot={bot} integration={integration}
setBot={setBot} integrations={integrations}
setIntegrations={setIntegrations} bot={bot}
environments={environments} setBot={setBot}
/> setIntegrations={setIntegrations}
))} environments={environments}
handleDeleteIntegration={handleDeleteIntegration}
/>
);
})}
</div> </div>
) : ( ) : (
<div /> <div />
@@ -8,7 +8,7 @@ interface BotKey {
interface Props { interface Props {
botId: string; botId: string;
isActive: boolean; isActive: boolean;
botKey: BotKey; botKey?: BotKey;
} }
/** /**
@@ -17,7 +17,7 @@ const deleteIntegrationAuth = ({ integrationAuthId }: Props) =>
} }
}).then(async (res) => { }).then(async (res) => {
if (res && res.status === 200) { if (res && res.status === 200) {
return res; return (await res.json()).integrationAuth;
} }
console.log('Failed to delete an integration authorization'); console.log('Failed to delete an integration authorization');
return undefined; return undefined;
@@ -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;
@@ -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;
@@ -6,32 +6,29 @@ import SecurityClient from '@app/components/utilities/SecurityClient';
* [environment] to the integration [app] with active state [isActive] * [environment] to the integration [app] with active state [isActive]
* @param {Object} obj * @param {Object} obj
* @param {String} obj.integrationId - id of integration * @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 {Boolean} obj.isActive - active state
* @param {String} obj.target - (optional) target (environment) for Vercel integration * @param {String} obj.environment - project environment to push secrets from
* @param {String} obj.context - (optional) context (environment) for Netlify integration * @param {String} obj.app - name of app
* @param {String} obj.siteId - (optional) app (site_id) for Netlify integration * @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 * @param {String} obj.owner - (optional) owner login of repo for GitHub integration
* @returns * @returns
*/ */
const updateIntegration = ({ const updateIntegration = ({
integrationId, integrationId,
app,
environment,
isActive, isActive,
target, environment,
context, app,
siteId, appId,
targetEnvironment,
owner owner
}: { }: {
integrationId: string; integrationId: string;
app: string;
environment: string;
isActive: boolean; isActive: boolean;
target: string | null; environment: string;
context: string | null; app: string;
siteId: string | null; appId: string | null;
targetEnvironment: string | null;
owner: string | null; owner: string | null;
}) => }) =>
SecurityClient.fetchCall(`/api/v1/integration/${integrationId}`, { SecurityClient.fetchCall(`/api/v1/integration/${integrationId}`, {
@@ -43,9 +40,8 @@ const updateIntegration = ({
app, app,
environment, environment,
isActive, isActive,
target, appId,
context, targetEnvironment,
siteId,
owner owner
}) })
}).then(async (res) => { }).then(async (res) => {
+191 -48
View File
@@ -7,6 +7,7 @@ import { useTranslation } from 'next-i18next';
import frameworkIntegrationOptions from 'public/json/frameworkIntegrations.json'; import frameworkIntegrationOptions from 'public/json/frameworkIntegrations.json';
import ActivateBotDialog from '@app/components/basic/dialog/ActivateBotDialog'; import ActivateBotDialog from '@app/components/basic/dialog/ActivateBotDialog';
import IntegrationAccessTokenDialog from '@app/components/basic/dialog/IntegrationAccessTokenDialog';
import CloudIntegrationSection from '@app/components/integrations/CloudIntegrationSection'; import CloudIntegrationSection from '@app/components/integrations/CloudIntegrationSection';
import FrameworkIntegrationSection from '@app/components/integrations/FrameworkIntegrationSection'; import FrameworkIntegrationSection from '@app/components/integrations/FrameworkIntegrationSection';
import IntegrationSection from '@app/components/integrations/IntegrationSection'; import IntegrationSection from '@app/components/integrations/IntegrationSection';
@@ -19,27 +20,63 @@ import {
} from '../../components/utilities/cryptography/crypto'; } from '../../components/utilities/cryptography/crypto';
import getBot from '../api/bot/getBot'; import getBot from '../api/bot/getBot';
import setBotActiveStatus from '../api/bot/setBotActiveStatus'; 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 getIntegrationOptions from '../api/integrations/GetIntegrationOptions';
import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations'; import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations';
import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations'; import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations';
import saveIntegrationAccessToken from '../api/integrations/saveIntegrationAccessToken';
import getAWorkspace from '../api/workspace/getAWorkspace'; import getAWorkspace from '../api/workspace/getAWorkspace';
import getLatestFileKey from '../api/workspace/getLatestFileKey'; 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() { export default function Integrations() {
const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]); const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]);
const [integrationAuths, setIntegrationAuths] = useState([]); const [integrationAuths, setIntegrationAuths] = useState<IntegrationAuth[]>([]);
const [environments, setEnvironments] = useState< const [environments, setEnvironments] = useState<
{ {
name: string; name: string;
slug: string; slug: string;
}[] }[]
>([]); >([]);
const [integrations, setIntegrations] = useState([]); const [integrations, setIntegrations] = useState<Integration[]>([]);
// TODO: These will have its type when migratiing towards react-query // TODO: These will have its type when migratiing towards react-query
const [bot, setBot] = useState<any>(null); const [bot, setBot] = useState<any>(null);
const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false);
// const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(false);
const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); const [selectedIntegrationOption, setSelectedIntegrationOption] = useState<IntegrationOption | null>(null);
const router = useRouter(); const router = useRouter();
const workspaceId = router.query.id as string; const workspaceId = router.query.id as string;
@@ -72,7 +109,7 @@ export default function Integrations() {
// get project bot // get project bot
setBot(await getBot({ workspaceId })); setBot(await getBot({ workspaceId }));
} catch (err) { } catch (err) {
console.log(err); console.error(err);
} }
})(); })();
}, []); }, []);
@@ -118,7 +155,7 @@ export default function Integrations() {
( (
await setBotActiveStatus({ await setBotActiveStatus({
botId: bot._id, botId: bot._id,
isActive: !bot.isActive, isActive: true,
botKey botKey
}) })
).bot ).bot
@@ -130,7 +167,7 @@ export default function Integrations() {
}; };
/** /**
* Start integration for a given integration option [integrationOption] * Handle integration option authorization for a given integration option [integrationOption]
* @param {Object} obj * @param {Object} obj
* @param {Object} obj.integrationOption - an integration option * @param {Object} obj.integrationOption - an integration option
* @param {String} obj.name * @param {String} obj.name
@@ -138,39 +175,62 @@ export default function Integrations() {
* @param {String} obj.docsLink * @param {String} obj.docsLink
* @returns * @returns
*/ */
const handleIntegrationOption = async ({ integrationOption }: { integrationOption: any }) => { const handleIntegrationOption = async ({
integrationOption,
accessToken
}: {
integrationOption: IntegrationOption,
accessToken?: string;
}) => {
try { try {
// generate CSRF token for OAuth2 code-token exchange integrations if (integrationOption.type === 'oauth') {
const state = crypto.randomBytes(16).toString('hex'); // integration is of type OAuth
localStorage.setItem('latestCSRFToken', state);
switch (integrationOption.name) { // generate CSRF token for OAuth2 code-token exchange integrations
case 'Heroku': const state = crypto.randomBytes(16).toString('hex');
window.location.assign( localStorage.setItem('latestCSRFToken', state);
`https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`
); switch (integrationOption.slug) {
break; case 'heroku':
case 'Vercel': window.location.assign(
window.location.assign( `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`
`https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}` );
); break;
break; case 'vercel':
case 'Netlify': window.location.assign(
window.location.assign( `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`
`https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify` );
); break;
break; case 'netlify':
case 'GitHub': window.location.assign(
window.location.assign( `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`
`https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}` );
); break;
break; case 'github':
default: window.location.assign(
break; `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}`
// case 'Fly.io': );
// console.log('fly.io'); break;
// setIntegrationAccessTokenDialogOpen(true); default:
// break; 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) { } catch (err) {
console.log(err); console.log(err);
@@ -186,21 +246,95 @@ export default function Integrations() {
* @param {String} integrationOption.docsLink * @param {String} integrationOption.docsLink
* @returns * @returns
*/ */
const integrationOptionPress = (integrationOption: any) => { const integrationOptionPress = async (integrationOption: IntegrationOption) => {
try { try {
if (bot.isActive) { const integrationAuthX = integrationAuths.find((integrationAuth) => integrationAuth.integration === integrationOption.slug);
// case: bot is active -> proceed with integration
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 }); handleIntegrationOption({ integrationOption });
return; return;
} }
// case: bot is not active -> open modal to activate bot // case: integration has been authorized before
setIsActivateBotDialogOpen(true); // -> create new integration
const integration = await createIntegration({
integrationAuthId: integrationAuthX._id
});
setIntegrations([...integrations, integration]);
} catch (err) { } catch (err) {
console.error(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 ( return (
<div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white"> <div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white">
<Head> <Head>
@@ -217,28 +351,37 @@ export default function Integrations() {
closeModal={() => setIsActivateBotDialogOpen(false)} closeModal={() => setIsActivateBotDialogOpen(false)}
selectedIntegrationOption={selectedIntegrationOption} selectedIntegrationOption={selectedIntegrationOption}
handleBotActivate={handleBotActivate} handleBotActivate={handleBotActivate}
handleIntegrationOption={handleIntegrationOption} integrationOptionPress={integrationOptionPress}
/> />
{/* <IntegrationAccessTokenDialog <IntegrationAccessTokenDialog
isOpen={isIntegrationAccessTokenDialogOpen} isOpen={isIntegrationAccessTokenDialogOpen}
closeModal={() => setIntegrationAccessTokenDialogOpen(false)} closeModal={() => setIntegrationAccessTokenDialogOpen(false)}
selectedIntegrationOption={selectedIntegrationOption} selectedIntegrationOption={selectedIntegrationOption}
handleBotActivate={handleBotActivate}
handleIntegrationOption={handleIntegrationOption} handleIntegrationOption={handleIntegrationOption}
/> */}
/>
<IntegrationSection <IntegrationSection
integrations={integrations} integrations={integrations}
setIntegrations={setIntegrations} setIntegrations={setIntegrations}
bot={bot} bot={bot}
setBot={setBot} setBot={setBot}
environments={environments} environments={environments}
handleDeleteIntegration={handleDeleteIntegration}
/> />
{cloudIntegrationOptions.length > 0 && bot ? ( {cloudIntegrationOptions.length > 0 && bot ? (
<CloudIntegrationSection <CloudIntegrationSection
cloudIntegrationOptions={cloudIntegrationOptions} cloudIntegrationOptions={cloudIntegrationOptions}
setSelectedIntegrationOption={setSelectedIntegrationOption as any} setSelectedIntegrationOption={setSelectedIntegrationOption as any}
integrationOptionPress={integrationOptionPress as any} integrationOptionPress={(integrationOption: IntegrationOption) => {
if (!bot.isActive) {
// case: bot is not active -> open modal to activate bot
setIsActivateBotDialogOpen(true);
return;
}
integrationOptionPress(integrationOption)
}}
integrationAuths={integrationAuths} integrationAuths={integrationAuths}
handleDeleteIntegrationAuth={handleDeleteIntegrationAuth}
/> />
) : ( ) : (
<div /> <div />