diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 4150890ac..11cbd6f64 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -35,14 +35,11 @@ export const getIntegrationAuth = async (req: Request, res: Response) => { }); } -export const getIntegrationOptions = async ( - req: Request, - res: Response -) => { - return res.status(200).send({ - integrationOptions: INTEGRATION_OPTIONS - }); -} +export const getIntegrationOptions = async (req: Request, res: Response) => { + return res.status(200).send({ + integrationOptions: INTEGRATION_OPTIONS, + }); +}; /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] @@ -90,8 +87,8 @@ export const oAuthExchange = async ( * @param res */ export const saveIntegrationAccessToken = async ( - req: Request, - res: Response + req: Request, + res: Response ) => { // TODO: refactor // TODO: check if access token is valid for each integration @@ -157,23 +154,23 @@ export const saveIntegrationAccessToken = async ( * @returns */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { - let apps; - try { - apps = await getApps({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get integration authorization applications' - }); - } + let apps; + try { + apps = await getApps({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get integration authorization applications", + }); + } - return res.status(200).send({ - apps - }); + return res.status(200).send({ + apps, + }); }; /** @@ -183,21 +180,21 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { * @returns */ export const deleteIntegrationAuth = async (req: Request, res: Response) => { - let integrationAuth; - try { - integrationAuth = await revokeAccess({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete integration authorization' - }); - } - - return res.status(200).send({ - integrationAuth - }); -} \ No newline at end of file + let integrationAuth; + try { + integrationAuth = await revokeAccess({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to delete integration authorization", + }); + } + + return res.status(200).send({ + integrationAuth, + }); +}; diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 621040aee..a17e4d7a6 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -12,9 +12,9 @@ import { eventPushSecrets } from '../../events'; /** * Create/initialize an (empty) integration for integration authorization - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const createIntegration = async (req: Request, res: Response) => { let integration; @@ -65,10 +65,10 @@ export const createIntegration = async (req: Request, res: Response) => { }); } - return res.status(200).send({ - integration - }); -} + return res.status(200).send({ + integration, + }); +}; /** * Change environment or name of integration with id [integrationId] @@ -77,57 +77,57 @@ export const createIntegration = async (req: Request, res: Response) => { * @returns */ export const updateIntegration = async (req: Request, res: Response) => { - let integration; - - // TODO: add integration-specific validation to ensure that each - // integration has the correct fields populated in [Integration] - - try { - const { - environment, - isActive, - app, - appId, - targetEnvironment, - owner, // github-specific integration param - } = req.body; - - integration = await Integration.findOneAndUpdate( - { - _id: req.integration._id - }, - { - environment, - isActive, - app, - appId, - targetEnvironment, - owner - }, - { - new: true - } - ); - - if (integration) { - // trigger event - push secrets - EventService.handleEvent({ - event: eventPushSecrets({ - workspaceId: integration.workspace.toString() - }) - }); - } - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to update integration' - }); - } + let integration; - return res.status(200).send({ - integration - }); + // TODO: add integration-specific validation to ensure that each + // integration has the correct fields populated in [Integration] + + try { + const { + environment, + isActive, + app, + appId, + targetEnvironment, + owner, // github-specific integration param + } = req.body; + + integration = await Integration.findOneAndUpdate( + { + _id: req.integration._id, + }, + { + environment, + isActive, + app, + appId, + targetEnvironment, + owner, + }, + { + new: true, + } + ); + + if (integration) { + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString(), + }), + }); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to update integration", + }); + } + + return res.status(200).send({ + integration, + }); }; /** @@ -138,24 +138,24 @@ export const updateIntegration = async (req: Request, res: Response) => { * @returns */ export const deleteIntegration = async (req: Request, res: Response) => { - let integration; - try { - const { integrationId } = req.params; + let integration; + try { + const { integrationId } = req.params; - integration = await Integration.findOneAndDelete({ - _id: integrationId - }); - - if (!integration) throw new Error('Failed to find integration'); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete integration' - }); - } - - return res.status(200).send({ - integration - }); + integration = await Integration.findOneAndDelete({ + _id: integrationId, + }); + + if (!integration) throw new Error("Failed to find integration"); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to delete integration", + }); + } + + return res.status(200).send({ + integration, + }); }; diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index 8675fc2b6..2b0a89f43 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -1,21 +1,21 @@ -import { Request, Response } from 'express'; -import * as Sentry from '@sentry/node'; +import { Request, Response } from "express"; +import * as Sentry from "@sentry/node"; import { - Workspace, - Membership, - MembershipOrg, - Integration, - IntegrationAuth, - IUser, - ServiceToken, - ServiceTokenData -} from '../../models'; + Workspace, + Membership, + MembershipOrg, + Integration, + IntegrationAuth, + IUser, + ServiceToken, + ServiceTokenData, +} from "../../models"; import { - createWorkspace as create, - deleteWorkspace as deleteWork -} from '../../helpers/workspace'; -import { addMemberships } from '../../helpers/membership'; -import { ADMIN } from '../../variables'; + createWorkspace as create, + deleteWorkspace as deleteWork, +} from "../../helpers/workspace"; +import { addMemberships } from "../../helpers/membership"; +import { ADMIN } from "../../variables"; /** * Return public keys of members of workspace with id [workspaceId] @@ -24,32 +24,31 @@ import { ADMIN } from '../../variables'; * @returns */ export const getWorkspacePublicKeys = async (req: Request, res: Response) => { - let publicKeys; - try { - const { workspaceId } = req.params; + let publicKeys; + try { + const { workspaceId } = req.params; - publicKeys = ( - await Membership.find({ - workspace: workspaceId - }).populate<{ user: IUser }>('user', 'publicKey') - ) - .map((member) => { - return { - publicKey: member.user.publicKey, - userId: member.user._id - }; - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspace member public keys' - }); - } + publicKeys = ( + await Membership.find({ + workspace: workspaceId, + }).populate<{ user: IUser }>("user", "publicKey") + ).map((member) => { + return { + publicKey: member.user.publicKey, + userId: member.user._id, + }; + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspace member public keys", + }); + } - return res.status(200).send({ - publicKeys - }); + return res.status(200).send({ + publicKeys, + }); }; /** @@ -59,24 +58,24 @@ export const getWorkspacePublicKeys = async (req: Request, res: Response) => { * @returns */ export const getWorkspaceMemberships = async (req: Request, res: Response) => { - let users; - try { - const { workspaceId } = req.params; + let users; + try { + const { workspaceId } = req.params; - users = await Membership.find({ - workspace: workspaceId - }).populate('user', '+publicKey'); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspace members' - }); - } + users = await Membership.find({ + workspace: workspaceId, + }).populate("user", "+publicKey"); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspace members", + }); + } - return res.status(200).send({ - users - }); + return res.status(200).send({ + users, + }); }; /** @@ -86,24 +85,24 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { * @returns */ export const getWorkspaces = async (req: Request, res: Response) => { - let workspaces; - try { - workspaces = ( - await Membership.find({ - user: req.user._id - }).populate('workspace') - ).map((m) => m.workspace); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspaces' - }); - } + let workspaces; + try { + workspaces = ( + await Membership.find({ + user: req.user._id, + }).populate("workspace") + ).map((m) => m.workspace); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspaces", + }); + } - return res.status(200).send({ - workspaces - }); + return res.status(200).send({ + workspaces, + }); }; /** @@ -113,24 +112,24 @@ export const getWorkspaces = async (req: Request, res: Response) => { * @returns */ export const getWorkspace = async (req: Request, res: Response) => { - let workspace; - try { - const { workspaceId } = req.params; + let workspace; + try { + const { workspaceId } = req.params; - workspace = await Workspace.findOne({ - _id: workspaceId - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspace' - }); - } + workspace = await Workspace.findOne({ + _id: workspaceId, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspace", + }); + } - return res.status(200).send({ - workspace - }); + return res.status(200).send({ + workspace, + }); }; /** @@ -141,46 +140,46 @@ export const getWorkspace = async (req: Request, res: Response) => { * @returns */ export const createWorkspace = async (req: Request, res: Response) => { - let workspace; - try { - const { workspaceName, organizationId } = req.body; + let workspace; + try { + const { workspaceName, organizationId } = req.body; - // validate organization membership - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: organizationId - }); + // validate organization membership + const membershipOrg = await MembershipOrg.findOne({ + user: req.user._id, + organization: organizationId, + }); - if (!membershipOrg) { - throw new Error('Failed to validate organization membership'); - } + if (!membershipOrg) { + throw new Error("Failed to validate organization membership"); + } - if (workspaceName.length < 1) { - throw new Error('Workspace names must be at least 1-character long'); - } + if (workspaceName.length < 1) { + throw new Error("Workspace names must be at least 1-character long"); + } - // create workspace and add user as member - workspace = await create({ - name: workspaceName, - organizationId - }); + // create workspace and add user as member + workspace = await create({ + name: workspaceName, + organizationId, + }); - await addMemberships({ - userIds: [req.user._id], - workspaceId: workspace._id.toString(), - roles: [ADMIN] - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to create workspace' - }); - } + await addMemberships({ + userIds: [req.user._id], + workspaceId: workspace._id.toString(), + roles: [ADMIN], + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to create workspace", + }); + } - return res.status(200).send({ - workspace - }); + return res.status(200).send({ + workspace, + }); }; /** @@ -190,24 +189,24 @@ export const createWorkspace = async (req: Request, res: Response) => { * @returns */ export const deleteWorkspace = async (req: Request, res: Response) => { - try { - const { workspaceId } = req.params; + try { + const { workspaceId } = req.params; - // delete workspace - await deleteWork({ - id: workspaceId - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete workspace' - }); - } + // delete workspace + await deleteWork({ + id: workspaceId, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to delete workspace", + }); + } - return res.status(200).send({ - message: 'Successfully deleted workspace' - }); + return res.status(200).send({ + message: "Successfully deleted workspace", + }); }; /** @@ -217,34 +216,34 @@ export const deleteWorkspace = async (req: Request, res: Response) => { * @returns */ export const changeWorkspaceName = async (req: Request, res: Response) => { - let workspace; - try { - const { workspaceId } = req.params; - const { name } = req.body; + let workspace; + try { + const { workspaceId } = req.params; + const { name } = req.body; - workspace = await Workspace.findOneAndUpdate( - { - _id: workspaceId - }, - { - name - }, - { - new: true - } - ); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to change workspace name' - }); - } + workspace = await Workspace.findOneAndUpdate( + { + _id: workspaceId, + }, + { + name, + }, + { + new: true, + } + ); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to change workspace name", + }); + } - return res.status(200).send({ - message: 'Successfully changed workspace name', - workspace - }); + return res.status(200).send({ + message: "Successfully changed workspace name", + workspace, + }); }; /** @@ -254,24 +253,24 @@ export const changeWorkspaceName = async (req: Request, res: Response) => { * @returns */ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { - let integrations; - try { - const { workspaceId } = req.params; + let integrations; + try { + const { workspaceId } = req.params; - integrations = await Integration.find({ - workspace: workspaceId - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspace integrations' - }); - } + integrations = await Integration.find({ + workspace: workspaceId, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspace integrations", + }); + } - return res.status(200).send({ - integrations - }); + return res.status(200).send({ + integrations, + }); }; /** @@ -281,56 +280,56 @@ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { * @returns */ export const getWorkspaceIntegrationAuthorizations = async ( - req: Request, - res: Response + req: Request, + res: Response ) => { - let authorizations; - try { - const { workspaceId } = req.params; + let authorizations; + try { + const { workspaceId } = req.params; - authorizations = await IntegrationAuth.find({ - workspace: workspaceId - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspace integration authorizations' - }); - } + authorizations = await IntegrationAuth.find({ + workspace: workspaceId, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspace integration authorizations", + }); + } - return res.status(200).send({ - authorizations - }); + return res.status(200).send({ + authorizations, + }); }; /** * Return service service tokens for workspace [workspaceId] belonging to user - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getWorkspaceServiceTokens = async ( - req: Request, - res: Response + req: Request, + res: Response ) => { - let serviceTokens; - try { - const { workspaceId } = req.params; - // ?? FIX. - serviceTokens = await ServiceToken.find({ - user: req.user._id, - workspace: workspaceId - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get workspace service tokens' - }); - } - - return res.status(200).send({ - serviceTokens - }); -} \ No newline at end of file + let serviceTokens; + try { + const { workspaceId } = req.params; + // ?? FIX. + serviceTokens = await ServiceToken.find({ + user: req.user._id, + workspace: workspaceId, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get workspace service tokens", + }); + } + + return res.status(200).send({ + serviceTokens, + }); +}; diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index f0f30f897..17dbb2be0 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,7 +1,7 @@ -import axios from 'axios'; -import * as Sentry from '@sentry/node'; -import { Octokit } from '@octokit/rest'; -import { IIntegrationAuth } from '../models'; +import axios from "axios"; +import * as Sentry from "@sentry/node"; +import { Octokit } from "@octokit/rest"; +import { IIntegrationAuth } from "../models"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -12,12 +12,14 @@ import { INTEGRATION_GITHUB, INTEGRATION_RENDER, INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, - INTEGRATION_FLYIO_API_URL -} from '../variables'; + INTEGRATION_FLYIO_API_URL, + INTEGRATION_CIRCLECI_API_URL, +} from "../variables"; /** * Return list of names of apps for integration named [integration] @@ -29,7 +31,7 @@ import { */ const getApps = async ({ integrationAuth, - accessToken + accessToken, }: { integrationAuth: IIntegrationAuth; accessToken: string; @@ -54,40 +56,45 @@ const getApps = async ({ break; case INTEGRATION_HEROKU: apps = await getAppsHeroku({ - accessToken + accessToken, }); break; case INTEGRATION_VERCEL: apps = await getAppsVercel({ integrationAuth, - accessToken + accessToken, }); break; case INTEGRATION_NETLIFY: apps = await getAppsNetlify({ - accessToken + accessToken, }); break; case INTEGRATION_GITHUB: apps = await getAppsGithub({ - accessToken + accessToken, }); break; case INTEGRATION_RENDER: apps = await getAppsRender({ - accessToken + accessToken, }); break; case INTEGRATION_FLYIO: apps = await getAppsFlyio({ - accessToken + accessToken, + }); + break; + case INTEGRATION_CIRCLECI: + apps = await getAppsCircleci({ + accessToken, }); break; } } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get integration apps'); + throw new Error("Failed to get integration apps"); } return apps; @@ -106,19 +113,19 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { const res = ( await axios.get(`${INTEGRATION_HEROKU_API_URL}/apps`, { headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: `Bearer ${accessToken}` - } + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}`, + }, }) ).data; apps = res.map((a: any) => ({ - name: a.name + name: a.name, })); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get Heroku integration apps'); + throw new Error("Failed to get Heroku integration apps"); } return apps; @@ -131,10 +138,10 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { * @returns {Object[]} apps - names of Vercel apps * @returns {String} apps.name - name of Vercel app */ -const getAppsVercel = async ({ +const getAppsVercel = async ({ integrationAuth, - accessToken -}: { + accessToken, +}: { integrationAuth: IIntegrationAuth; accessToken: string; }) => { @@ -146,21 +153,23 @@ const getAppsVercel = async ({ Authorization: `Bearer ${accessToken}`, 'Accept-Encoding': 'application/json' }, - ...( integrationAuth?.teamId ? { - params: { - teamId: integrationAuth.teamId - } - } : {}) + ...(integrationAuth?.teamId + ? { + params: { + teamId: integrationAuth.teamId, + }, + } + : {}), }) ).data; apps = res.projects.map((a: any) => ({ - name: a.name + name: a.name, })); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get Vercel integration apps'); + throw new Error("Failed to get Vercel integration apps"); } return apps; @@ -173,11 +182,7 @@ const getAppsVercel = async ({ * @returns {Object[]} apps - names of Netlify sites * @returns {String} apps.name - name of Netlify site */ -const getAppsNetlify = async ({ - accessToken -}: { - accessToken: string; -}) => { +const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => { let apps; try { const res = ( @@ -191,12 +196,12 @@ const getAppsNetlify = async ({ apps = res.map((a: any) => ({ name: a.name, - appId: a.site_id + appId: a.site_id, })); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get Netlify integration apps'); + throw new Error("Failed to get Netlify integration apps"); } return apps; @@ -209,35 +214,32 @@ const getAppsNetlify = async ({ * @returns {Object[]} apps - names of Netlify sites * @returns {String} apps.name - name of Netlify site */ -const getAppsGithub = async ({ - accessToken -}: { - accessToken: string; -}) => { +const getAppsGithub = async ({ accessToken }: { accessToken: string }) => { let apps; try { const octokit = new Octokit({ - auth: accessToken + auth: accessToken, }); - const repos = (await octokit.request( - 'GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}', - { - per_page: 100 - } - )).data; + const repos = ( + await octokit.request( + "GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}", + { + per_page: 100, + } + ) + ).data; apps = repos - .filter((a:any) => a.permissions.admin === true) + .filter((a: any) => a.permissions.admin === true) .map((a: any) => ({ - name: a.name, - owner: a.owner.login - }) - ); + name: a.name, + owner: a.owner.login, + })); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get Github repos'); + throw new Error("Failed to get Github repos"); } return apps; @@ -251,11 +253,7 @@ const getAppsGithub = async ({ * @returns {String} apps.name - name of Render service * @returns {String} apps.appId - id of Render service */ -const getAppsRender = async ({ - accessToken -}: { - accessToken: string; -}) => { +const getAppsRender = async ({ accessToken }: { accessToken: string }) => { let apps: any; try { const res = ( @@ -263,8 +261,8 @@ const getAppsRender = async ({ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', - 'Accept-Encoding': 'application/json' - } + 'Accept-Encoding': 'application/json', + }, }) ).data; @@ -277,11 +275,11 @@ const getAppsRender = async ({ } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get Render services'); + throw new Error("Failed to get Render services"); } - + return apps; -} +}; /** * Return list of apps for Fly.io integration @@ -290,11 +288,7 @@ const getAppsRender = async ({ * @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; -}) => { +const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { let apps; try { const query = ` @@ -308,34 +302,68 @@ const getAppsFlyio = async ({ } } `; - - const res = (await axios({ - url: INTEGRATION_FLYIO_API_URL, - method: 'post', - headers: { - 'Authorization': 'Bearer ' + accessToken, + + const res = ( + await axios({ + url: INTEGRATION_FLYIO_API_URL, + method: "post", + headers: { + Authorization: "Bearer " + accessToken, 'Accept': 'application/json', - 'Accept-Encoding': 'application/json' - }, - data: { - query, - variables: { - role: null - } - } - })).data.data.apps.nodes; - - apps = res - .map((a: any) => ({ - name: a.name - })); + 'Accept-Encoding': 'application/json', + }, + 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'); + throw new Error("Failed to get Fly.io apps"); } + + return apps; +}; + +const getAppsCircleci = async ({ accessToken }: { accessToken: string }) => { + // in place of accessToken we have to send Circle-Token i.e. Personal API token from CircleCi + let apps: any; + try { + let res = ( + await axios.get( + `${INTEGRATION_CIRCLECI_API_URL}/v1.1/projects`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json", + }, + } + ) + ).data + + apps = res?.map((a: any) => { + return { + name: a?.reponame + } + }) + } catch (err) { + console.log(err); + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to get Render services"); + } + + console.log("hello apps"); return apps; -} +}; export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 222fa940a..b78070e5a 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; -import * as Sentry from '@sentry/node'; +import axios from "axios"; +import * as Sentry from "@sentry/node"; import _ from 'lodash'; import AWS from 'aws-sdk'; import { @@ -9,9 +9,9 @@ import { GetSecretValueCommand, ResourceNotFoundException } from '@aws-sdk/client-secrets-manager'; -import { Octokit } from '@octokit/rest'; -import sodium from 'libsodium-wrappers'; -import { IIntegration, IIntegrationAuth } from '../models'; +import { Octokit } from "@octokit/rest"; +import sodium from "libsodium-wrappers"; +import { IIntegration, IIntegrationAuth } from "../models"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -22,12 +22,15 @@ import { INTEGRATION_GITHUB, INTEGRATION_RENDER, INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, - INTEGRATION_FLYIO_API_URL -} from '../variables'; + INTEGRATION_FLYIO_API_URL, + INTEGRATION_CIRCLECI_API_URL, +} from "../variables"; +import { access, appendFile } from "fs"; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -43,7 +46,7 @@ const syncSecrets = async ({ integrationAuth, secrets, accessId, - accessToken + accessToken, }: { integration: IIntegration; integrationAuth: IIntegrationAuth; @@ -80,7 +83,7 @@ const syncSecrets = async ({ await syncSecretsHeroku({ integration, secrets, - accessToken + accessToken, }); break; case INTEGRATION_VERCEL: @@ -88,7 +91,7 @@ const syncSecrets = async ({ integration, integrationAuth, secrets, - accessToken + accessToken, }); break; case INTEGRATION_NETLIFY: @@ -96,30 +99,36 @@ const syncSecrets = async ({ integration, integrationAuth, secrets, - accessToken + accessToken, }); break; case INTEGRATION_GITHUB: await syncSecretsGitHub({ integration, secrets, - accessToken + accessToken, }); break; case INTEGRATION_RENDER: await syncSecretsRender({ integration, secrets, - accessToken + accessToken, }); break; case INTEGRATION_FLYIO: await syncSecretsFlyio({ integration, secrets, - accessToken + accessToken, }); break; + case INTEGRATION_CIRCLECI: + await syncSecretsCircleci({ + integration, + secrets, + accessToken, + }); } } catch (err) { Sentry.setUser(null); @@ -465,7 +474,7 @@ const syncSecretsAWSSecretManager = async ({ const syncSecretsHeroku = async ({ integration, secrets, - accessToken + accessToken, }: { integration: IIntegration; secrets: any; @@ -477,9 +486,9 @@ const syncSecretsHeroku = async ({ `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, { headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: `Bearer ${accessToken}` - } + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}`, + }, } ) ).data; @@ -495,15 +504,15 @@ const syncSecretsHeroku = async ({ secrets, { headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: `Bearer ${accessToken}` - } + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}`, + }, } ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to sync secrets to Heroku'); + throw new Error("Failed to sync secrets to Heroku"); } }; @@ -514,37 +523,42 @@ const syncSecretsHeroku = async ({ * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) */ const syncSecretsVercel = async ({ - integration, - integrationAuth, - secrets, - accessToken + integration, + integrationAuth, + secrets, + accessToken, }: { - integration: IIntegration, - integrationAuth: IIntegrationAuth, - secrets: any; - accessToken: string; + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; }) => { - interface VercelSecret { - id?: string; - type: string; - key: string; - value: string; - target: string[]; - } - - try { - // Get all (decrypted) secrets back from Vercel in - // decrypted format - const params: { [key: string]: string } = { - decrypt: 'true', - ...( integrationAuth?.teamId ? { - teamId: integrationAuth.teamId - } : {}) - } - - const res = (await Promise.all((await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, - { + interface VercelSecret { + id?: string; + type: string; + key: string; + value: string; + target: string[]; + } + + try { + // Get all (decrypted) secrets back from Vercel in + // decrypted format + const params: { [key: string]: string } = { + decrypt: "true", + ...(integrationAuth?.teamId + ? { + teamId: integrationAuth.teamId, + } + : {}), + }; + + const res = ( + await Promise.all( + ( + await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, + { params, headers: { Authorization: `Bearer ${accessToken}` @@ -572,98 +586,95 @@ const syncSecretsVercel = async ({ const deleteSecrets: VercelSecret[] = []; const newSecrets: VercelSecret[] = []; - // Identify secrets to create - Object.keys(secrets).map((key) => { - if (!(key in res)) { - // case: secret has been created - newSecrets.push({ - key: key, - value: secrets[key], - type: 'encrypted', - target: [integration.targetEnvironment] - }); - } - }); - - // Identify secrets to update and delete - Object.keys(res).map((key) => { - if (key in secrets) { - if (res[key].value !== secrets[key]) { - // case: secret value has changed - updateSecrets.push({ - id: res[key].id, - key: key, - value: secrets[key], - type: 'encrypted', - target: [integration.targetEnvironment] - }); - } - } else { - // case: secret has been deleted - deleteSecrets.push({ - id: res[key].id, - key: key, - value: res[key].value, - type: 'encrypted', - target: [integration.targetEnvironment], - }); - } - }); - - // Sync/push new secrets - if (newSecrets.length > 0) { - await axios.post( - `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, - newSecrets, - { - params, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); + // Identify secrets to create + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: secret has been created + newSecrets.push({ + key: key, + value: secrets[key], + type: "encrypted", + target: [integration.targetEnvironment], + }); } + }); - // Sync/push updated secrets - if (updateSecrets.length > 0) { - updateSecrets.forEach(async (secret: VercelSecret) => { - const { - id, - ...updatedSecret - } = secret; - await axios.patch( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, - updatedSecret, - { - params, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); + // Identify secrets to update and delete + Object.keys(res).map((key) => { + if (key in secrets) { + if (res[key].value !== secrets[key]) { + // case: secret value has changed + updateSecrets.push({ + id: res[key].id, + key: key, + value: secrets[key], + type: "encrypted", + target: [integration.targetEnvironment], }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + id: res[key].id, + key: key, + value: res[key].value, + type: "encrypted", + target: [integration.targetEnvironment], + }); } + }); - // Delete secrets - if (deleteSecrets.length > 0) { - deleteSecrets.forEach(async (secret: VercelSecret) => { - await axios.delete( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, - { - params, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to Vercel'); + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, + newSecrets, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); } -} + + // Sync/push updated secrets + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: VercelSecret) => { + const { id, ...updatedSecret } = secret; + await axios.patch( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + updatedSecret, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + }); + } + + // Delete secrets + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret: VercelSecret) => { + await axios.delete( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to Vercel"); + } +}; /** * Sync/push [secrets] to Netlify site with id [integration.appId] @@ -674,202 +685,214 @@ const syncSecretsVercel = async ({ * @param {Object} obj.accessToken - access token for Netlify integration */ const syncSecretsNetlify = async ({ - integration, - integrationAuth, - secrets, - accessToken + integration, + integrationAuth, + secrets, + accessToken, }: { - integration: IIntegration; - integrationAuth: IIntegrationAuth; - secrets: any; - accessToken: string; + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; }) => { - try { - - interface NetlifyValue { - id?: string; - context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production', - value: string; - } - - interface NetlifySecret { - key: string; - values: NetlifyValue[]; - } - - interface NetlifySecretsRes { - [index: string]: NetlifySecret; - } - - const getParams = new URLSearchParams({ - context_name: 'all', // integration.context or all - site_id: integration.appId - }); - - const res = (await axios.get( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, - { - params: getParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - )) - .data - .reduce((obj: any, secret: any) => ({ - ...obj, - [secret.key]: secret - }), {}); - - const newSecrets: NetlifySecret[] = []; // createEnvVars - const deleteSecrets: string[] = []; // deleteEnvVar - const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue - const updateSecrets: NetlifySecret[] = []; // setEnvVarValue - - // identify secrets to create and update - Object.keys(secrets).map((key) => { - if (!(key in res)) { - // case: Infisical secret does not exist in Netlify -> create secret - newSecrets.push({ - key, - values: [{ - value: secrets[key], - context: integration.targetEnvironment - }] - }); - } else { - // case: Infisical secret exists in Netlify - const contexts = res[key].values - .reduce((obj: any, value: NetlifyValue) => ({ - ...obj, - [value.context]: value - }), {}); - - if (integration.targetEnvironment in contexts) { - // case: Netlify secret value exists in integration context - 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.targetEnvironment, - value: secrets[key] - }] - }); - } - } else { - // case: Netlify secret value does not exist in integration context - // -> add the new Netlify secret context and value - updateSecrets.push({ - key, - values: [{ - context: integration.targetEnvironment, - value: secrets[key] - }] - }); - } - } - }) - - // identify secrets to delete - // TODO: revise (patch case where 1 context was deleted but others still there - Object.keys(res).map((key) => { - // loop through each key's context - if (!(key in secrets)) { - // case: Netlify secret does not exist in Infisical - - const numberOfValues = res[key].values.length; - - res[key].values.forEach((value: NetlifyValue) => { - if (value.context === integration.targetEnvironment) { - if (numberOfValues <= 1) { - // case: Netlify secret value has less than 1 context -> delete secret - deleteSecrets.push(key); - } else { - // case: Netlify secret value has more than 1 context -> delete secret value context - deleteSecretValues.push({ - key, - values: [{ - id: value.id, - context: integration.targetEnvironment, - value: value.value - }] - }); - } - } - }); - } - }); - - const syncParams = new URLSearchParams({ - site_id: integration.appId - }); - - if (newSecrets.length > 0) { - await axios.post( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, - newSecrets, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - } - - if (updateSecrets.length > 0) { - updateSecrets.forEach(async (secret: NetlifySecret) => { - await axios.patch( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, - { - context: secret.values[0].context, - value: secret.values[0].value - }, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - - if (deleteSecrets.length > 0) { - deleteSecrets.forEach(async (key: string) => { - await axios.delete( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - - if (deleteSecretValues.length > 0) { - deleteSecretValues.forEach(async (secret: NetlifySecret) => { - await axios.delete( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to Heroku'); + try { + interface NetlifyValue { + id?: string; + context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production', + value: string; } -} + + interface NetlifySecret { + key: string; + values: NetlifyValue[]; + } + + interface NetlifySecretsRes { + [index: string]: NetlifySecret; + } + + const getParams = new URLSearchParams({ + context_name: "all", // integration.context or all + site_id: integration.appId, + }); + + const res = ( + await axios.get( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + { + params: getParams, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).data.reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret, + }), + {} + ); + + const newSecrets: NetlifySecret[] = []; // createEnvVars + const deleteSecrets: string[] = []; // deleteEnvVar + const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue + const updateSecrets: NetlifySecret[] = []; // setEnvVarValue + + // identify secrets to create and update + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: Infisical secret does not exist in Netlify -> create secret + newSecrets.push({ + key, + values: [ + { + value: secrets[key], + context: integration.targetEnvironment, + }, + ], + }); + } else { + // case: Infisical secret exists in Netlify + const contexts = res[key].values.reduce( + (obj: any, value: NetlifyValue) => ({ + ...obj, + [value.context]: value, + }), + {} + ); + + if (integration.targetEnvironment in contexts) { + // case: Netlify secret value exists in integration context + 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.targetEnvironment, + value: secrets[key], + }, + ], + }); + } + } else { + // case: Netlify secret value does not exist in integration context + // -> add the new Netlify secret context and value + updateSecrets.push({ + key, + values: [ + { + context: integration.targetEnvironment, + value: secrets[key], + }, + ], + }); + } + } + }); + + // identify secrets to delete + // TODO: revise (patch case where 1 context was deleted but others still there + Object.keys(res).map((key) => { + // loop through each key's context + if (!(key in secrets)) { + // case: Netlify secret does not exist in Infisical + + const numberOfValues = res[key].values.length; + + res[key].values.forEach((value: NetlifyValue) => { + if (value.context === integration.targetEnvironment) { + if (numberOfValues <= 1) { + // case: Netlify secret value has less than 1 context -> delete secret + deleteSecrets.push(key); + } else { + // case: Netlify secret value has more than 1 context -> delete secret value context + deleteSecretValues.push({ + key, + values: [ + { + id: value.id, + context: integration.targetEnvironment, + value: value.value, + }, + ], + }); + } + } + }); + } + }); + + const syncParams = new URLSearchParams({ + site_id: integration.appId, + }); + + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + newSecrets, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + } + + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: NetlifySecret) => { + await axios.patch( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + context: secret.values[0].context, + value: secret.values[0].value, + }, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + }); + } + + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (key: string) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + }); + } + + if (deleteSecretValues.length > 0) { + deleteSecretValues.forEach(async (secret: NetlifySecret) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to Heroku"); + } +}; /** * Sync/push [secrets] to GitHub repo with name [integration.app] @@ -877,24 +900,23 @@ const syncSecretsNetlify = async ({ * @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 + * @param {String} obj.accessToken - access token for GitHub integration */ const syncSecretsGitHub = async ({ integration, secrets, - accessToken + accessToken, }: { integration: IIntegration; secrets: any; accessToken: string; }) => { try { - interface GitHubRepoKey { key_id: string; key: string; } - + interface GitHubSecret { name: string; created_at: string; @@ -902,87 +924,88 @@ const syncSecretsGitHub = async ({ } interface GitHubSecretRes { - [index: string]: GitHubSecret; + [index: string]: GitHubSecret; } const deleteSecrets: GitHubSecret[] = []; const octokit = new Octokit({ - auth: accessToken + auth: accessToken, }); // const user = (await octokit.request('GET /user', {})).data; - const repoPublicKey: GitHubRepoKey = (await octokit.request( - 'GET /repos/{owner}/{repo}/actions/secrets/public-key', - { - owner: integration.owner, - repo: integration.app - } - )).data; + const repoPublicKey: GitHubRepoKey = ( + await octokit.request( + "GET /repos/{owner}/{repo}/actions/secrets/public-key", + { + owner: integration.owner, + repo: integration.app, + } + ) + ).data; // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key - const encryptedSecrets: GitHubSecretRes = (await octokit.request( - 'GET /repos/{owner}/{repo}/actions/secrets', - { + const encryptedSecrets: GitHubSecretRes = ( + await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { owner: integration.owner, - repo: integration.app - } - )) - .data - .secrets - .reduce((obj: any, secret: any) => ({ - ...obj, - [secret.name]: secret - }), {}); - + repo: integration.app, + }) + ).data.secrets.reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.name]: secret, + }), + {} + ); + Object.keys(encryptedSecrets).map(async (key) => { if (!(key in secrets)) { await octokit.request( - 'DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}', + "DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { owner: integration.owner, repo: integration.app, - secret_name: key + secret_name: key, } ); } }); - + Object.keys(secrets).map((key) => { // let encryptedSecret; sodium.ready.then(async () => { - // convert secret & base64 key to Uint8Array. - const binkey = sodium.from_base64( - repoPublicKey.key, - sodium.base64_variants.ORIGINAL - ); - const binsec = sodium.from_string(secrets[key]); + // convert secret & base64 key to Uint8Array. + const binkey = sodium.from_base64( + repoPublicKey.key, + sodium.base64_variants.ORIGINAL + ); + const binsec = sodium.from_string(secrets[key]); - // encrypt secret using libsodium - const encBytes = sodium.crypto_box_seal(binsec, binkey); + // encrypt secret using libsodium + const encBytes = sodium.crypto_box_seal(binsec, binkey); - // convert encrypted Uint8Array to base64 - const encryptedSecret = sodium.to_base64( - encBytes, - sodium.base64_variants.ORIGINAL - ); - - await octokit.request( - 'PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}', - { - owner: integration.owner, - repo: integration.app, - secret_name: key, - encrypted_value: encryptedSecret, - key_id: repoPublicKey.key_id - } - ); + // convert encrypted Uint8Array to base64 + const encryptedSecret = sodium.to_base64( + encBytes, + sodium.base64_variants.ORIGINAL + ); + + await octokit.request( + "PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", + { + owner: integration.owner, + repo: integration.app, + secret_name: key, + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id, + } + ); }); }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to sync secrets to GitHub'); + throw new Error("Failed to sync secrets to GitHub"); } }; @@ -996,7 +1019,7 @@ const syncSecretsGitHub = async ({ const syncSecretsRender = async ({ integration, secrets, - accessToken + accessToken, }: { integration: IIntegration; secrets: any; @@ -1007,20 +1030,20 @@ const syncSecretsRender = async ({ `${INTEGRATION_RENDER_API_URL}/v1/services/${integration.appId}/env-vars`, Object.keys(secrets).map((key) => ({ key, - value: secrets[key] + value: secrets[key], })), { headers: { - Authorization: `Bearer ${accessToken}` - } + Authorization: `Bearer ${accessToken}`, + }, } ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to sync secrets to Render'); + throw new Error("Failed to sync secrets to Render"); } -} +}; /** * Sync/push [secrets] to Fly.io app @@ -1032,7 +1055,7 @@ const syncSecretsRender = async ({ const syncSecretsFlyio = async ({ integration, secrets, - accessToken + accessToken, }: { integration: IIntegration; secrets: any; @@ -1062,28 +1085,31 @@ const syncSecretsFlyio = async ({ await axios({ url: INTEGRATION_FLYIO_API_URL, - method: 'post', + method: "post", headers: { - 'Authorization': 'Bearer ' + accessToken + Authorization: "Bearer " + accessToken, }, data: { query: SetSecrets, variables: { input: { appId: integration.app, - secrets: Object.entries(secrets).map(([key, value]) => ({ key, value })) - } - } - } + 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 { @@ -1094,8 +1120,9 @@ const syncSecretsFlyio = async ({ } }`; - const getSecretsRes = (await axios({ - method: 'post', + const getSecretsRes = ( + await axios({ + method: "post", url: INTEGRATION_FLYIO_API_URL, headers: { 'Authorization': 'Bearer ' + accessToken, @@ -1105,15 +1132,16 @@ const syncSecretsFlyio = async ({ data: { query: GetSecrets, variables: { - appName: integration.app - } - } - })).data.data.app.secrets; - + 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) { @@ -1134,28 +1162,100 @@ const syncSecretsFlyio = async ({ }`; await axios({ - method: 'post', - url: INTEGRATION_FLYIO_API_URL, - headers: { - 'Authorization': 'Bearer ' + accessToken, - 'Content-Type': 'application/json' + 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, + }, }, - 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'); + throw new Error("Failed to sync secrets to Fly.io"); } -} +}; -export { syncSecrets }; \ No newline at end of file +const syncSecretsCircleci = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + const circleciOrganizationDetail = ( + await axios.get(`${INTEGRATION_CIRCLECI_API_URL}/v2/me/collaborations`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json", + }, + }) + ).data[0]; + + const { slug } = circleciOrganizationDetail; + + // inject secrets to CircleCI (one by one) + Object.keys(secrets).forEach( + async (key) => + await axios.post( + `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, + { + name: key, + value: secrets[key], + }, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json", + }, + } + ) + ); + + // get secrets from CircleCI + const getSecretsRes = ( + await axios.get( + `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json", + }, + } + ) + ).data?.items; + + // delete secrets from CircleCI + getSecretsRes.forEach(async (sec: any) => { + if (!(sec.name in secrets)) { + await axios.delete( + `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar/${sec.name}`, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json", + }, + } + ); + } + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to CircleCI"); + } +}; + +export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index ddf31feb7..1b52fabd9 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,4 +1,4 @@ -import { Schema, model, Types } from 'mongoose'; +import { Schema, model, Types } from "mongoose"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -8,8 +8,9 @@ import { INTEGRATION_NETLIFY, INTEGRATION_GITHUB, INTEGRATION_RENDER, - INTEGRATION_FLYIO -} from '../variables'; + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, +} from "../variables"; export interface IIntegration { _id: Types.ObjectId; @@ -31,44 +32,47 @@ export interface IIntegration { | 'netlify' | 'github' | 'render' - | 'flyio'; + | 'flyio' + | 'circleci'; 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, - required: true + required: true, }, isActive: { type: Boolean, - required: true + required: true, }, app: { // name of app in provider type: String, - default: null + default: null, }, - appId: { // (new) + appId: { + // (new) // id of app in provider type: String, - default: null + default: null, }, - targetEnvironment: { // (new) - // target environment + targetEnvironment: { + // (new) + // target environment type: String, - default: null + default: null, }, owner: { // github-specific repo owner-login type: String, - default: null + default: null, }, path: { // aws-parameter-store-specific path @@ -91,21 +95,22 @@ const integrationSchema = new Schema( INTEGRATION_NETLIFY, INTEGRATION_GITHUB, INTEGRATION_RENDER, - INTEGRATION_FLYIO + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, ], - required: true + required: true, }, integrationAuth: { type: Schema.Types.ObjectId, - ref: 'IntegrationAuth', - required: true - } + ref: "IntegrationAuth", + required: true, + }, }, { - timestamps: true + timestamps: true, } ); -const Integration = model('Integration', integrationSchema); +const Integration = model("Integration", integrationSchema); export default Integration; diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index ebe9daa55..95f8c75af 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,4 +1,4 @@ -import { Schema, model, Types } from 'mongoose'; +import { Schema, model, Types } from "mongoose"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -8,24 +8,16 @@ import { INTEGRATION_NETLIFY, INTEGRATION_GITHUB, INTEGRATION_RENDER, - INTEGRATION_FLYIO -} from '../variables'; + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, +} from "../variables"; export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: - | 'azure-key-vault' - | 'aws-parameter-store' - | 'aws-secret-manager' - | 'heroku' - | 'vercel' - | 'netlify' - | 'github' - | 'render' - | 'flyio'; - teamId: string; // TODO: deprecate (vercel) -> move to accessId - accountId: string; // TODO: deprecate (netlify) -> move to accessId + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault' | 'circleci' | 'aws-parameter-store' | 'aws-secret-manager'; + teamId: string; + accountId: string; refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; @@ -41,9 +33,9 @@ export interface IIntegrationAuth { const integrationAuthSchema = new Schema( { workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace', - required: true + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true, }, integration: { type: String, @@ -56,29 +48,30 @@ const integrationAuthSchema = new Schema( INTEGRATION_NETLIFY, INTEGRATION_GITHUB, INTEGRATION_RENDER, - INTEGRATION_FLYIO + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, ], - required: true + required: true, }, teamId: { // vercel-specific integration param - type: String + type: String, }, accountId: { // netlify-specific integration param - type: String + type: String, }, refreshCiphertext: { type: String, - select: false + select: false, }, refreshIV: { type: String, - select: false + select: false, }, refreshTag: { type: String, - select: false + select: false, }, accessIdCiphertext: { type: String, @@ -94,28 +87,28 @@ const integrationAuthSchema = new Schema( }, accessCiphertext: { type: String, - select: false + select: false, }, accessIV: { type: String, - select: false + select: false, }, accessTag: { type: String, - select: false + select: false, }, accessExpiresAt: { type: Date, - select: false - } + select: false, + }, }, { - timestamps: true + timestamps: true, } ); const IntegrationAuth = model( - 'IntegrationAuth', + "IntegrationAuth", integrationAuthSchema ); diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 182b23db1..52feec126 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -3,8 +3,8 @@ import { ENV_TESTING, ENV_STAGING, ENV_PROD, - ENV_SET -} from './environment'; + ENV_SET, +} from "./environment"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -15,6 +15,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_RENDER, INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -27,27 +28,22 @@ import { INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, - INTEGRATION_OPTIONS -} from './integration'; -import { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, -} from './organization'; -import { SECRET_SHARED, SECRET_PERSONAL } from './secret'; -import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from './event'; + INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_OPTIONS, +} from "./integration"; +import { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED } from "./organization"; +import { SECRET_SHARED, SECRET_PERSONAL } from "./secret"; +import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from "./event"; import { ACTION_LOGIN, ACTION_LOGOUT, ACTION_ADD_SECRETS, ACTION_UPDATE_SECRETS, ACTION_DELETE_SECRETS, - ACTION_READ_SECRETS -} from './action'; -import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from './smtp'; -import { PLAN_STARTER, PLAN_PRO } from './stripe'; + ACTION_READ_SECRETS, +} from "./action"; +import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from "./smtp"; +import { PLAN_STARTER, PLAN_PRO } from "./stripe"; export { OWNER, @@ -71,6 +67,7 @@ export { INTEGRATION_GITHUB, INTEGRATION_RENDER, INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -83,6 +80,7 @@ export { INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, + INTEGRATION_CIRCLECI_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_LOGIN, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index d22059cb9..de7853ffa 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -3,50 +3,53 @@ import { TENANT_ID_AZURE } from '../config'; import { - CLIENT_ID_HEROKU, - CLIENT_ID_NETLIFY, - CLIENT_ID_GITHUB, - CLIENT_SLUG_VERCEL -} from '../config'; + CLIENT_ID_HEROKU, + CLIENT_ID_NETLIFY, + CLIENT_ID_GITHUB, + CLIENT_SLUG_VERCEL, +} from "../config"; // integrations const INTEGRATION_AZURE_KEY_VAULT = 'azure-key-vault'; const INTEGRATION_AWS_PARAMETER_STORE = 'aws-parameter-store'; const INTEGRATION_AWS_SECRET_MANAGER = 'aws-secret-manager'; -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_HEROKU = "heroku"; +const INTEGRATION_VERCEL = "vercel"; +const INTEGRATION_NETLIFY = "netlify"; +const INTEGRATION_GITHUB = "github"; +const INTEGRATION_RENDER = "render"; +const INTEGRATION_FLYIO = "flyio"; +const INTEGRATION_CIRCLECI = "circleci"; const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_RENDER, - INTEGRATION_FLYIO + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, ]); // integration types -const INTEGRATION_OAUTH2 = 'oauth2'; +const INTEGRATION_OAUTH2 = "oauth2"; // integration oauth endpoints const INTEGRATION_AZURE_TOKEN_URL = `https://login.microsoftonline.com/${TENANT_ID_AZURE}/oauth2/v2.0/token`; const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; const INTEGRATION_VERCEL_TOKEN_URL = - 'https://api.vercel.com/v2/oauth/access_token'; -const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; + "https://api.vercel.com/v2/oauth/access_token"; +const INTEGRATION_NETLIFY_TOKEN_URL = "https://api.netlify.com/oauth/token"; const INTEGRATION_GITHUB_TOKEN_URL = - 'https://github.com/login/oauth/access_token'; + "https://github.com/login/oauth/access_token"; // integration apps endpoints -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_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_CIRCLECI_API_URL = "https://circleci.com/api"; const INTEGRATION_OPTIONS = [ { @@ -154,8 +157,8 @@ const INTEGRATION_OPTIONS = [ name: 'Circle CI', slug: 'circleci', image: 'Circle CI.png', - isAvailable: false, - type: '', + isAvailable: true, + type: 'pat', clientId: '', docsLink: '' } @@ -165,23 +168,25 @@ export { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_RENDER, - INTEGRATION_FLYIO, - INTEGRATION_SET, - INTEGRATION_OAUTH2, + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_RENDER, + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, + INTEGRATION_SET, + INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_RENDER_API_URL, - INTEGRATION_FLYIO_API_URL, - INTEGRATION_OPTIONS + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_GITHUB_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_RENDER_API_URL, + INTEGRATION_FLYIO_API_URL, + INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_OPTIONS, }; diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 806460997..91af2ff25 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -1,24 +1,16 @@ // membership roles -const OWNER = 'owner'; -const ADMIN = 'admin'; -const MEMBER = 'member'; +const OWNER = "owner"; +const ADMIN = "admin"; +const MEMBER = "member"; // membership statuses -const INVITED = 'invited'; +const INVITED = "invited"; // membership permissions ability -const ABILITY_READ = 'read'; -const ABILITY_WRITE = 'write'; +const ABILITY_READ = "read"; +const ABILITY_WRITE = "write"; // -- organization -const ACCEPTED = 'accepted'; +const ACCEPTED = "accepted"; -export { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - ABILITY_READ, - ABILITY_WRITE -} \ No newline at end of file +export { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED, ABILITY_READ, ABILITY_WRITE }; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 7519acc28..f2e3acf70 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -11,7 +11,8 @@ const integrationSlugNameMapping: Mapping = { 'netlify': 'Netlify', 'github': 'GitHub', 'render': 'Render', - 'flyio': 'Fly.io' + 'flyio': 'Fly.io', + "circleci": 'CircleCI' } const envMapping: Mapping = { diff --git a/frontend/src/components/integrations/CloudIntegration.tsx b/frontend/src/components/integrations/CloudIntegration.tsx index cc06bdd8e..9536957ce 100644 --- a/frontend/src/components/integrations/CloudIntegration.tsx +++ b/frontend/src/components/integrations/CloudIntegration.tsx @@ -44,9 +44,9 @@ const CloudIntegration = ({ tabIndex={0} className={`relative ${ cloudIntegrationOption.isAvailable - ? 'hover:bg-white/10 duration-200 cursor-pointer' + ? 'cursor-pointer duration-200 hover:bg-white/10' : 'opacity-50' - } flex flex-row bg-white/5 h-32 rounded-md p-4 items-center`} + } flex h-32 flex-row items-center rounded-md bg-white/5 p-4`} onClick={() => { if (!cloudIntegrationOption.isAvailable) return; setSelectedIntegrationOption(cloudIntegrationOption); @@ -61,22 +61,22 @@ const CloudIntegration = ({ alt="integration logo" /> {cloudIntegrationOption.name.split(' ').length > 2 ? ( -
+
{cloudIntegrationOption.name.split(' ')[0]}
{cloudIntegrationOption.name.split(' ')[1]} {cloudIntegrationOption.name.split(' ')[2]}
) : ( -
+
{cloudIntegrationOption.name}
)} {cloudIntegrationOption.isAvailable && integrationAuths - .map((authorization) => authorization.integration) + .map((authorization) => authorization?.integration) .includes(cloudIntegrationOption.slug) && ( -
+
null} role="button" @@ -86,8 +86,7 @@ const CloudIntegration = ({ const deletedIntegrationAuth = await deleteIntegrationAuth({ integrationAuthId: integrationAuths .filter( - (authorization) => - authorization.integration === cloudIntegrationOption.slug + (authorization) => authorization.integration === cloudIntegrationOption.slug ) .map((authorization) => authorization._id)[0] }); @@ -96,20 +95,20 @@ const CloudIntegration = ({ 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="flex w-max cursor-pointer flex-row items-center rounded-b-md bg-red py-0.5 px-2 text-xs opacity-0 duration-200 group-hover:opacity-100" > - + Revoke
-
- +
+ Authorized
)} {!cloudIntegrationOption.isAvailable && ( -
-
+
+
Coming Soon
diff --git a/frontend/src/components/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index 5dbb3f11e..e01c31e2b 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -45,8 +45,8 @@ type Props = { handleDeleteIntegration: (args: { integration: Integration }) => void; }; -const IntegrationTile = ({ - integration, +const IntegrationTile = ({ + integration, integrations, bot, setBot, @@ -57,7 +57,7 @@ const IntegrationTile = ({ // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( - environments.find(({ slug }) => slug === integration.environment) || { + environments.find(({ slug }) => slug === integration?.environment) || { name: '', slug: '' } @@ -69,11 +69,10 @@ const IntegrationTile = ({ useEffect(() => { const loadIntegration = async () => { - const tempApps: [IntegrationApp] = await getIntegrationApps({ - integrationAuthId: integration.integrationAuth + integrationAuthId: integration?.integrationAuth }); - + setApps(tempApps); if (integration?.app) { @@ -90,15 +89,16 @@ const IntegrationTile = ({ case 'vercel': setIntegrationTargetEnvironment( integration?.targetEnvironment - ? integration.targetEnvironment.charAt(0).toUpperCase() + integration.targetEnvironment.substring(1) - : 'Development' + ? integration.targetEnvironment.charAt(0).toUpperCase() + + integration.targetEnvironment.substring(1) + : 'Development' ); break; case 'netlify': setIntegrationTargetEnvironment( - integration?.targetEnvironment - ? contextNetlifyMapping[integration.targetEnvironment] - : 'Local development' + integration?.targetEnvironment + ? contextNetlifyMapping[integration.targetEnvironment] + : 'Local development' ); break; default: @@ -108,7 +108,7 @@ const IntegrationTile = ({ loadIntegration(); }, []); - + const handleStartIntegration = async () => { const reformatTargetEnvironment = (targetEnvironment: string) => { switch (integration.integration) { @@ -119,13 +119,13 @@ const IntegrationTile = ({ 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, @@ -136,15 +136,15 @@ const IntegrationTile = ({ targetEnvironment: reformatTargetEnvironment(integrationTargetEnvironment), owner }); - + setIntegrations( - integrations.map((i) => i._id === updatedIntegration._id ? updatedIntegration : i) + 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: Integration) => { try { @@ -152,7 +152,7 @@ const IntegrationTile = ({ case 'vercel': return (
-
ENVIRONMENT
+
ENVIRONMENT
-
CONTEXT
+
CONTEXT
; return ( -
+
-

ENVIRONMENT

+

ENVIRONMENT

name) : null} isSelected={integrationEnvironment.name} @@ -208,7 +208,7 @@ const IntegrationTile = ({ />
- +

INTEGRATION

@@ -218,7 +218,7 @@ const IntegrationTile = ({
-
APP
+
APP
app.name) : null} isSelected={integrationApp} @@ -231,9 +231,9 @@ const IntegrationTile = ({
{integration.isActive ? ( -
- -
In Sync
+
+ +
In Sync
) : ( + +
+ ) +} + +CircleCICreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/circleci/create.tsx b/frontend/src/pages/integrations/circleci/create.tsx new file mode 100644 index 000000000..79d144d53 --- /dev/null +++ b/frontend/src/pages/integrations/circleci/create.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + Select, + SelectItem +} from '../../../components/v2'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function CircleCICreateIntegrationPage() { + const router = useRouter(); + + const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); + const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); + const [targetApp, setTargetApp] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + // TODO: handle case where apps can be empty + if (integrationAuthApps) { + setTargetApp(integrationAuthApps[0]?.name); + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + owner: null, + path: null, + region: null, + }); + + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( +
+ + CircleCI Integration + + + + + + + + +
+ ) :
+} + +CircleCICreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file