From 68bf0b9efe105eb55dbc517b432f390250968d71 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 6 Feb 2023 17:57:47 +0700 Subject: [PATCH 01/11] Finish v1 Azure Key Vault integration --- backend/src/config/index.ts | 6 + .../v1/integrationAuthController.ts | 10 +- .../controllers/v1/integrationController.ts | 3 + backend/src/helpers/integration.ts | 8 +- backend/src/integrations/apps.ts | 15 ++ backend/src/integrations/exchange.ts | 59 +++++++ backend/src/integrations/refresh.ts | 80 +++++++-- backend/src/integrations/sync.ts | 154 +++++++++++++++- backend/src/models/integration.ts | 4 +- backend/src/models/integrationAuth.ts | 4 +- backend/src/services/IntegrationService.ts | 21 ++- backend/src/variables/index.ts | 4 + backend/src/variables/integration.ts | 19 ++ frontend/public/data/frequentConstants.ts | 11 ++ frontend/src/components/basic/Layout.tsx | 4 +- .../components/integrations/Integration.tsx | 17 +- .../WorkspaceContext/WorkspaceContext.tsx | 2 +- .../api/integrations/authorizeIntegration.ts | 2 +- frontend/src/pages/azure-key-vault.tsx | 166 ++++++++++++++++++ frontend/src/pages/github.tsx | 2 +- frontend/src/pages/integrations/[id].tsx | 18 +- 21 files changed, 560 insertions(+), 49 deletions(-) create mode 100644 frontend/src/pages/azure-key-vault.tsx diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 2ecd1af8c..ad1acb1a6 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -13,10 +13,13 @@ const MONGO_URL = process.env.MONGO_URL!; const NODE_ENV = process.env.NODE_ENV! || 'production'; const VERBOSE_ERROR_OUTPUT = process.env.VERBOSE_ERROR_OUTPUT! === 'true' && true; const LOKI_HOST = process.env.LOKI_HOST || undefined; +const CLIENT_ID_AZURE = process.env.CLIENT_ID_AZURE!; +const TENANT_ID_AZURE = process.env.TENANT_ID_AZURE!; const CLIENT_ID_HEROKU = process.env.CLIENT_ID_HEROKU!; const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!; const CLIENT_ID_NETLIFY = process.env.CLIENT_ID_NETLIFY!; const CLIENT_ID_GITHUB = process.env.CLIENT_ID_GITHUB!; +const CLIENT_SECRET_AZURE = process.env.CLIENT_SECRET_AZURE!; const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!; @@ -60,10 +63,13 @@ export { NODE_ENV, VERBOSE_ERROR_OUTPUT, LOKI_HOST, + CLIENT_ID_AZURE, + TENANT_ID_AZURE, CLIENT_ID_HEROKU, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, CLIENT_ID_GITHUB, + CLIENT_SECRET_AZURE, CLIENT_SECRET_HEROKU, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b030b79d6..cedc7e345 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -31,7 +31,7 @@ export const oAuthExchange = async ( ) => { try { const { workspaceId, code, integration } = req.body; - + if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); @@ -40,12 +40,14 @@ export const oAuthExchange = async ( throw new Error("Failed to get environments") } - await IntegrationService.handleOAuthExchange({ + const integrationDetails = await IntegrationService.handleOAuthExchange({ workspaceId, integration, code, environment: environments[0].slug, }); + + return res.status(200).send(integrationDetails); } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); @@ -53,10 +55,6 @@ export const oAuthExchange = async ( message: 'Failed to get OAuth2 code-token exchange' }); } - - return res.status(200).send({ - message: 'Successfully enabled integration authorization' - }); }; /** diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 4e66ce0aa..2b52f9725 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -16,6 +16,9 @@ import { eventPushSecrets } from '../../events'; * @returns */ export const createIntegration = async (req: Request, res: Response) => { + + // TODO: make this more versatile + let integration; try { // initialize new integration after saving integration access token diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 1618e5e67..595dbbb6f 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -44,6 +44,7 @@ const handleOAuthExchangeHelper = async ({ }) => { let action; let integrationAuth; + let newIntegration; try { const bot = await Bot.findOne({ workspace: workspaceId, @@ -100,7 +101,7 @@ const handleOAuthExchangeHelper = async ({ } // initialize new integration after exchange - await new Integration({ + newIntegration = await new Integration({ workspace: workspaceId, isActive: false, app: null, @@ -113,6 +114,11 @@ const handleOAuthExchangeHelper = async ({ Sentry.captureException(err); throw new Error('Failed to handle OAuth2 code-token exchange') } + + return ({ + integrationAuth, + integration: newIntegration + }); } /** * Sync/push environment variables in workspace with id [workspaceId] to diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index d1252954e..50f146be5 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/node'; import { Octokit } from '@octokit/rest'; import { IIntegrationAuth } from '../models'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -40,6 +41,11 @@ const getApps = async ({ let apps: App[]; try { switch (integrationAuth.integration) { + case INTEGRATION_AZURE_KEY_VAULT: + apps = await getAppsAzureKeyVault({ + accessToken + }); + break; case INTEGRATION_HEROKU: apps = await getAppsHeroku({ accessToken @@ -81,6 +87,15 @@ const getApps = async ({ return apps; }; +const getAppsAzureKeyVault = async ({ + accessToken +}: { + accessToken: string; +}) => { + // TODO + return []; +} + /** * Return list of apps for Heroku integration * @param {Object} obj diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 26aca5fdb..ada2b76bd 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -1,10 +1,12 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, @@ -12,15 +14,27 @@ import { } from '../variables'; import { SITE_URL, + CLIENT_ID_AZURE, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, CLIENT_ID_GITHUB, + CLIENT_SECRET_AZURE, CLIENT_SECRET_HEROKU, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, CLIENT_SECRET_GITHUB } from '../config'; +interface ExchangeCodeAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +} + interface ExchangeCodeHerokuResponse { token_type: string; access_token: string; @@ -75,6 +89,11 @@ const exchangeCode = async ({ try { switch (integration) { + case INTEGRATION_AZURE_KEY_VAULT: + obj = await exchangeCodeAzure({ + code + }); + break; case INTEGRATION_HEROKU: obj = await exchangeCodeHeroku({ code @@ -105,6 +124,46 @@ const exchangeCode = async ({ return obj; }; +/** + * Return [accessToken] for Azure OAuth2 code-token exchange + * @param param0 + */ +const exchangeCodeAzure = async ({ + code +}: { + code: string; +}) => { + const accessExpiresAt = new Date(); + let res: ExchangeCodeAzureResponse; + try { + res = (await axios.post( + INTEGRATION_AZURE_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + scope: 'https://vault.azure.net/.default openid offline_access', // TODO: do we need all these permissions? + client_id: CLIENT_ID_AZURE, + client_secret: CLIENT_SECRET_AZURE, + redirect_uri: `${SITE_URL}/azure-key-vault` + } as any) + )).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); + } catch (err: any) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Azure'); + } + + return ({ + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }); +} + /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku * OAuth2 code-token exchange diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index ea232f1e5..4fdbcdbbb 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,13 +1,26 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; -import { INTEGRATION_HEROKU } from '../variables'; +import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU } from '../variables'; import { - CLIENT_SECRET_HEROKU + SITE_URL, + CLIENT_ID_AZURE, + CLIENT_SECRET_AZURE, + CLIENT_SECRET_HEROKU } from '../config'; import { - INTEGRATION_HEROKU_TOKEN_URL + INTEGRATION_AZURE_TOKEN_URL, + INTEGRATION_HEROKU_TOKEN_URL } from '../variables'; +interface RefreshTokenAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: 4871; + access_token: string; + refresh_token: string; +} + /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] @@ -25,6 +38,11 @@ const exchangeRefresh = async ({ let accessToken; try { switch (integration) { + case INTEGRATION_AZURE_KEY_VAULT: + accessToken = await exchangeRefreshAzure({ + refreshToken + }); + break; case INTEGRATION_HEROKU: accessToken = await exchangeRefreshHeroku({ refreshToken @@ -40,6 +58,38 @@ const exchangeRefresh = async ({ return accessToken; }; +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * Azure integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for Azure + * @returns + */ +const exchangeRefreshAzure = async ({ + refreshToken +}: { + refreshToken: string; +}) => { + try { + const res: RefreshTokenAzureResponse = (await axios.post( + INTEGRATION_AZURE_TOKEN_URL, + new URLSearchParams({ + client_id: CLIENT_ID_AZURE, + scope: 'openid offline_access', + refresh_token: refreshToken, + grant_type: 'refresh_token', + client_secret: CLIENT_SECRET_AZURE + } as any) + )).data; + + return res.access_token; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get refresh OAuth2 access token for Azure'); + } +} + /** * Return new access token by exchanging refresh token [refreshToken] for the * Heroku integration @@ -52,23 +102,23 @@ const exchangeRefreshHeroku = async ({ }: { refreshToken: string; }) => { - let accessToken; - //TODO: Refactor code to take advantage of using RequestError. It's possible to create new types of errors for more detailed errors - try { - const res = await axios.post( - INTEGRATION_HEROKU_TOKEN_URL, - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_secret: CLIENT_SECRET_HEROKU - } as any) - ); + + let accessToken; + try { + const res = await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: CLIENT_SECRET_HEROKU + } as any) + ); accessToken = res.data.access_token; } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get new OAuth2 access token for Heroku'); + throw new Error('Failed to refresh OAuth2 access token for Heroku'); } return accessToken; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 9954943b8..4604d744a 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -6,6 +6,7 @@ import sodium from 'libsodium-wrappers'; // const sodium = require('libsodium-wrappers'); import { IIntegration, IIntegrationAuth } from '../models'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -18,7 +19,6 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL } from '../variables'; -import { access, appendFile } from 'fs'; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -41,6 +41,13 @@ const syncSecrets = async ({ }) => { try { switch (integration.integration) { + case INTEGRATION_AZURE_KEY_VAULT: + await syncSecretsAzureKeyVault({ + integration, + secrets, + accessToken + }); + break; case INTEGRATION_HEROKU: await syncSecretsHeroku({ integration, @@ -93,6 +100,151 @@ const syncSecrets = async ({ } }; +/** + * Sync/push [secrets] to Azure Key Vault with vault URI [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Azure Key Vault integration + */ +const syncSecretsAzureKeyVault = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + + interface GetAzureKeyVaultSecret { + id: string; // secret URI + attributes: { + enabled: true, + created: number; + updated: number; + recoveryLevel: string; + recoverableDays: number; + } + } + + interface AzureKeyVaultSecret extends GetAzureKeyVaultSecret { + key: string; + } + + /** + * Return all secrets from Azure Key Vault by paginating through URL [url] + * @param {String} url - pagination URL to get next set of secrets from Azure Key Vault + * @returns + */ + const paginateAzureKeyVaultSecrets = async (url: string) => { + let result: GetAzureKeyVaultSecret[] = []; + + while (url) { + const res = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + result = result.concat(res.data.value); + url = res.data.nextLink; + } + + return result; + } + + const getAzureKeyVaultSecrets = await paginateAzureKeyVaultSecrets(`${integration.app}/secrets?api-version=7.3`); + + let lastSlashIndex: number; + const res = (await Promise.all(getAzureKeyVaultSecrets.map(async (getAzureKeyVaultSecret) => { + if (!lastSlashIndex) { + lastSlashIndex = getAzureKeyVaultSecret.id.lastIndexOf('/'); + } + + const azureKeyVaultSecret = await axios.get(`${getAzureKeyVaultSecret.id}?api-version=7.3`, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + return ({ + ...azureKeyVaultSecret.data, + key: getAzureKeyVaultSecret.id.substring(lastSlashIndex + 1), + }); + }))) + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), {}); + + const setSecrets: { + key: string; + value: string; + }[] = []; + + Object.keys(secrets).forEach((key) => { + const hyphenatedKey = key.replace(/_/g, '-'); + if (!(hyphenatedKey in res)) { + // case: secret has been created + setSecrets.push({ + key: hyphenatedKey, + value: secrets[key] + }); + } else { + if (secrets[key] !== res[hyphenatedKey].value) { + // case: secret has been updated + setSecrets.push({ + key: hyphenatedKey, + value: secrets[key] + }); + } + } + }); + + const deleteSecrets: AzureKeyVaultSecret[] = []; + + Object.keys(res).forEach((key) => { + const underscoredKey = key.replace(/-/g, '_'); + if (!(underscoredKey in secrets)) { + deleteSecrets.push(res[key]); + } + }); + + // Sync/push set secrets + if (setSecrets.length > 0) { + setSecrets.forEach(async ({ key, value }) => { + await axios.put( + `${integration.app}/secrets/${key}?api-version=7.3`, + { + value + }, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret) => { + await axios.delete(`${integration.app}/secrets/${secret.key}?api-version=7.3`, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Azure Key Vault'); + } +}; + /** * Sync/push [secrets] to Heroku app named [integration.app] * @param {Object} obj diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 01e1d7ee3..9c1214fb5 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,5 +1,6 @@ import { Schema, model, Types } from 'mongoose'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -17,7 +18,7 @@ export interface IIntegration { owner: string; targetEnvironment: string; appId: string; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault'; integrationAuth: Types.ObjectId; } @@ -59,6 +60,7 @@ const integrationSchema = new Schema( integration: { type: String, enum: [ + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index bff56f09a..0eaa8c9e1 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,5 +1,6 @@ import { Schema, model, Types } from 'mongoose'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -9,7 +10,7 @@ import { export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -31,6 +32,7 @@ const integrationAuthSchema = new Schema( integration: { type: String, enum: [ + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 4ee991cdd..cb452f8c8 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -1,7 +1,3 @@ -import * as Sentry from '@sentry/node'; -import { - Integration -} from '../models'; import { handleOAuthExchangeHelper, syncIntegrationsHelper, @@ -10,7 +6,6 @@ import { setIntegrationAuthRefreshHelper, setIntegrationAuthAccessHelper, } from '../helpers/integration'; -import { exchangeCode } from '../integrations'; // should sync stuff be here too? Probably. // TODO: move bot functions to IntegrationService. @@ -26,11 +21,15 @@ class IntegrationService { * - Store integration access and refresh tokens returned from the OAuth2 code-token exchange * - Add placeholder inactive integration * - Create bot sequence for integration - * @param {Object} obj - * @param {String} obj.workspaceId - id of workspace - * @param {String} obj.environment - workspace environment - * @param {String} obj.integration - name of integration - * @param {String} obj.code - code + * @param {Object} obj1 + * @param {String} obj1.workspaceId - id of workspace + * @param {String} obj1.environment - workspace environment + * @param {String} obj1.integration - name of integration + * @param {String} obj1.code - code + * @returns {Object} obj2 + * @returns {IntegrationAuth} obj2.integrationAuth - integration authorization after OAuth2 code-token exchange + * @returns {Integration} obj2.integration - newly-initialized integration OAuth2 code-token exchange + * @retrun */ static async handleOAuthExchange({ workspaceId, @@ -43,7 +42,7 @@ class IntegrationService { code: string; environment: string; }) { - await handleOAuthExchangeHelper({ + return await handleOAuthExchangeHelper({ workspaceId, integration, code, diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index fafab6939..cf061520c 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -6,6 +6,7 @@ import { ENV_SET } from './environment'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -14,6 +15,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, @@ -58,6 +60,7 @@ export { ENV_STAGING, ENV_PROD, ENV_SET, + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -66,6 +69,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 7cecb54c2..0cac80984 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -1,3 +1,7 @@ +import { + CLIENT_ID_AZURE, + TENANT_ID_AZURE +} from '../config'; import { CLIENT_ID_HEROKU, CLIENT_ID_NETLIFY, @@ -6,6 +10,7 @@ import { } from '../config'; // integrations +const INTEGRATION_AZURE_KEY_VAULT = 'azure-key-vault'; const INTEGRATION_HEROKU = 'heroku'; const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_NETLIFY = 'netlify'; @@ -13,6 +18,7 @@ const INTEGRATION_GITHUB = 'github'; const INTEGRATION_RENDER = 'render'; const INTEGRATION_FLYIO = 'flyio'; const INTEGRATION_SET = new Set([ + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -25,6 +31,7 @@ const INTEGRATION_SET = new Set([ 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'; @@ -40,6 +47,16 @@ const INTEGRATION_RENDER_API_URL = 'https://api.render.com'; const INTEGRATION_FLYIO_API_URL = 'https://api.fly.io/graphql'; const INTEGRATION_OPTIONS = [ + { + name: 'Azure Key Vault', + slug: 'azure-key-vault', + image: 'Microsoft Azure.png', + isAvailable: true, + type: 'oauth', + clientId: CLIENT_ID_AZURE, + tenantId: TENANT_ID_AZURE, + docsLink: '' + }, { name: 'Heroku', slug: 'heroku', @@ -143,6 +160,7 @@ const INTEGRATION_OPTIONS = [ ] export { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -151,6 +169,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 2ff525e54..6ac74cdbd 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -2,6 +2,16 @@ interface Mapping { [key: string]: string; } +const integrationSlugNameMapping: Mapping = { + 'azure-key-vault': 'Azure Key Vault', + 'heroku': 'Heroku', + 'vercel': 'Vercel', + 'netlify': 'Netlify', + 'github': 'GitHub', + 'render': 'Render', + 'flyio': 'Fly.io' +} + const envMapping: Mapping = { Development: "dev", Staging: "staging", @@ -49,6 +59,7 @@ const plans = plansProd || plansDev; export { contextNetlifyMapping, envMapping, + integrationSlugNameMapping, plans, reverseContextNetlifyMapping, reverseEnvMapping} diff --git a/frontend/src/components/basic/Layout.tsx b/frontend/src/components/basic/Layout.tsx index ba4cf73b2..1f1ee246d 100644 --- a/frontend/src/components/basic/Layout.tsx +++ b/frontend/src/components/basic/Layout.tsx @@ -199,13 +199,13 @@ const Layout = ({ children }: LayoutProps) => { .split('/') [router.asPath.split('/').length - 1].split('?')[0]; - if (!['heroku', 'vercel', 'github', 'netlify'].includes(intendedWorkspaceId)) { + if (!['heroku', 'vercel', 'github', 'netlify', 'azure-key-vault'].includes(intendedWorkspaceId)) { localStorage.setItem('projectData.id', intendedWorkspaceId); } // If a user is not a member of a workspace they are trying to access, just push them to one of theirs if ( - !['heroku', 'vercel', 'github', 'netlify'].includes(intendedWorkspaceId) && + !['heroku', 'vercel', 'github', 'netlify', 'azure-key-vault'].includes(intendedWorkspaceId) && !userWorkspaces .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) diff --git a/frontend/src/components/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index abec74f9d..6bbecad43 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; import { faArrowRight, faRotate, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; // TODO: This needs to be moved from public folder -import { contextNetlifyMapping, reverseContextNetlifyMapping } from 'public/data/frequentConstants'; +import { contextNetlifyMapping, integrationSlugNameMapping, reverseContextNetlifyMapping } from 'public/data/frequentConstants'; import Button from '@app/components/basic/buttons/Button'; import ListBox from '@app/components/basic/Listbox'; @@ -52,6 +52,7 @@ const IntegrationTile = ({ environments = [], handleDeleteIntegration }: Props) => { + // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( environments.find(({ slug }) => slug === integration.environment) || { @@ -72,7 +73,14 @@ const IntegrationTile = ({ }); setApps(tempApps); - setIntegrationApp(integration.app ? integration.app : tempApps[0].name); + + if (integration?.app) { + setIntegrationApp(integration.app); + } else if (tempApps.length > 0) { + setIntegrationApp(tempApps[0].name) + } else { + setIntegrationApp(''); + } switch (integration.integration) { case 'vercel': @@ -174,7 +182,7 @@ const IntegrationTile = ({ return
; }; - if (!integrationApp || apps.length === 0) return
; + if (!integrationApp) return
; return (
@@ -201,7 +209,8 @@ const IntegrationTile = ({

INTEGRATION

- {integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)} + {/* {integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)} */} + {integrationSlugNameMapping[integration.integration]}
diff --git a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx index ea82fbbe7..e32ea2598 100644 --- a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx +++ b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx @@ -35,7 +35,7 @@ export const WorkspaceProvider = ({ children }: Props): JSX.Element => { // ws empty means user has no access to the ws // push to the first workspace if (!isLoading && !value?.currentWorkspace?._id) { - router.push(`/dashboard/${value.workspaces?.[0]?._id}`); + // router.push(`/dashboard/${value.workspaces?.[0]?._id}`); } }, [value?.currentWorkspace?._id, isLoading, value.workspaces?.[0]?._id, router.pathname]); diff --git a/frontend/src/pages/api/integrations/authorizeIntegration.ts b/frontend/src/pages/api/integrations/authorizeIntegration.ts index 94f1d54f5..666499be6 100644 --- a/frontend/src/pages/api/integrations/authorizeIntegration.ts +++ b/frontend/src/pages/api/integrations/authorizeIntegration.ts @@ -26,7 +26,7 @@ const AuthorizeIntegration = ({ workspaceId, code, integration }: Props) => }) }).then(async (res) => { if (res && res.status === 200) { - return res; + return (res.json()); } console.log('Failed to authorize the integration'); return undefined; diff --git a/frontend/src/pages/azure-key-vault.tsx b/frontend/src/pages/azure-key-vault.tsx new file mode 100644 index 000000000..57f861387 --- /dev/null +++ b/frontend/src/pages/azure-key-vault.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from '../components/v2'; +import AuthorizeIntegration from './api/integrations/authorizeIntegration'; +import updateIntegration from './api/integrations/updateIntegration'; +import getAWorkspace from './api/workspace/getAWorkspace'; + +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; +} + +export default function AzureKeyVault() { + const router = useRouter(); + + // query-string variables + const parsedUrl = queryString.parse(router.asPath.split('?')[1]); + const {code} = parsedUrl; + const {state} = parsedUrl; + + const [integration, setIntegration] = useState(null); + const [environments, setEnvironments] = useState< + { + name: string; + slug: string; + }[] + >([]); + const [environment, setEnvironment] = useState(''); + const [vaultBaseUrl, setVaultBaseUrl] = useState(''); + const [vaultBaseUrlErrorText, setVaultBaseUrlErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + try { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + const integrationDetails = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'azure-key-vault', + }); + + setIntegration(integrationDetails.integration); + + const workspaceId = localStorage.getItem('projectData.id'); + if (!workspaceId) return; + + const workspace = await getAWorkspace(workspaceId); + setEnvironment(workspace.environments[0].slug); + setEnvironments(workspace.environments); + + } + } catch (error) { + console.error('Azure Key Vault integration error: ', error); + } + })(); + }, []); + + const handleButtonClick = async () => { + try { + if (vaultBaseUrl.length === 0) { + setVaultBaseUrlErrorText('Vault URI cannot be blank'); + return; + } + + if ( + !vaultBaseUrl.startsWith('https://') + || !vaultBaseUrl.endsWith('vault.azure.net') + ) { + setVaultBaseUrlErrorText('Vault URI must be like https://.vault.azure.net'); + return; + } + + if (!integration) return; + + setIsLoading(true); + await updateIntegration({ + integrationId: integration._id, + isActive: true, + environment, + app: vaultBaseUrl, + appId: null, + targetEnvironment: null, + owner: null + }); + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + + } catch (err) { + console.error(err); + } + } + + return (integration && environments.length > 0) ? ( +
+ + Azure Key Vault Integration + + + + + setVaultBaseUrl(e.target.value)} + /> + + + +
+ ) :
+} + +AzureKeyVault.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/github.tsx b/frontend/src/pages/github.tsx index 897027246..37c35b194 100644 --- a/frontend/src/pages/github.tsx +++ b/frontend/src/pages/github.tsx @@ -25,7 +25,7 @@ export default function Github() { integration: 'github', }); router.push( - `/integrations/${ localStorage.getItem('projectData.id')}` + `/integrations/${localStorage.getItem('projectData.id')}` ); } } catch (error) { diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index a4bf83a89..688d7c34f 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -195,6 +195,11 @@ export default function Integrations() { localStorage.setItem('latestCSRFToken', state); switch (integrationOption.slug) { + case 'azure-key-vault': + window.location.assign( + `https://login.microsoftonline.com/${integrationOption.tenantId}/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/azure-key-vault&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}` + ); + break; case 'heroku': window.location.assign( `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}` @@ -275,10 +280,15 @@ export default function Integrations() { // case: integration has been authorized before // -> create new integration - const integration = await createIntegration({ - integrationAuthId: integrationAuthX._id - }); - setIntegrations([...integrations, integration]); + + if (!['azure-key-vault'].includes(integrationOption.slug)) { + const integration = await createIntegration({ + integrationAuthId: integrationAuthX._id + }); + setIntegrations([...integrations, integration]); + } else { + handleIntegrationOption({ integrationOption }); + } } catch (err) { console.error(err); } From 5ea5887146da207168ef89149ce9085192a2a512 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 7 Feb 2023 11:48:17 +0700 Subject: [PATCH 02/11] Begin refactoring all integrations to separate integration pages by step --- backend/package-lock.json | 100 +++--------- .../v1/integrationAuthController.ts | 6 +- .../controllers/v1/integrationController.ts | 24 ++- backend/src/helpers/integration.ts | 26 ++-- backend/src/routes/v1/integration.ts | 9 +- backend/src/services/IntegrationService.ts | 5 +- frontend/src/hooks/api/workspace/index.tsx | 1 + frontend/src/hooks/api/workspace/queries.tsx | 16 +- .../api/integrations/authorizeIntegration.ts | 2 +- .../api/integrations/createIntegration.ts | 12 +- frontend/src/pages/azure-key-vault.tsx | 93 +++++------ frontend/src/pages/heroku.tsx | 144 ++++++++++++++++-- frontend/src/pages/integrations/[id].tsx | 4 +- 13 files changed, 258 insertions(+), 184 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 6703e198f..189b1eccd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,13 +10,13 @@ "license": "ISC", "dependencies": { "@godaddy/terminus": "^4.11.2", - "@sentry/node": "^7.21.1", "@octokit/rest": "^19.0.5", - "@sentry/tracing": "^7.21.1", + "@sentry/node": "^7.14.0", + "@sentry/tracing": "^7.19.0", "@types/crypto-js": "^4.1.1", - "axios": "^1.2.0", "@types/libsodium-wrappers": "^0.7.10", "await-to-js": "^3.0.0", + "axios": "^1.1.3", "bcrypt": "^5.1.0", "bigint-conversion": "^2.2.2", "builder-pattern": "^2.2.0", @@ -32,9 +32,9 @@ "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jsrp": "^0.2.4", - "mongoose": "^6.7.3", "libsodium-wrappers": "^0.7.10", "lodash": "^4.17.21", + "mongoose": "^6.7.2", "nodemailer": "^6.8.0", "posthog-node": "^2.2.2", "query-string": "^7.1.3", @@ -2838,19 +2838,6 @@ "@maxmind/geoip2-node": "^3.4.0" } }, - "node_modules/@sentry/core": { - "version": "7.21.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.21.1.tgz", - "integrity": "sha512-Og5wEEsy24fNvT/T7IKjcV4EvVK5ryY2kxbJzKY6GU2eX+i+aBl+n/vp7U0Es351C/AlTkS+0NOUsp2TQQFxZA==", - "dependencies": { - "@sentry/types": "7.21.1", - "@sentry/utils": "7.21.1", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2905,27 +2892,10 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@sentry/node": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.19.0.tgz", - "integrity": "sha512-yG7Tx32WqOkEHVotFLrumCcT9qlaSDTkFNZ+yLSvZXx74ifsE781DzBA9W7K7bBdYO3op+p2YdsOKzf3nPpAyQ==", - "dependencies": { - "@sentry/core": "7.19.0", - "@sentry/types": "7.19.0", - "@sentry/utils": "7.19.0", - "cookie": "^0.4.1", - "https-proxy-agent": "^5.0.0", - "lru_map": "^0.3.3", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry/node/node_modules/@sentry/core": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.19.0.tgz", - "integrity": "sha512-YF9cTBcAnO4R44092BJi5Wa2/EO02xn2ziCtmNgAVTN2LD31a/YVGxGBt/FDr4Y6yeuVehaqijVVvtpSmXrGJw==", + "node_modules/@sentry/core": { + "version": "7.21.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.21.1.tgz", + "integrity": "sha512-Og5wEEsy24fNvT/T7IKjcV4EvVK5ryY2kxbJzKY6GU2eX+i+aBl+n/vp7U0Es351C/AlTkS+0NOUsp2TQQFxZA==", "dependencies": { "@sentry/types": "7.21.1", "@sentry/utils": "7.21.1", @@ -2986,26 +2956,6 @@ "node": ">=8" } }, - "node_modules/@sentry/types": { - "version": "7.21.1", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.21.1.tgz", - "integrity": "sha512-3/IKnd52Ol21amQvI+kz+WB76s8/LR5YvFJzMgIoI2S8d82smIr253zGijRXxHPEif8kMLX4Yt+36VzrLxg6+A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry/utils": { - "version": "7.21.1", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.21.1.tgz", - "integrity": "sha512-F0W0AAi8tgtTx6ApZRI2S9HbXEA9ENX1phTZgdNNWcMFm1BNbc21XEwLqwXBNjub5nlA6CE8xnjXRgdZKx4kzQ==", - "dependencies": { - "@sentry/types": "7.21.1", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -14306,16 +14256,6 @@ "@maxmind/geoip2-node": "^3.4.0" } }, - "@sentry/core": { - "version": "7.21.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.21.1.tgz", - "integrity": "sha512-Og5wEEsy24fNvT/T7IKjcV4EvVK5ryY2kxbJzKY6GU2eX+i+aBl+n/vp7U0Es351C/AlTkS+0NOUsp2TQQFxZA==", - "requires": { - "@sentry/types": "7.21.1", - "@sentry/utils": "7.21.1", - "tslib": "^1.9.3" - } - }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -14370,6 +14310,16 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "@sentry/core": { + "version": "7.21.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.21.1.tgz", + "integrity": "sha512-Og5wEEsy24fNvT/T7IKjcV4EvVK5ryY2kxbJzKY6GU2eX+i+aBl+n/vp7U0Es351C/AlTkS+0NOUsp2TQQFxZA==", + "requires": { + "@sentry/types": "7.21.1", + "@sentry/utils": "7.21.1", + "tslib": "^1.9.3" + } + }, "@sentry/node": { "version": "7.21.1", "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.21.1.tgz", @@ -14409,20 +14359,6 @@ "tslib": "^1.9.3" } }, - "@sentry/types": { - "version": "7.21.1", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.21.1.tgz", - "integrity": "sha512-3/IKnd52Ol21amQvI+kz+WB76s8/LR5YvFJzMgIoI2S8d82smIr253zGijRXxHPEif8kMLX4Yt+36VzrLxg6+A==" - }, - "@sentry/utils": { - "version": "7.21.1", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.21.1.tgz", - "integrity": "sha512-F0W0AAi8tgtTx6ApZRI2S9HbXEA9ENX1phTZgdNNWcMFm1BNbc21XEwLqwXBNjub5nlA6CE8xnjXRgdZKx4kzQ==", - "requires": { - "@sentry/types": "7.21.1", - "tslib": "^1.9.3" - } - }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index cedc7e345..747004f44 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -40,14 +40,16 @@ export const oAuthExchange = async ( throw new Error("Failed to get environments") } - const integrationDetails = await IntegrationService.handleOAuthExchange({ + const integrationAuth = await IntegrationService.handleOAuthExchange({ workspaceId, integration, code, environment: environments[0].slug, }); - return res.status(200).send(integrationDetails); + return res.status(200).send({ + integrationAuth + }); } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 2b52f9725..0ffb8cbb8 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { Integration, @@ -16,20 +17,31 @@ import { eventPushSecrets } from '../../events'; * @returns */ export const createIntegration = async (req: Request, res: Response) => { - - // TODO: make this more versatile - let integration; try { + const { + integrationAuthId, + app, + appId, + isActive, + targetEnvironment, + owner + } = req.body; + // 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, + isActive, + app, + appId, + targetEnvironment, integration: req.integrationAuth.integration, - integrationAuth: req.integrationAuth._id + integrationAuth: new Types.ObjectId(integrationAuthId) }).save(); + + // TODO: run sync function + } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 595dbbb6f..30e370188 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -30,6 +30,7 @@ interface Update { * @param {String} obj.workspaceId - id of workspace * @param {String} obj.integration - name of integration * @param {String} obj.code - code + * @returns {IntegrationAuth} integrationAuth - integration auth after OAuth2 code-token exchange */ const handleOAuthExchangeHelper = async ({ workspaceId, @@ -44,7 +45,7 @@ const handleOAuthExchangeHelper = async ({ }) => { let action; let integrationAuth; - let newIntegration; + // let newIntegration; try { const bot = await Bot.findOne({ workspace: workspaceId, @@ -100,25 +101,22 @@ const handleOAuthExchangeHelper = async ({ }); } - // initialize new integration after exchange - newIntegration = await new Integration({ - workspace: workspaceId, - isActive: false, - app: null, - environment, - integration, - integrationAuth: integrationAuth._id - }).save(); + // // initialize new integration after exchange + // newIntegration = await new Integration({ + // workspace: workspaceId, + // isActive: false, + // app: null, + // environment, + // integration, + // integrationAuth: integrationAuth._id + // }).save(); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to handle OAuth2 code-token exchange') } - return ({ - integrationAuth, - integration: newIntegration - }); + return integrationAuth; } /** * Sync/push environment variables in workspace with id [workspaceId] to diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index d587bd2e5..3db9c412e 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -10,7 +10,7 @@ import { ADMIN, MEMBER } from '../../variables'; import { body, param } from 'express-validator'; import { integrationController } from '../../controllers/v1'; -router.post( // new: add new integration +router.post( // new: add new integration for integration auth '/', requireAuth({ acceptedAuthModes: ['jwt', 'apiKey'] @@ -19,7 +19,12 @@ router.post( // new: add new integration acceptedRoles: [ADMIN, MEMBER], location: 'body' }), - body('integrationAuthId').exists().trim(), + body('integrationAuthId').exists().isString().trim(), + body('app').isString().trim(), + body('isActive').exists().isBoolean(), + body('appId').trim(), + body('targetEnvironment').trim(), + body('owner').trim(), validateRequest, integrationController.createIntegration ); diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index cb452f8c8..5b0195427 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -26,10 +26,7 @@ class IntegrationService { * @param {String} obj1.environment - workspace environment * @param {String} obj1.integration - name of integration * @param {String} obj1.code - code - * @returns {Object} obj2 - * @returns {IntegrationAuth} obj2.integrationAuth - integration authorization after OAuth2 code-token exchange - * @returns {Integration} obj2.integration - newly-initialized integration OAuth2 code-token exchange - * @retrun + * @returns {IntegrationAuth} integrationAuth - integration authorization after OAuth2 code-token exchange */ static async handleOAuthExchange({ workspaceId, diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 5de95ab05..67d8b9dea 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -3,6 +3,7 @@ export { useDeleteWorkspace, useDeleteWsEnvironment, useGetUserWorkspaces, + useGetWorkspaceById, useRenameWorkspace, useUpdateWsEnvironment } from './queries'; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index b0566a43d..efdf4a6a3 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -11,16 +11,30 @@ import { Workspace } from './types'; + const workspaceKeys = { + getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const, getAllUserWorkspace: ['workspaces'] as const }; +const fetchWorkspaceById = async (workspaceId: string) => { + const { data } = await apiRequest.get<{ workspace: Workspace }>(`/api/v1/workspace/${workspaceId}`); + return data.workspace; +} + const fetchUserWorkspaces = async () => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace'); - return data.workspaces; }; +export const useGetWorkspaceById = (workspaceId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceById(workspaceId), + queryFn: () => fetchWorkspaceById(workspaceId), + enabled: true + }); +}; + export const useGetUserWorkspaces = () => useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); diff --git a/frontend/src/pages/api/integrations/authorizeIntegration.ts b/frontend/src/pages/api/integrations/authorizeIntegration.ts index 666499be6..9c70796b7 100644 --- a/frontend/src/pages/api/integrations/authorizeIntegration.ts +++ b/frontend/src/pages/api/integrations/authorizeIntegration.ts @@ -26,7 +26,7 @@ const AuthorizeIntegration = ({ workspaceId, code, integration }: Props) => }) }).then(async (res) => { if (res && res.status === 200) { - return (res.json()); + return (await res.json()).integrationAuth; } console.log('Failed to authorize the integration'); return undefined; diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts index 6b37f1281..c2da2378a 100644 --- a/frontend/src/pages/api/integrations/createIntegration.ts +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -1,7 +1,9 @@ import SecurityClient from '@app/components/utilities/SecurityClient'; interface Props { - integrationAuthId: string; + integrationAuthId: string; + isActive: boolean; + app: string | null; } /** * This route creates a new integration based on the integration authorization with id [integrationAuthId] @@ -10,7 +12,9 @@ interface Props { * @returns */ const createIntegration = ({ - integrationAuthId + integrationAuthId, + isActive, + app }: Props) => SecurityClient.fetchCall('/api/v1/integration', { method: 'POST', @@ -18,7 +22,9 @@ const createIntegration = ({ 'Content-Type': 'application/json' }, body: JSON.stringify({ - integrationAuthId + integrationAuthId, + isActive, + app }) }).then(async (res) => { if (res && res.status === 200) { diff --git a/frontend/src/pages/azure-key-vault.tsx b/frontend/src/pages/azure-key-vault.tsx index 57f861387..71a3c6db7 100644 --- a/frontend/src/pages/azure-key-vault.tsx +++ b/frontend/src/pages/azure-key-vault.tsx @@ -13,71 +13,54 @@ import { Select, SelectItem } from '../components/v2'; +import { useGetWorkspaceById } from '../hooks/api/workspace'; import AuthorizeIntegration from './api/integrations/authorizeIntegration'; -import updateIntegration from './api/integrations/updateIntegration'; -import getAWorkspace from './api/workspace/getAWorkspace'; +import createIntegration from './api/integrations/createIntegration'; -interface Integration { +interface IntegrationAuth { _id: string; - isActive: boolean; - app: string | null; - appId: string | null; + integration: string; + workspace: string; createdAt: string; updatedAt: string; - environment: string; - integration: string; - targetEnvironment: string; - workspace: string; - integrationAuth: string; } export default function AzureKeyVault() { const router = useRouter(); + const workspaceResult = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); - // query-string variables - const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const {code} = parsedUrl; - const {state} = parsedUrl; + const { code, state } = queryString.parse(router.asPath.split('?')[1]); + + const [integrationAuth, setIntegrationAuth] = useState(null); + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); - const [integration, setIntegration] = useState(null); - const [environments, setEnvironments] = useState< - { - name: string; - slug: string; - }[] - >([]); - const [environment, setEnvironment] = useState(''); const [vaultBaseUrl, setVaultBaseUrl] = useState(''); const [vaultBaseUrlErrorText, setVaultBaseUrlErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { (async () => { try { - if (state === localStorage.getItem('latestCSRFToken')) { - localStorage.removeItem('latestCSRFToken'); + if (state !== localStorage.getItem('latestCSRFToken')) return; + localStorage.removeItem('latestCSRFToken'); - const integrationDetails = await AuthorizeIntegration({ - workspaceId: localStorage.getItem('projectData.id') as string, - code: code as string, - integration: 'azure-key-vault', - }); - - setIntegration(integrationDetails.integration); - - const workspaceId = localStorage.getItem('projectData.id'); - if (!workspaceId) return; - - const workspace = await getAWorkspace(workspaceId); - setEnvironment(workspace.environments[0].slug); - setEnvironments(workspace.environments); - - } + setIntegrationAuth(await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'azure-key-vault', + })); } catch (error) { console.error('Azure Key Vault integration error: ', error); } })(); }, []); + + useEffect(() => { + if (workspaceResult && workspaceResult.data) { + setSelectedSourceEnvironment(workspaceResult.data.environments[0].slug); + } + }, [workspaceResult]); const handleButtonClick = async () => { try { @@ -94,17 +77,13 @@ export default function AzureKeyVault() { return; } - if (!integration) return; + if (!integrationAuth?._id) return; setIsLoading(true); - await updateIntegration({ - integrationId: integration._id, + await createIntegration({ + integrationAuthId: integrationAuth?._id, isActive: true, - environment, - app: vaultBaseUrl, - appId: null, - targetEnvironment: null, - owner: null + app: vaultBaseUrl }); setIsLoading(false); @@ -117,7 +96,11 @@ export default function AzureKeyVault() { } } - return (integration && environments.length > 0) ? ( + if (!workspaceResult) return
+ + const { data: w } = workspaceResult; + + return (integrationAuth && w && selectedSourceEnvironment) ? (
Azure Key Vault Integration @@ -126,13 +109,13 @@ export default function AzureKeyVault() { className='mt-4' > diff --git a/frontend/src/pages/heroku.tsx b/frontend/src/pages/heroku.tsx index ff4f2263e..66facdc06 100644 --- a/frontend/src/pages/heroku.tsx +++ b/frontend/src/pages/heroku.tsx @@ -1,41 +1,159 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import queryString from 'query-string'; +import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; + +import { + Button, + Card, + CardTitle, + FormControl, + Select, + SelectItem +} from '../components/v2'; import AuthorizeIntegration from './api/integrations/authorizeIntegration'; +import getIntegrationApps from './api/integrations/GetIntegrationApps'; +import getAWorkspace from './api/workspace/getAWorkspace'; + +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 IntegrationApp { + name: string; + appId?: string; + owner?: string; +} export default function Heroku() { const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const {code} = parsedUrl; - const {state} = parsedUrl; + const { code } = parsedUrl; + const { state } = parsedUrl; + + const [integration, setIntegration] = useState(null); + const [environments, setEnvironments] = useState< + { + name: string; + slug: string; + }[] + >([]); + const [environment, setEnvironment] = useState(''); + const [app, setApp] = useState(''); + const [apps, setApps] = useState([]); - /** - * Here we forward to the default workspace if a user opens this url - */ - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { (async () => { try { if (state === localStorage.getItem('latestCSRFToken')) { localStorage.removeItem('latestCSRFToken'); - await AuthorizeIntegration({ + const integrationDetails = await AuthorizeIntegration({ workspaceId: localStorage.getItem('projectData.id') as string, code: code as string, integration: 'heroku', }); - router.push( - `/integrations/${ localStorage.getItem('projectData.id')}` - ); + + setIntegration(integrationDetails.integration); + + const workspaceId = localStorage.getItem('projectData.id'); + if (!workspaceId) return; + + const workspace = await getAWorkspace(workspaceId); + setEnvironment(workspace.environments[0].slug); + setEnvironments(workspace.environments); + + const tempApps: [IntegrationApp] = await getIntegrationApps({ + integrationAuthId: integrationDetails.integration.integrationAuth + }); + + console.log('tempApps: ', tempApps); + setApp(tempApps[0].name); + setApps(tempApps); + + // router.push( + // `/integrations/${ localStorage.getItem('projectData.id')}` + // ); } } catch (error) { console.error('Heroku integration error: ', error); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + + const handleButtonClick = async () => { + try { + console.log('handleButtonClick'); + console.log('project environment: ', environment); + console.log('app', app); + } catch (err) { + console.error(err); + } + } + + console.log('integration: ', integration); + console.log('environments: ', environments); + console.log('apps: ', apps); - return
; + return (integration && environments.length > 0 && apps.length > 0) ? ( +
+ + Heroku Integration + + + + + + + + +
+ ) :
Hello
} Heroku.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index 688d7c34f..f065494cd 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -235,7 +235,9 @@ export default function Integrations() { setIntegrationAuths([...integrationAuths, integrationAuth]) const integration = await createIntegration({ - integrationAuthId: integrationAuth._id + integrationAuthId: integrationAuth._id, + isActive: false, + app: null }); setIntegrations([...integrations, integration]); From 679b1d9c23c9d9c6622964f9e23c9630bb5f4452 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 7 Feb 2023 23:10:31 +0700 Subject: [PATCH 03/11] Move existing integration authorization and creation into separate steps --- .../v1/integrationAuthController.ts | 40 ++- .../controllers/v1/integrationController.ts | 15 +- backend/src/helpers/integration.ts | 12 - backend/src/integrations/exchange.ts | 8 +- backend/src/integrations/sync.ts | 2 - backend/src/routes/v1/integration.ts | 1 + backend/src/routes/v1/integrationAuth.ts | 13 + frontend/package-lock.json | 310 +++++++++++++++++- frontend/package.json | 1 + frontend/public/data/frequentConstants.ts | 2 +- frontend/src/components/basic/Layout.tsx | 6 +- .../dialog/IntegrationAccessTokenDialog.tsx | 120 ------- .../src/hooks/api/integrationAuth/index.tsx | 3 + .../src/hooks/api/integrationAuth/queries.tsx | 38 +++ .../src/hooks/api/integrationAuth/types.ts | 13 + .../api/integrations/createIntegration.ts | 16 +- frontend/src/pages/azure-key-vault.tsx | 149 --------- frontend/src/pages/integrations/[id].tsx | 187 +++++------ .../integrations/azure-key-vault/create.tsx | 124 +++++++ .../azure-key-vault/oauth2/callback.tsx | 41 +++ .../pages/integrations/flyio/authorize.tsx | 76 +++++ .../src/pages/integrations/flyio/create.tsx | 122 +++++++ .../src/pages/integrations/github/create.tsx | 123 +++++++ .../integrations/github/oauth2/callback.tsx | 41 +++ .../src/pages/integrations/heroku/create.tsx | 121 +++++++ .../integrations/heroku/oauth2/callback.tsx | 44 +++ .../src/pages/integrations/netlify/create.tsx | 144 ++++++++ .../integrations/netlify/oauth2/callback.tsx | 41 +++ .../pages/integrations/render/authorize.tsx | 76 +++++ .../src/pages/integrations/render/create.tsx | 122 +++++++ .../src/pages/integrations/vercel/create.tsx | 142 ++++++++ .../integrations/vercel/oauth2/callback.tsx | 41 +++ 32 files changed, 1766 insertions(+), 428 deletions(-) delete mode 100644 frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx create mode 100644 frontend/src/hooks/api/integrationAuth/index.tsx create mode 100644 frontend/src/hooks/api/integrationAuth/queries.tsx create mode 100644 frontend/src/hooks/api/integrationAuth/types.ts delete mode 100644 frontend/src/pages/azure-key-vault.tsx create mode 100644 frontend/src/pages/integrations/azure-key-vault/create.tsx create mode 100644 frontend/src/pages/integrations/azure-key-vault/oauth2/callback.tsx create mode 100644 frontend/src/pages/integrations/flyio/authorize.tsx create mode 100644 frontend/src/pages/integrations/flyio/create.tsx create mode 100644 frontend/src/pages/integrations/github/create.tsx create mode 100644 frontend/src/pages/integrations/github/oauth2/callback.tsx create mode 100644 frontend/src/pages/integrations/heroku/create.tsx create mode 100644 frontend/src/pages/integrations/heroku/oauth2/callback.tsx create mode 100644 frontend/src/pages/integrations/netlify/create.tsx create mode 100644 frontend/src/pages/integrations/netlify/oauth2/callback.tsx create mode 100644 frontend/src/pages/integrations/render/authorize.tsx create mode 100644 frontend/src/pages/integrations/render/create.tsx create mode 100644 frontend/src/pages/integrations/vercel/create.tsx create mode 100644 frontend/src/pages/integrations/vercel/oauth2/callback.tsx diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 747004f44..be42647de 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -10,6 +10,31 @@ import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables'; import { IntegrationService } from '../../services'; import { getApps, revokeAccess } from '../../integrations'; +/*** + * Return integration authorization with id [integrationAuthId] + */ +export const getIntegrationAuth = async (req: Request, res: Response) => { + let integrationAuth; + try { + const { integrationAuthId } = req.params; + integrationAuth = await IntegrationAuth.findById(integrationAuthId); + + if (!integrationAuth) return res.status(400).send({ + message: 'Failed to find integration authorization' + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get integration authorization' + }); + } + + return res.status(200).send({ + integrationAuth + }); +} + export const getIntegrationOptions = async ( req: Request, res: Response @@ -31,7 +56,6 @@ export const oAuthExchange = async ( ) => { try { const { workspaceId, code, integration } = req.body; - if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); @@ -81,6 +105,13 @@ export const saveIntegrationAccessToken = async ( integration: string; } = req.body; + 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'); + integrationAuth = await IntegrationAuth.findOneAndUpdate({ workspace: new Types.ObjectId(workspaceId), integration @@ -91,13 +122,6 @@ export const saveIntegrationAccessToken = async ( 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({ diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 0ffb8cbb8..779cb3cb7 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -24,23 +24,34 @@ export const createIntegration = async (req: Request, res: Response) => { app, appId, isActive, + sourceEnvironment, targetEnvironment, owner } = req.body; + + // TODO: validate [sourceEnvironment] and [targetEnvironment] // initialize new integration after saving integration access token integration = await new Integration({ workspace: req.integrationAuth.workspace._id, - environment: req.integrationAuth.workspace?.environments[0].slug, + environment: sourceEnvironment, isActive, app, appId, targetEnvironment, + owner, integration: req.integrationAuth.integration, integrationAuth: new Types.ObjectId(integrationAuthId) }).save(); - // TODO: run sync function + if (integration) { + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString() + }) + }); + } } catch (err) { Sentry.setUser({ email: req.user.email }); diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 30e370188..17338ee68 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -43,9 +43,7 @@ const handleOAuthExchangeHelper = async ({ code: string; environment: string; }) => { - let action; let integrationAuth; - // let newIntegration; try { const bot = await Bot.findOne({ workspace: workspaceId, @@ -100,16 +98,6 @@ const handleOAuthExchangeHelper = async ({ accessExpiresAt: res.accessExpiresAt }); } - - // // initialize new integration after exchange - // newIntegration = await new Integration({ - // workspace: workspaceId, - // isActive: false, - // app: null, - // environment, - // integration, - // integrationAuth: integrationAuth._id - // }).save(); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index ada2b76bd..846c9e7db 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -144,7 +144,7 @@ const exchangeCodeAzure = async ({ scope: 'https://vault.azure.net/.default openid offline_access', // TODO: do we need all these permissions? client_id: CLIENT_ID_AZURE, client_secret: CLIENT_SECRET_AZURE, - redirect_uri: `${SITE_URL}/azure-key-vault` + redirect_uri: `${SITE_URL}/integrations/azure-key-vault/oauth2/callback` } as any) )).data; @@ -227,7 +227,7 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => { code: code, client_id: CLIENT_ID_VERCEL, client_secret: CLIENT_SECRET_VERCEL, - redirect_uri: `${SITE_URL}/vercel` + redirect_uri: `${SITE_URL}/integrations/vercel/oauth2/callback` } as any) ) ).data; @@ -267,7 +267,7 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => { code: code, client_id: CLIENT_ID_NETLIFY, client_secret: CLIENT_SECRET_NETLIFY, - redirect_uri: `${SITE_URL}/netlify` + redirect_uri: `${SITE_URL}/integrations/netlify/oauth2/callback` } as any) ) ).data; @@ -319,7 +319,7 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { client_id: CLIENT_ID_GITHUB, client_secret: CLIENT_SECRET_GITHUB, code: code, - redirect_uri: `${SITE_URL}/github` + redirect_uri: `${SITE_URL}/integrations/github/oauth2/callback` }, headers: { Accept: 'application/json' diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 4604d744a..401c7f9a1 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,9 +1,7 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { Octokit } from '@octokit/rest'; -// import * as sodium from 'libsodium-wrappers'; import sodium from 'libsodium-wrappers'; -// const sodium = require('libsodium-wrappers'); import { IIntegration, IIntegrationAuth } from '../models'; import { INTEGRATION_AZURE_KEY_VAULT, diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index 3db9c412e..90cea3a38 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -23,6 +23,7 @@ router.post( // new: add new integration for integration auth body('app').isString().trim(), body('isActive').exists().isBoolean(), body('appId').trim(), + body('sourceEnvironment').trim(), body('targetEnvironment').trim(), body('owner').trim(), validateRequest, diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 3f88aa7e5..1918140c2 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -18,6 +18,19 @@ router.get( integrationAuthController.getIntegrationOptions ); +router.get( + '/:integrationAuthId', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('integrationAuthId'), + validateRequest, + integrationAuthController.getIntegrationAuth +); + router.post( '/oauth-token', requireAuth({ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7f52e04ae..b0317ff7c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@fortawesome/react-fontawesome": "^0.1.19", "@headlessui/react": "^1.6.6", "@hookform/resolvers": "^2.9.10", + "@octokit/rest": "^19.0.7", "@radix-ui/react-accordion": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.1", @@ -3521,6 +3522,153 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.3.tgz", + "integrity": "sha512-/aFM2M4HVDBT/jjDBa84sJniv1t9Gm/rLkalaz9htOm+L+8JMj1k9w0CkUdcxNyNxZPlTxKPVko+m1VlM58ZVA==", + "dependencies": { + "@octokit/types": "^9.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/core": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.0.tgz", + "integrity": "sha512-AgvDRUg3COpR82P7PBdGZF/NNqGmtMq2NiPqeSsDIeCfYFOZ9gddqWNQHnFdEUf+YwOj4aZYmJnlPp7OXmDIDg==", + "dependencies": { + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/endpoint": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.5.tgz", + "integrity": "sha512-LG4o4HMY1Xoaec87IqQ41TQ+glvIeTKqfjkCEmt5AIwDZJwQeVZFIEYXrYY6yLwK+pAScb9Gj4q+Nz2qSw1roA==", + "dependencies": { + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/graphql": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.5.tgz", + "integrity": "sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ==", + "dependencies": { + "@octokit/request": "^6.0.0", + "@octokit/types": "^9.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-16.0.0.tgz", + "integrity": "sha512-JbFWOqTJVLHZSUUoF4FzAZKYtqdxWu9Z5m2QQnOyEa04fOFljvyh7D3GYKbfuaSWisqehImiVIMG4eyJeP5VEA==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-6.0.0.tgz", + "integrity": "sha512-Sq5VU1PfT6/JyuXPyt04KZNVsFOSBaYOAq2QRZUwzVlI10KFvcbUo8lR258AAQL1Et60b0WuVik+zOWKLuDZxw==", + "dependencies": { + "@octokit/types": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=4" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-7.0.1.tgz", + "integrity": "sha512-pnCaLwZBudK5xCdrR823xHGNgqOzRnJ/mpC/76YPpNP7DybdsJtP7mdOwh+wYZxK5jqeQuhu59ogMI4NRlBUvA==", + "dependencies": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.3.1" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/request": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.3.tgz", + "integrity": "sha512-TNAodj5yNzrrZ/VxP+H5HiYaZep0H3GU0O7PaF+fhDrt8FPrnkei9Aal/txsN/1P7V3CPiThG0tIvpPDYUsyAA==", + "dependencies": { + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/request-error": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", + "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", + "dependencies": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest": { + "version": "19.0.7", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.7.tgz", + "integrity": "sha512-HRtSfjrWmWVNp2uAkEpQnuGMJsu/+dBr47dRc5QVgsCbnIc1+GFEaoKBWkYG+zjrsHpSqcAElMio+n10c0b5JA==", + "dependencies": { + "@octokit/core": "^4.1.0", + "@octokit/plugin-paginate-rest": "^6.0.0", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^7.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.0.0.tgz", + "integrity": "sha512-LUewfj94xCMH2rbD5YJ+6AQ4AVjFYTgpp6rboWM5T7N3IsIF65SBEOVcYMGAEzO/kKNiNaW4LoWtoThOhH06gw==", + "dependencies": { + "@octokit/openapi-types": "^16.0.0" + } + }, "node_modules/@pkgr/utils": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz", @@ -8789,6 +8937,11 @@ } ] }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "node_modules/better-opn": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", @@ -10371,6 +10524,11 @@ "node": ">= 0.8" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -14099,7 +14257,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -16455,7 +16612,6 @@ "version": "2.6.8", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz", "integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==", - "dev": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -21217,8 +21373,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/trim-lines": { "version": "3.0.1", @@ -21664,6 +21819,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-user-agent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -22068,8 +22228,7 @@ "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { "version": "5.75.0", @@ -22267,7 +22426,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -24829,6 +24987,118 @@ "fastq": "^1.6.0" } }, + "@octokit/auth-token": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.3.tgz", + "integrity": "sha512-/aFM2M4HVDBT/jjDBa84sJniv1t9Gm/rLkalaz9htOm+L+8JMj1k9w0CkUdcxNyNxZPlTxKPVko+m1VlM58ZVA==", + "requires": { + "@octokit/types": "^9.0.0" + } + }, + "@octokit/core": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.0.tgz", + "integrity": "sha512-AgvDRUg3COpR82P7PBdGZF/NNqGmtMq2NiPqeSsDIeCfYFOZ9gddqWNQHnFdEUf+YwOj4aZYmJnlPp7OXmDIDg==", + "requires": { + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/endpoint": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.5.tgz", + "integrity": "sha512-LG4o4HMY1Xoaec87IqQ41TQ+glvIeTKqfjkCEmt5AIwDZJwQeVZFIEYXrYY6yLwK+pAScb9Gj4q+Nz2qSw1roA==", + "requires": { + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/graphql": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.5.tgz", + "integrity": "sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ==", + "requires": { + "@octokit/request": "^6.0.0", + "@octokit/types": "^9.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/openapi-types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-16.0.0.tgz", + "integrity": "sha512-JbFWOqTJVLHZSUUoF4FzAZKYtqdxWu9Z5m2QQnOyEa04fOFljvyh7D3GYKbfuaSWisqehImiVIMG4eyJeP5VEA==" + }, + "@octokit/plugin-paginate-rest": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-6.0.0.tgz", + "integrity": "sha512-Sq5VU1PfT6/JyuXPyt04KZNVsFOSBaYOAq2QRZUwzVlI10KFvcbUo8lR258AAQL1Et60b0WuVik+zOWKLuDZxw==", + "requires": { + "@octokit/types": "^9.0.0" + } + }, + "@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "requires": {} + }, + "@octokit/plugin-rest-endpoint-methods": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-7.0.1.tgz", + "integrity": "sha512-pnCaLwZBudK5xCdrR823xHGNgqOzRnJ/mpC/76YPpNP7DybdsJtP7mdOwh+wYZxK5jqeQuhu59ogMI4NRlBUvA==", + "requires": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.3.1" + } + }, + "@octokit/request": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.3.tgz", + "integrity": "sha512-TNAodj5yNzrrZ/VxP+H5HiYaZep0H3GU0O7PaF+fhDrt8FPrnkei9Aal/txsN/1P7V3CPiThG0tIvpPDYUsyAA==", + "requires": { + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/request-error": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", + "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", + "requires": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@octokit/rest": { + "version": "19.0.7", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.7.tgz", + "integrity": "sha512-HRtSfjrWmWVNp2uAkEpQnuGMJsu/+dBr47dRc5QVgsCbnIc1+GFEaoKBWkYG+zjrsHpSqcAElMio+n10c0b5JA==", + "requires": { + "@octokit/core": "^4.1.0", + "@octokit/plugin-paginate-rest": "^6.0.0", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^7.0.0" + } + }, + "@octokit/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.0.0.tgz", + "integrity": "sha512-LUewfj94xCMH2rbD5YJ+6AQ4AVjFYTgpp6rboWM5T7N3IsIF65SBEOVcYMGAEzO/kKNiNaW4LoWtoThOhH06gw==", + "requires": { + "@octokit/openapi-types": "^16.0.0" + } + }, "@pkgr/utils": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz", @@ -28742,6 +29012,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "better-opn": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-2.1.1.tgz", @@ -29938,6 +30213,11 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, + "deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -32732,8 +33012,7 @@ "is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" }, "is-regex": { "version": "1.1.4", @@ -34410,7 +34689,6 @@ "version": "2.6.8", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz", "integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==", - "dev": true, "requires": { "whatwg-url": "^5.0.0" } @@ -37863,8 +38141,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "trim-lines": { "version": "3.0.1", @@ -38186,6 +38463,11 @@ "unist-util-is": "^5.0.0" } }, + "universal-user-agent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -38479,8 +38761,7 @@ "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { "version": "5.75.0", @@ -38626,7 +38907,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/frontend/package.json b/frontend/package.json index db8153a0a..4e2e3e585 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@fortawesome/react-fontawesome": "^0.1.19", "@headlessui/react": "^1.6.6", "@hookform/resolvers": "^2.9.10", + "@octokit/rest": "^19.0.7", "@radix-ui/react-accordion": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.1", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 6ac74cdbd..f25da26d3 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -29,7 +29,7 @@ const reverseEnvMapping: Mapping = { const contextNetlifyMapping: Mapping = { "dev": "Local development", "branch-deploy": "Branch deploys", - "deploy-review": "Deploy Previews", + "deploy-preview": "Deploy Previews", "production": "Production" } diff --git a/frontend/src/components/basic/Layout.tsx b/frontend/src/components/basic/Layout.tsx index 1f1ee246d..c6e234058 100644 --- a/frontend/src/components/basic/Layout.tsx +++ b/frontend/src/components/basic/Layout.tsx @@ -199,13 +199,13 @@ const Layout = ({ children }: LayoutProps) => { .split('/') [router.asPath.split('/').length - 1].split('?')[0]; - if (!['heroku', 'vercel', 'github', 'netlify', 'azure-key-vault'].includes(intendedWorkspaceId)) { + if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) { localStorage.setItem('projectData.id', intendedWorkspaceId); } - + // If a user is not a member of a workspace they are trying to access, just push them to one of theirs if ( - !['heroku', 'vercel', 'github', 'netlify', 'azure-key-vault'].includes(intendedWorkspaceId) && + !['callback', 'create', 'authorize'].includes(intendedWorkspaceId) && !userWorkspaces .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) diff --git a/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx b/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx deleted file mode 100644 index 0a09f21e9..000000000 --- a/frontend/src/components/basic/dialog/IntegrationAccessTokenDialog.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Fragment, useState } from "react"; -import { Dialog, Transition } from "@headlessui/react"; - -import Button from "../buttons/Button"; -import InputField from "../InputField"; - -interface IntegrationOption { - clientId: string; - clientSlug?: string; // vercel-integration specific - docsLink: string; - image: string; - isAvailable: boolean; - name: string; - slug: string; - type: string; -} - -type Props = { - isOpen: boolean; - closeModal: () => void; - selectedIntegrationOption: IntegrationOption | null - handleIntegrationOption: (arg:{ - integrationOption: IntegrationOption, - accessToken?: string; -})=>void; -}; - -const IntegrationAccessTokenDialog = ({ - isOpen, - closeModal, - selectedIntegrationOption, - handleIntegrationOption -}:Props) => { - const [accessToken, setAccessToken] = useState(''); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const submit = async () => { - try { - if (selectedIntegrationOption && accessToken !== '') { - handleIntegrationOption({ - integrationOption: selectedIntegrationOption, - accessToken - }); - closeModal(); - setAccessToken(''); - } - } catch (err) { - console.log(err); - } - } - - return ( -
- - { - closeModal(); - }}> - -
- -
-
- - - - {`Enter your ${selectedIntegrationOption?.name} API Key`} - -
-

- {`This integration requires you to obtain an API key from ${selectedIntegrationOption?.name ?? ''} and store it with Infisical. `} - You can learn how to do this here. -

-
-
- -
-
-
-
-
-
-
-
-
-
- ); -} - -export default IntegrationAccessTokenDialog; \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx new file mode 100644 index 000000000..43920b65f --- /dev/null +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -0,0 +1,3 @@ +export { + useGetIntegrationAuthApps, + useGetIntegrationAuthById} from './queries'; \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx new file mode 100644 index 000000000..508afc741 --- /dev/null +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + App, + IntegrationAuth} from './types'; + +const integrationAuthKeys = { + getIntegrationAuthById: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuth'] as const, + getIntegrationAuthApps: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuthApps'] as const, +} + +const fetchIntegrationAuthById = async (integrationAuthId: string) => { + const { data } = await apiRequest.get<{ integrationAuth: IntegrationAuth }>(`/api/v1/integration-auth/${integrationAuthId}`); + return data.integrationAuth; +} + +const fetchIntegrationAuthApps = async (integrationAuthId: string) => { + const { data } = await apiRequest.get<{ apps: App[] }>(`/api/v1/integration-auth/${integrationAuthId}/apps`); + return data.apps; +} + +export const useGetIntegrationAuthById = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), + queryFn: () => fetchIntegrationAuthById(integrationAuthId), + enabled: true + }); +} + +export const useGetIntegrationAuthApps = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthApps(integrationAuthId), + queryFn: () => fetchIntegrationAuthApps(integrationAuthId), + enabled: true + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts new file mode 100644 index 000000000..bf193eafe --- /dev/null +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -0,0 +1,13 @@ +export type IntegrationAuth = { + _id: string; + workspace: string; + integration: string; + teamId?: string; + accountId?: string; +} + +export type App = { + name: string; + appId?: string; + owner?: string; +} \ No newline at end of file diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts index c2da2378a..6b83d3fdc 100644 --- a/frontend/src/pages/api/integrations/createIntegration.ts +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -4,6 +4,10 @@ interface Props { integrationAuthId: string; isActive: boolean; app: string | null; + appId: string | null; + sourceEnvironment: string; + targetEnvironment: string | null; + owner: string | null; } /** * This route creates a new integration based on the integration authorization with id [integrationAuthId] @@ -14,7 +18,11 @@ interface Props { const createIntegration = ({ integrationAuthId, isActive, - app + app, + appId, + sourceEnvironment, + targetEnvironment, + owner }: Props) => SecurityClient.fetchCall('/api/v1/integration', { method: 'POST', @@ -24,7 +32,11 @@ const createIntegration = ({ body: JSON.stringify({ integrationAuthId, isActive, - app + app, + appId, + sourceEnvironment, + targetEnvironment, + owner }) }).then(async (res) => { if (res && res.status === 200) { diff --git a/frontend/src/pages/azure-key-vault.tsx b/frontend/src/pages/azure-key-vault.tsx deleted file mode 100644 index 71a3c6db7..000000000 --- a/frontend/src/pages/azure-key-vault.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; -import queryString from 'query-string'; - -import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; - -import { - Button, - Card, - CardTitle, - FormControl, - Input, - Select, - SelectItem -} from '../components/v2'; -import { useGetWorkspaceById } from '../hooks/api/workspace'; -import AuthorizeIntegration from './api/integrations/authorizeIntegration'; -import createIntegration from './api/integrations/createIntegration'; - -interface IntegrationAuth { - _id: string; - integration: string; - workspace: string; - createdAt: string; - updatedAt: string; -} - -export default function AzureKeyVault() { - const router = useRouter(); - const workspaceResult = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); - - const { code, state } = queryString.parse(router.asPath.split('?')[1]); - - const [integrationAuth, setIntegrationAuth] = useState(null); - const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); - - const [vaultBaseUrl, setVaultBaseUrl] = useState(''); - const [vaultBaseUrlErrorText, setVaultBaseUrlErrorText] = useState(''); - - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - (async () => { - try { - if (state !== localStorage.getItem('latestCSRFToken')) return; - localStorage.removeItem('latestCSRFToken'); - - setIntegrationAuth(await AuthorizeIntegration({ - workspaceId: localStorage.getItem('projectData.id') as string, - code: code as string, - integration: 'azure-key-vault', - })); - } catch (error) { - console.error('Azure Key Vault integration error: ', error); - } - })(); - }, []); - - useEffect(() => { - if (workspaceResult && workspaceResult.data) { - setSelectedSourceEnvironment(workspaceResult.data.environments[0].slug); - } - }, [workspaceResult]); - - const handleButtonClick = async () => { - try { - if (vaultBaseUrl.length === 0) { - setVaultBaseUrlErrorText('Vault URI cannot be blank'); - return; - } - - if ( - !vaultBaseUrl.startsWith('https://') - || !vaultBaseUrl.endsWith('vault.azure.net') - ) { - setVaultBaseUrlErrorText('Vault URI must be like https://.vault.azure.net'); - return; - } - - if (!integrationAuth?._id) return; - - setIsLoading(true); - await createIntegration({ - integrationAuthId: integrationAuth?._id, - isActive: true, - app: vaultBaseUrl - }); - setIsLoading(false); - - router.push( - `/integrations/${localStorage.getItem('projectData.id')}` - ); - - } catch (err) { - console.error(err); - } - } - - if (!workspaceResult) return
- - const { data: w } = workspaceResult; - - return (integrationAuth && w && selectedSourceEnvironment) ? ( -
- - Azure Key Vault Integration - - - - - setVaultBaseUrl(e.target.value)} - /> - - - -
- ) :
-} - -AzureKeyVault.requireAuth = true; - -export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index f065494cd..ac2669de8 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -7,7 +7,6 @@ import { useTranslation } from 'next-i18next'; import frameworkIntegrationOptions from 'public/json/frameworkIntegrations.json'; import ActivateBotDialog from '@app/components/basic/dialog/ActivateBotDialog'; -import IntegrationAccessTokenDialog from '@app/components/basic/dialog/IntegrationAccessTokenDialog'; import CloudIntegrationSection from '@app/components/integrations/CloudIntegrationSection'; import FrameworkIntegrationSection from '@app/components/integrations/FrameworkIntegrationSection'; import IntegrationSection from '@app/components/integrations/IntegrationSection'; @@ -20,12 +19,10 @@ import { } from '../../components/utilities/cryptography/crypto'; import getBot from '../api/bot/getBot'; import setBotActiveStatus from '../api/bot/setBotActiveStatus'; -import createIntegration from '../api/integrations/createIntegration'; import deleteIntegration from '../api/integrations/DeleteIntegration'; import getIntegrationOptions from '../api/integrations/GetIntegrationOptions'; import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations'; import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations'; -import saveIntegrationAccessToken from '../api/integrations/saveIntegrationAccessToken'; import getAWorkspace from '../api/workspace/getAWorkspace'; import getLatestFileKey from '../api/workspace/getLatestFileKey'; @@ -75,7 +72,6 @@ export default function Integrations() { // TODO: These will have its type when migratiing towards react-query const [bot, setBot] = useState(null); const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); - const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(false); const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); const router = useRouter(); @@ -166,87 +162,83 @@ export default function Integrations() { } }; - /** - * Handle integration option authorization for a given integration option [integrationOption] - * @param {Object} obj - * @param {Object} obj.integrationOption - an integration option - * @param {String} obj.name - * @param {String} obj.type - * @param {String} obj.docsLink - * @returns - */ - const handleIntegrationOption = async ({ - integrationOption, - accessToken - }: { - integrationOption: IntegrationOption, - accessToken?: string; - }) => { + const handleUnauthorizedIntegrationOptionPress = (integrationOption: IntegrationOption) => { try { - if (!bot.isActive) { - await handleBotActivate(); + // generate CSRF token for OAuth2 code-token exchange integrations + const state = crypto.randomBytes(16).toString('hex'); + localStorage.setItem('latestCSRFToken', state); + + let link = ''; + switch (integrationOption.slug) { + case 'azure-key-vault': + link = `https://login.microsoftonline.com/${integrationOption.tenantId}/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; + break; + case 'heroku': + link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; + break; + case 'vercel': + link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; + break; + case 'netlify': + link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; + break; + case 'github': + link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; + break; + case 'render': + link = `${window.location.origin}/integrations/render/authorize` + break; + case 'flyio': + link = `${window.location.origin}/integrations/flyio/authorize` + break; + default: + break; } - - if (integrationOption.type === 'oauth') { - // integration is of type OAuth - // generate CSRF token for OAuth2 code-token exchange integrations - const state = crypto.randomBytes(16).toString('hex'); - localStorage.setItem('latestCSRFToken', state); - - switch (integrationOption.slug) { - case 'azure-key-vault': - window.location.assign( - `https://login.microsoftonline.com/${integrationOption.tenantId}/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/azure-key-vault&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}` - ); - break; - case 'heroku': - window.location.assign( - `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}` - ); - break; - case 'vercel': - window.location.assign( - `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}` - ); - break; - case 'netlify': - window.location.assign( - `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify` - ); - break; - case 'github': - window.location.assign( - `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}` - ); - break; - default: - break; - } - return; - } if (integrationOption.type === 'pat') { - // integration is of type personal access token - const integrationAuth = await saveIntegrationAccessToken({ - workspaceId: localStorage.getItem('projectData.id'), - integration: integrationOption.slug, - accessToken: accessToken ?? '' - }); - - setIntegrationAuths([...integrationAuths, integrationAuth]) - - const integration = await createIntegration({ - integrationAuthId: integrationAuth._id, - isActive: false, - app: null - }); - - setIntegrations([...integrations, integration]); - return; + if (link !== '') { + window.location.assign(link); } } catch (err) { console.error(err); } - }; + } + + const handleAuthorizedIntegrationOptionPress = (integrationAuth: IntegrationAuth) => { + try { + let link = ''; + switch (integrationAuth.integration) { + case 'azure-key-vault': + link = `${window.location.origin}/integrations/azure-key-vault/create?integrationAuthId=${integrationAuth._id}`; + break; + case 'heroku': + link = `${window.location.origin}/integrations/heroku/create?integrationAuthId=${integrationAuth._id}`; + break; + case 'vercel': + link = `${window.location.origin}/integrations/vercel/create?integrationAuthId=${integrationAuth._id}`; + break; + case 'netlify': + link = `${window.location.origin}/integrations/netlify/create?integrationAuthId=${integrationAuth._id}`; + break; + case 'github': + link = `${window.location.origin}/integrations/github/create?integrationAuthId=${integrationAuth._id}`; + break; + case 'render': + link = `${window.location.origin}/integrations/render/create?integrationAuthId=${integrationAuth._id}`; + break; + case 'flyio': + link = `${window.location.origin}/integrations/flyio/create?integrationAuthId=${integrationAuth._id}`; + break; + default: + break; + } + + if (link !== '') { + window.location.assign(link); + } + } catch (err) { + console.error(err); + } + } /** * Open dialog to activate bot if bot is not active. @@ -258,39 +250,20 @@ export default function Integrations() { * @returns */ const integrationOptionPress = async (integrationOption: IntegrationOption) => { - // consider: don't start integration until at [handleIntegrationOption] step try { const integrationAuthX = integrationAuths.find((integrationAuth) => integrationAuth.integration === integrationOption.slug); - - if (!integrationAuthX) { - // case: integration has not been authorized before - - if (integrationOption.type === 'pat') { - // case: integration requires user to input their personal access token for that integration - setIntegrationAccessTokenDialogOpen(true); - return; - } - - // case: integration does not require user to input their personal access token (i.e. it's an OAuth2 integration) - handleIntegrationOption({ integrationOption }); - return; - } - + if (!bot.isActive) { await handleBotActivate(); } - // case: integration has been authorized before - // -> create new integration - - if (!['azure-key-vault'].includes(integrationOption.slug)) { - const integration = await createIntegration({ - integrationAuthId: integrationAuthX._id - }); - setIntegrations([...integrations, integration]); - } else { - handleIntegrationOption({ integrationOption }); + if (!integrationAuthX) { + // case: integration has not been authorized + handleUnauthorizedIntegrationOptionPress(integrationOption); + return; } + + handleAuthorizedIntegrationOptionPress(integrationAuthX); } catch (err) { console.error(err); } @@ -372,12 +345,6 @@ export default function Integrations() { selectedIntegrationOption={selectedIntegrationOption} integrationOptionPress={integrationOptionPress} /> - setIntegrationAccessTokenDialogOpen(false)} - selectedIntegrationOption={selectedIntegrationOption} - handleIntegrationOption={handleIntegrationOption} - /> { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + const handleButtonClick = async () => { + try { + if (vaultBaseUrl.length === 0) { + setVaultBaseUrlErrorText('Vault URI cannot be blank'); + return; + } + + if ( + !vaultBaseUrl.startsWith('https://') + || !vaultBaseUrl.endsWith('vault.azure.net') + ) { + setVaultBaseUrlErrorText('Vault URI must be like https://.vault.azure.net'); + return; + } + + if (!integrationAuth?._id) return; + + setIsLoading(true); + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: vaultBaseUrl, + appId: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + owner: null + }); + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment) ? ( +
+ + Azure Key Vault Integration + + + + + setVaultBaseUrl(e.target.value)} + /> + + + +
+ ) :
+} + +AzureKeyVaultCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/azure-key-vault/oauth2/callback.tsx b/frontend/src/pages/integrations/azure-key-vault/oauth2/callback.tsx new file mode 100644 index 000000000..14d92d9c2 --- /dev/null +++ b/frontend/src/pages/integrations/azure-key-vault/oauth2/callback.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../../components/utilities/withTranslateProps'; +import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration"; + +export default function AzureKeyVaultOAuth2CallbackPage() { + const router = useRouter(); + + const { code, state } = queryString.parse(router.asPath.split('?')[1]); + + useEffect(() => { + (async () => { + try { + // validate state + if (state !== localStorage.getItem('latestCSRFToken')) return; + localStorage.removeItem('latestCSRFToken'); + + const integrationAuth = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'azure-key-vault' + }); + + router.push( + `/integrations/azure-key-vault/create?integrationAuthId=${integrationAuth._id}` + ); + + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
+} + +AzureKeyVaultOAuth2CallbackPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/flyio/authorize.tsx b/frontend/src/pages/integrations/flyio/authorize.tsx new file mode 100644 index 000000000..5b00e562a --- /dev/null +++ b/frontend/src/pages/integrations/flyio/authorize.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + Input, +} from '../../../components/v2'; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function FlyioCreateIntegrationPage() { + const router = useRouter(); + const [accessToken, setAccessToken] = useState(''); + const [accessTokenErrorText, setAccessTokenErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setAccessTokenErrorText(''); + if (accessToken.length === 0) { + setAccessTokenErrorText('Access token cannot be blank'); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'flyio', + accessToken + }); + + setIsLoading(false); + + router.push( + `/integrations/flyio/create?integrationAuthId=${integrationAuth._id}` + ); + } catch (err) { + console.error(err); + } + } + + return ( +
+ + Render Integration + + setAccessToken(e.target.value)} + /> + + + +
+ ) +} + +FlyioCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx new file mode 100644 index 000000000..7f9d079ae --- /dev/null +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -0,0 +1,122 @@ +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 { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function FlyioCreateIntegrationPage() { + 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: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + owner: null + }); + + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( +
+ + Fly.io Integration + + + + + + + + +
+ ) :
+} + +FlyioCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx new file mode 100644 index 000000000..324ef5715 --- /dev/null +++ b/frontend/src/pages/integrations/github/create.tsx @@ -0,0 +1,123 @@ +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 { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function GitHubCreateIntegrationPage() { + 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 [owner, setOwner] = useState(null); + 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); + setOwner(integrationAuthApps[0]?.owner ?? null); + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + owner + }); + + setIsLoading(false); + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( +
+ + GitHub Integration + + + + + + + + +
+ ) :
+} + +GitHubCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/github/oauth2/callback.tsx b/frontend/src/pages/integrations/github/oauth2/callback.tsx new file mode 100644 index 000000000..ab2bb18c8 --- /dev/null +++ b/frontend/src/pages/integrations/github/oauth2/callback.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../../components/utilities/withTranslateProps'; +import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration"; + +export default function GitHubOAuth2CallbackPage() { + const router = useRouter(); + + const { code, state } = queryString.parse(router.asPath.split('?')[1]); + + useEffect(() => { + (async () => { + try { + // validate state + if (state !== localStorage.getItem('latestCSRFToken')) return; + localStorage.removeItem('latestCSRFToken'); + + const integrationAuth = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'github' + }); + + router.push( + `/integrations/github/create?integrationAuthId=${integrationAuth._id}` + ); + + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
+} + +GitHubOAuth2CallbackPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx new file mode 100644 index 000000000..6d3c805d5 --- /dev/null +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -0,0 +1,121 @@ +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 { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function HerokuCreateIntegrationPage() { + 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 { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + owner: null + }); + + setIsLoading(false); + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( +
+ + Heroku Integration + + + + + + + + +
+ ) :
+} + +HerokuCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/heroku/oauth2/callback.tsx b/frontend/src/pages/integrations/heroku/oauth2/callback.tsx new file mode 100644 index 000000000..c5846170b --- /dev/null +++ b/frontend/src/pages/integrations/heroku/oauth2/callback.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../../components/utilities/withTranslateProps'; +import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration"; + +export default function HerokuOAuth2CallbackPage() { + const router = useRouter(); + + const { code, state } = queryString.parse(router.asPath.split('?')[1]); + + useEffect(() => { + (async () => { + try { + // validate state + console.log('A'); + if (state !== localStorage.getItem('latestCSRFToken')) return; + localStorage.removeItem('latestCSRFToken'); + console.log('B'); + const integrationAuth = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'heroku' + }); + + console.log('C'); + router.push( + `/integrations/heroku/create?integrationAuthId=${integrationAuth._id}` + ); + + } catch (err) { + console.error(err); + } + console.log('D'); + })(); + }, []); + + return
+} + +HerokuOAuth2CallbackPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/netlify/create.tsx b/frontend/src/pages/integrations/netlify/create.tsx new file mode 100644 index 000000000..6ec6469ab --- /dev/null +++ b/frontend/src/pages/integrations/netlify/create.tsx @@ -0,0 +1,144 @@ +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 { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +const netlifyEnvironments = [ + { name: 'Local development', slug: 'dev' }, + { name: 'Branch deploys', slug: 'branch-deploy' }, + { name: 'Deploy previews', slug: 'deploy-preview' }, + { name: 'Production', slug: 'production' } +] + +export default function NetlifyCreateIntegrationPage() { + 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 [targetEnvironment, setTargetEnvironment] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + setTargetEnvironment(netlifyEnvironments[0].slug); + } + }, [workspace]); + + useEffect(() => { + // TODO: handle case where apps can be empty + if (integrationAuthApps) { + console.log(integrationAuthApps) + setTargetApp(integrationAuthApps[0].name); + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment, + owner: null + }); + + setIsLoading(false); + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp && targetEnvironment) ? ( +
+ + Netlify Integration + + + + + + + + + + + +
+ ) :
+} + +NetlifyCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/netlify/oauth2/callback.tsx b/frontend/src/pages/integrations/netlify/oauth2/callback.tsx new file mode 100644 index 000000000..db884282a --- /dev/null +++ b/frontend/src/pages/integrations/netlify/oauth2/callback.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../../components/utilities/withTranslateProps'; +import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration"; + +export default function NetlifyOAuth2CallbackPage() { + const router = useRouter(); + + const { code, state } = queryString.parse(router.asPath.split('?')[1]); + + useEffect(() => { + (async () => { + try { + // validate state + if (state !== localStorage.getItem('latestCSRFToken')) return; + localStorage.removeItem('latestCSRFToken'); + + const integrationAuth = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'netlify' + }); + + router.push( + `/integrations/netlify/create?integrationAuthId=${integrationAuth._id}` + ); + + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
+} + +NetlifyOAuth2CallbackPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/render/authorize.tsx b/frontend/src/pages/integrations/render/authorize.tsx new file mode 100644 index 000000000..bd7a877c3 --- /dev/null +++ b/frontend/src/pages/integrations/render/authorize.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + Input, +} from '../../../components/v2'; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function RenderCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(''); + const [apiKeyErrorText, setApiKeyErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(''); + if (apiKey.length === 0) { + setApiKeyErrorText('API Key cannot be blank'); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'render', + accessToken: apiKey + }); + + setIsLoading(false); + + router.push( + `/integrations/render/create?integrationAuthId=${integrationAuth._id}` + ); + } catch (err) { + console.error(err); + } + } + + return ( +
+ + Render Integration + + setApiKey(e.target.value)} + /> + + + +
+ ) +} + +RenderCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/render/create.tsx b/frontend/src/pages/integrations/render/create.tsx new file mode 100644 index 000000000..2f02408d9 --- /dev/null +++ b/frontend/src/pages/integrations/render/create.tsx @@ -0,0 +1,122 @@ +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 { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function RenderCreateIntegrationPage() { + 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: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + owner: null + }); + + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( +
+ + Render Integration + + + + + + + + +
+ ) :
+} + +RenderCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx new file mode 100644 index 000000000..5a3249378 --- /dev/null +++ b/frontend/src/pages/integrations/vercel/create.tsx @@ -0,0 +1,142 @@ +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 { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; + +const vercelEnvironments = [ + { name: 'Development', slug: 'development' }, + { name: 'Preview', slug: 'preview' }, + { name: 'Production', slug: 'production' } +] + +export default function VercelCreateIntegrationPage() { + 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 [targetEnvironment, setTargetEnvironemnt] = 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); + setTargetEnvironemnt(vercelEnvironments[0].slug); + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment, + owner: null + }); + + setIsLoading(false); + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp && targetEnvironment) ? ( +
+ + Vercel Integration + + + + + + + + + + + +
+ ) :
+} + +VercelCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/vercel/oauth2/callback.tsx b/frontend/src/pages/integrations/vercel/oauth2/callback.tsx new file mode 100644 index 000000000..1acf01b02 --- /dev/null +++ b/frontend/src/pages/integrations/vercel/oauth2/callback.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../../components/utilities/withTranslateProps'; +import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration"; + +export default function VercelOAuth2CallbackPage() { + const router = useRouter(); + + const { code, state } = queryString.parse(router.asPath.split('?')[1]); + + useEffect(() => { + (async () => { + try { + // validate state + if (state !== localStorage.getItem('latestCSRFToken')) return; + localStorage.removeItem('latestCSRFToken'); + + const integrationAuth = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'vercel' + }); + + router.push( + `/integrations/vercel/create?integrationAuthId=${integrationAuth._id}` + ); + + } catch (err) { + console.error(err); + } + })(); + }, []); + + return
+} + +VercelOAuth2CallbackPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file From db05412865294652bc280eda2ea73563e93f10db Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 7 Feb 2023 23:27:21 +0700 Subject: [PATCH 04/11] Fix incorrect imports, build errors --- frontend/src/pages/github.tsx | 41 ----- frontend/src/pages/heroku.tsx | 159 ------------------ frontend/src/pages/integrations/[id].tsx | 1 + .../src/pages/integrations/flyio/create.tsx | 2 +- .../src/pages/integrations/github/create.tsx | 2 +- .../src/pages/integrations/heroku/create.tsx | 2 +- .../integrations/heroku/oauth2/callback.tsx | 4 - .../src/pages/integrations/netlify/create.tsx | 2 +- .../src/pages/integrations/render/create.tsx | 2 +- .../src/pages/integrations/vercel/create.tsx | 2 +- frontend/src/pages/netlify.tsx | 46 ----- frontend/src/pages/vercel.tsx | 46 ----- 12 files changed, 7 insertions(+), 302 deletions(-) delete mode 100644 frontend/src/pages/github.tsx delete mode 100644 frontend/src/pages/heroku.tsx delete mode 100644 frontend/src/pages/netlify.tsx delete mode 100644 frontend/src/pages/vercel.tsx diff --git a/frontend/src/pages/github.tsx b/frontend/src/pages/github.tsx deleted file mode 100644 index 37c35b194..000000000 --- a/frontend/src/pages/github.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import queryString from 'query-string'; - -import AuthorizeIntegration from './api/integrations/authorizeIntegration'; - -export default function Github() { - const router = useRouter(); - const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const {code} = parsedUrl; - const {state} = parsedUrl; - - /** - * Here we forward to the default workspace if a user opens this url - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - (async () => { - try { - if (state === localStorage.getItem('latestCSRFToken')) { - localStorage.removeItem('latestCSRFToken'); - await AuthorizeIntegration({ - workspaceId: localStorage.getItem('projectData.id') as string, - code: code as string, - integration: 'github', - }); - router.push( - `/integrations/${localStorage.getItem('projectData.id')}` - ); - } - } catch (error) { - console.error('Github integration error: ', error); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return
; -} - -Github.requireAuth = true; diff --git a/frontend/src/pages/heroku.tsx b/frontend/src/pages/heroku.tsx deleted file mode 100644 index 66facdc06..000000000 --- a/frontend/src/pages/heroku.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; -import queryString from 'query-string'; - -import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; - -import { - Button, - Card, - CardTitle, - FormControl, - Select, - SelectItem -} from '../components/v2'; -import AuthorizeIntegration from './api/integrations/authorizeIntegration'; -import getIntegrationApps from './api/integrations/GetIntegrationApps'; -import getAWorkspace from './api/workspace/getAWorkspace'; - -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 IntegrationApp { - name: string; - appId?: string; - owner?: string; -} - -export default function Heroku() { - const router = useRouter(); - const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const { code } = parsedUrl; - const { state } = parsedUrl; - - const [integration, setIntegration] = useState(null); - const [environments, setEnvironments] = useState< - { - name: string; - slug: string; - }[] - >([]); - const [environment, setEnvironment] = useState(''); - const [app, setApp] = useState(''); - const [apps, setApps] = useState([]); - - useEffect(() => { - (async () => { - try { - if (state === localStorage.getItem('latestCSRFToken')) { - localStorage.removeItem('latestCSRFToken'); - const integrationDetails = await AuthorizeIntegration({ - workspaceId: localStorage.getItem('projectData.id') as string, - code: code as string, - integration: 'heroku', - }); - - setIntegration(integrationDetails.integration); - - const workspaceId = localStorage.getItem('projectData.id'); - if (!workspaceId) return; - - const workspace = await getAWorkspace(workspaceId); - setEnvironment(workspace.environments[0].slug); - setEnvironments(workspace.environments); - - const tempApps: [IntegrationApp] = await getIntegrationApps({ - integrationAuthId: integrationDetails.integration.integrationAuth - }); - - console.log('tempApps: ', tempApps); - setApp(tempApps[0].name); - setApps(tempApps); - - // router.push( - // `/integrations/${ localStorage.getItem('projectData.id')}` - // ); - } - } catch (error) { - console.error('Heroku integration error: ', error); - } - })(); - }, []); - - const handleButtonClick = async () => { - try { - console.log('handleButtonClick'); - console.log('project environment: ', environment); - console.log('app', app); - } catch (err) { - console.error(err); - } - } - - console.log('integration: ', integration); - console.log('environments: ', environments); - console.log('apps: ', apps); - - return (integration && environments.length > 0 && apps.length > 0) ? ( -
- - Heroku Integration - - - - - - - - -
- ) :
Hello
-} - -Heroku.requireAuth = true; - -export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index ac2669de8..e6bc784f6 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -49,6 +49,7 @@ interface Integration { } interface IntegrationOption { + tenantId?: string; clientId: string; clientSlug?: string; // vercel-integration specific docsLink: string; diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx index 7f9d079ae..2e185d9fe 100644 --- a/frontend/src/pages/integrations/flyio/create.tsx +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 324ef5715..3b39fa2d1 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index 6d3c805d5..46e379462 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; diff --git a/frontend/src/pages/integrations/heroku/oauth2/callback.tsx b/frontend/src/pages/integrations/heroku/oauth2/callback.tsx index c5846170b..321255c9f 100644 --- a/frontend/src/pages/integrations/heroku/oauth2/callback.tsx +++ b/frontend/src/pages/integrations/heroku/oauth2/callback.tsx @@ -14,17 +14,14 @@ export default function HerokuOAuth2CallbackPage() { (async () => { try { // validate state - console.log('A'); if (state !== localStorage.getItem('latestCSRFToken')) return; localStorage.removeItem('latestCSRFToken'); - console.log('B'); const integrationAuth = await AuthorizeIntegration({ workspaceId: localStorage.getItem('projectData.id') as string, code: code as string, integration: 'heroku' }); - console.log('C'); router.push( `/integrations/heroku/create?integrationAuthId=${integrationAuth._id}` ); @@ -32,7 +29,6 @@ export default function HerokuOAuth2CallbackPage() { } catch (err) { console.error(err); } - console.log('D'); })(); }, []); diff --git a/frontend/src/pages/integrations/netlify/create.tsx b/frontend/src/pages/integrations/netlify/create.tsx index 6ec6469ab..a8b6bcd05 100644 --- a/frontend/src/pages/integrations/netlify/create.tsx +++ b/frontend/src/pages/integrations/netlify/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; diff --git a/frontend/src/pages/integrations/render/create.tsx b/frontend/src/pages/integrations/render/create.tsx index 2f02408d9..3429def89 100644 --- a/frontend/src/pages/integrations/render/create.tsx +++ b/frontend/src/pages/integrations/render/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx index 5a3249378..5e0e8ad7e 100644 --- a/frontend/src/pages/integrations/vercel/create.tsx +++ b/frontend/src/pages/integrations/vercel/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; diff --git a/frontend/src/pages/netlify.tsx b/frontend/src/pages/netlify.tsx deleted file mode 100644 index 532d6044a..000000000 --- a/frontend/src/pages/netlify.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import queryString from 'query-string'; - -import AuthorizeIntegration from './api/integrations/authorizeIntegration'; - -export default function Netlify() { - const router = useRouter(); - const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const {code} = parsedUrl; - const {state} = parsedUrl; - // modify comment here - - /** - * Here we forward to the default workspace if a user opens this url - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - (async () => { - try { - if (!code) throw new Error('Code not found'); - - if (state === localStorage.getItem('latestCSRFToken')) { - localStorage.removeItem('latestCSRFToken'); - - await AuthorizeIntegration({ - workspaceId: localStorage.getItem('projectData.id') as string, - code: code as string, - integration: 'netlify', - }); - - router.push( - `/integrations/${ localStorage.getItem('projectData.id')}` - ); - } - } catch (err) { - console.error('Netlify integration error: ', err); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return
; -} - -Netlify.requireAuth = true; diff --git a/frontend/src/pages/vercel.tsx b/frontend/src/pages/vercel.tsx deleted file mode 100644 index 8e8245736..000000000 --- a/frontend/src/pages/vercel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import queryString from 'query-string'; - -import AuthorizeIntegration from './api/integrations/authorizeIntegration'; - -export default function Vercel() { - const router = useRouter(); - const parsedUrl = queryString.parse(router.asPath.split('?')[1]); - const {code} = parsedUrl; - const {state} = parsedUrl; - - /** - * Here we forward to the default workspace if a user opens this url - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - (async () => { - try { - // type check - if (!code) throw new Error('Code not found'); - - if (state === localStorage.getItem('latestCSRFToken')) { - localStorage.removeItem('latestCSRFToken'); - - await AuthorizeIntegration({ - workspaceId: localStorage.getItem('projectData.id') as string, - code: code as string, - integration: 'vercel', - }); - - router.push( - `/integrations/${ localStorage.getItem('projectData.id')}` - ); - } - } catch (err) { - console.error('Vercel integration error: ', err); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return
; -} - -Vercel.requireAuth = true; From aba8feb98507cd763ac438c69cf3dd00f5a39c47 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 8 Feb 2023 01:28:46 +0700 Subject: [PATCH 05/11] Patch encoding header issue for some integrations for getting their apps --- backend/src/integrations/apps.ts | 9 +++++++-- backend/src/integrations/exchange.ts | 3 ++- frontend/src/pages/integrations/[id].tsx | 2 +- .../src/pages/integrations/github/oauth2/callback.tsx | 1 - 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 50f146be5..3d834d876 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -262,7 +262,9 @@ const getAppsRender = async ({ const res = ( await axios.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, { headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Accept-Encoding': 'application/json' } }) ).data; @@ -272,6 +274,7 @@ const getAppsRender = async ({ name: a.service.name, appId: a.service.id })); + } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -311,7 +314,9 @@ const getAppsFlyio = async ({ url: INTEGRATION_FLYIO_API_URL, method: 'post', headers: { - 'Authorization': 'Bearer ' + accessToken + 'Authorization': 'Bearer ' + accessToken, + 'Accept': 'application/json', + 'Accept-Encoding': 'application/json' }, data: { query, diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 846c9e7db..8650b31a0 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -322,7 +322,8 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { redirect_uri: `${SITE_URL}/integrations/github/oauth2/callback` }, headers: { - Accept: 'application/json' + 'Accept': 'application/json', + 'Accept-Encoding': 'application/json' } }) ).data; diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index e6bc784f6..d802e81a8 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -253,7 +253,7 @@ export default function Integrations() { const integrationOptionPress = async (integrationOption: IntegrationOption) => { try { const integrationAuthX = integrationAuths.find((integrationAuth) => integrationAuth.integration === integrationOption.slug); - + if (!bot.isActive) { await handleBotActivate(); } diff --git a/frontend/src/pages/integrations/github/oauth2/callback.tsx b/frontend/src/pages/integrations/github/oauth2/callback.tsx index ab2bb18c8..a3fd7e84c 100644 --- a/frontend/src/pages/integrations/github/oauth2/callback.tsx +++ b/frontend/src/pages/integrations/github/oauth2/callback.tsx @@ -7,7 +7,6 @@ import AuthorizeIntegration from "../../../api/integrations/authorizeIntegration export default function GitHubOAuth2CallbackPage() { const router = useRouter(); - const { code, state } = queryString.parse(router.asPath.split('?')[1]); useEffect(() => { From 8b23e89a6451a846c29312ac04014051273fc582 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 Feb 2023 12:38:43 -0800 Subject: [PATCH 06/11] add k8 diagram --- docs/images/k8-diagram.png | Bin 0 -> 134131 bytes docs/integrations/platforms/kubernetes.mdx | 3 +++ 2 files changed, 3 insertions(+) create mode 100644 docs/images/k8-diagram.png diff --git a/docs/images/k8-diagram.png b/docs/images/k8-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..f45d301bc2d2daa4a88fcee185b83ebeccb1a143 GIT binary patch literal 134131 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≤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 literal 0 HcmV?d00001 diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 189dc9d7b..1bc90a540 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -3,6 +3,9 @@ title: 'Kubernetes' description: "This page explains how to use Infisical to inject secrets into Kubernetes clusters." --- +![title](../../images/k8-diagram.png) + + The Infisical Secrets Operator is a custom Kubernetes controller that helps keep secrets in a cluster up to date by synchronizing them. It is installed in its own namespace within the cluster and follows strict RBAC policies. The operator uses InfisicalSecret custom resources to identify which secrets to sync and where to store them. From 6e67304e924b607af5cb60d15ffb6d2c2c2d86aa Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 Feb 2023 12:54:09 -0800 Subject: [PATCH 07/11] Update wording of k8 --- docs/integrations/platforms/kubernetes.mdx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 1bc90a540..07ce0aae3 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -6,10 +6,9 @@ description: "This page explains how to use Infisical to inject secrets into Kub ![title](../../images/k8-diagram.png) -The Infisical Secrets Operator is a custom Kubernetes controller that helps keep secrets in a cluster up to date by synchronizing them. -It is installed in its own namespace within the cluster and follows strict RBAC policies. -The operator uses InfisicalSecret custom resources to identify which secrets to sync and where to store them. -It is responsible for continuously updating managed secrets, and in the future may also automatically reload deployments that use them as needed. +The Infisical Secrets Operator is a Kubernetes controller that retrieves secrets from Infisical and stores them in a designated cluster. +It uses an `InfisicalSecret` resource to specify authentication and storage methods. +The operator continuously updates secrets and can also reload dependent deployments automatically. ## Install Operator From d8889beaf7e86803c4494a21a8d4b652425792b9 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Tue, 7 Feb 2023 12:58:39 -0800 Subject: [PATCH 08/11] mark gitlab as complete --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ec8cff50..64d643e7f 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,9 @@ We're currently setting the foundation and building [integrations](https://infis 🔜 GCP SM (https://github.com/Infisical/infisical/issues/285) - 🔜 GitLab CI/CD (https://github.com/Infisical/infisical/issues/134) + + ✔️ GitLab CI/CD + 🔜 CircleCI (https://github.com/Infisical/infisical/issues/91) From 78926247091a9404e62f5a8ef2bf40f1b4f3a267 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Tue, 7 Feb 2023 16:29:15 -0800 Subject: [PATCH 09/11] Added tags to secrets in the dashboard --- .../src/controllers/v2/secretsController.ts | 15 ++ backend/src/controllers/v2/tagController.ts | 4 +- .../v1/secretSnapshotController.ts | 8 +- frontend/public/data/frequentInterfaces.ts | 10 + .../basic/dialog/AddWorkspaceDialog.tsx | 8 +- .../basic/table/EnvironmentsTable.tsx | 2 +- .../src/components/dashboard/AddTagsMenu.tsx | 61 ++++++ .../dashboard/DashboardInputField.tsx | 7 +- .../dashboard/DeleteActionButton.tsx | 2 +- .../src/components/dashboard/DropZone.tsx | 6 +- frontend/src/components/dashboard/KeyPair.tsx | 91 +++++++-- frontend/src/components/dashboard/SideBar.tsx | 2 +- .../utilities/secrets/encryptSecrets.ts | 6 +- .../utilities/secrets/getSecretsForProject.ts | 12 +- .../DeleteActionModal/DeleteActionModal.tsx | 2 +- frontend/src/components/v2/Select/Select.tsx | 2 +- .../src/ee/components/PITRecoverySidebar.tsx | 8 +- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/tags/index.tsx | 3 + frontend/src/hooks/api/tags/queries.tsx | 62 ++++++ frontend/src/hooks/api/tags/types.ts | 39 ++++ frontend/src/hooks/api/types.ts | 3 +- frontend/src/hooks/api/workspace/index.tsx | 3 +- frontend/src/hooks/api/workspace/types.ts | 1 + .../pages/api/workspace/getWorkspaceTags.ts | 22 +++ frontend/src/pages/dashboard/[id].tsx | 171 +++++++++++----- .../ProjectSettingsPage.tsx | 56 +++++- .../EnvironmentSection/EnvironmentSection.tsx | 2 +- .../SecretTagsSection/SecretTagsSection.tsx | 186 ++++++++++++++++++ .../components/SecretTagsSection/index.tsx | 1 + .../ProjectSettingsPage/components/index.tsx | 1 + 31 files changed, 695 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/dashboard/AddTagsMenu.tsx create mode 100644 frontend/src/hooks/api/tags/index.tsx create mode 100644 frontend/src/hooks/api/tags/queries.tsx create mode 100644 frontend/src/hooks/api/tags/types.ts create mode 100644 frontend/src/pages/api/workspace/getWorkspaceTags.ts create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/index.tsx diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 5ac86e903..ff39791ac 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -103,6 +103,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; tags: string[] } @@ -115,6 +118,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, tags }: secretsToCreateType) => { return ({ @@ -129,6 +135,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, tags }); }) @@ -160,6 +169,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueIV, secretValueTag, secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, tags }) => ({ _id: new Types.ObjectId(), @@ -178,6 +190,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueIV, secretValueTag, secretValueHash, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, tags })) }); diff --git a/backend/src/controllers/v2/tagController.ts b/backend/src/controllers/v2/tagController.ts index 250ee08a5..b737eb41a 100644 --- a/backend/src/controllers/v2/tagController.ts +++ b/backend/src/controllers/v2/tagController.ts @@ -52,9 +52,9 @@ export const deleteWorkspaceTag = async (req: Request, res: Response) => { UnauthorizedRequestError({ message: 'Failed to validate membership' }); } - await Tag.findByIdAndDelete(tagId) + const result = await Tag.findByIdAndDelete(tagId); - res.sendStatus(200) + res.json(result); } export const getWorkspaceTags = async (req: Request, res: Response) => { diff --git a/backend/src/ee/controllers/v1/secretSnapshotController.ts b/backend/src/ee/controllers/v1/secretSnapshotController.ts index 6e8605c2f..ae3af7958 100644 --- a/backend/src/ee/controllers/v1/secretSnapshotController.ts +++ b/backend/src/ee/controllers/v1/secretSnapshotController.ts @@ -15,7 +15,13 @@ export const getSecretSnapshot = async (req: Request, res: Response) => { secretSnapshot = await SecretSnapshot .findById(secretSnapshotId) - .populate('secretVersions'); + .populate({ + path: 'secretVersions', + populate: { + path: 'tags', + model: 'Tag', + } + }); if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); diff --git a/frontend/public/data/frequentInterfaces.ts b/frontend/public/data/frequentInterfaces.ts index c2fa797fd..9865d9909 100644 --- a/frontend/public/data/frequentInterfaces.ts +++ b/frontend/public/data/frequentInterfaces.ts @@ -1,3 +1,12 @@ +export interface Tag { + _id: string; + name: string; + slug: string; + user: string; + workspace: string; + createdAt: string; +} + export interface SecretDataProps { pos: number; key: string; @@ -5,4 +14,5 @@ export interface SecretDataProps { valueOverride: string | undefined; id: string; comment: string; + tags: Tag[]; } \ No newline at end of file diff --git a/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx b/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx index d9cf17645..88dc7cb78 100644 --- a/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx +++ b/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx @@ -36,7 +36,7 @@ const AddWorkspaceDialog = ({ return (
- + -
+
- +

- This project will contain your environmental variables. + This project will contain your secrets and configs.

diff --git a/frontend/src/components/basic/table/EnvironmentsTable.tsx b/frontend/src/components/basic/table/EnvironmentsTable.tsx index 37411c366..f4e02a3bb 100644 --- a/frontend/src/components/basic/table/EnvironmentsTable.tsx +++ b/frontend/src/components/basic/table/EnvironmentsTable.tsx @@ -83,7 +83,7 @@ const EnvironmentTable = ({ data = [], onCreateEnv, onDeleteEnv, onUpdateEnv }:
+ + )})} + + + + + ); +}; + +export default AddTagsMenu; diff --git a/frontend/src/components/dashboard/DashboardInputField.tsx b/frontend/src/components/dashboard/DashboardInputField.tsx index c3f7e9a65..5b8cf9b69 100644 --- a/frontend/src/components/dashboard/DashboardInputField.tsx +++ b/frontend/src/components/dashboard/DashboardInputField.tsx @@ -3,7 +3,6 @@ import { faCircle, faExclamationCircle, faEye, faLayerGroup } from '@fortawesome import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import guidGenerator from '../utilities/randomId'; -import { Button } from '../v2'; import { HoverObject } from '../v2/HoverCard'; const REGEX = /([$]{.*?})/g; @@ -99,8 +98,8 @@ const DashboardInputField = ({ )} {!error &&
- +
}
); diff --git a/frontend/src/components/dashboard/DeleteActionButton.tsx b/frontend/src/components/dashboard/DeleteActionButton.tsx index d0708f239..91819b43c 100644 --- a/frontend/src/components/dashboard/DeleteActionButton.tsx +++ b/frontend/src/components/dashboard/DeleteActionButton.tsx @@ -19,7 +19,7 @@ export const DeleteActionButton = ({ onSubmit, isPlain }: Props) => {
+ : 'cursor-pointer w-[1.5rem] h-[2.35rem] mr-2 flex items-center justfy-center'}`}> {isPlain ?
null} diff --git a/frontend/src/components/dashboard/DropZone.tsx b/frontend/src/components/dashboard/DropZone.tsx index 5b1c0cd45..cd6d8650b 100644 --- a/frontend/src/components/dashboard/DropZone.tsx +++ b/frontend/src/components/dashboard/DropZone.tsx @@ -64,7 +64,8 @@ const DropZone = ({ key, value: keyPairs[key as keyof typeof keyPairs].value, comment: keyPairs[key as keyof typeof keyPairs].comments.join('\n'), - type: 'shared' + type: 'shared', + tags: [] })); break; } @@ -86,7 +87,8 @@ const DropZone = ({ key, value: keyPairs[key as keyof typeof keyPairs]?.toString() ?? '', comment, - type: 'shared' + type: 'shared', + tags: [] }; }); break; diff --git a/frontend/src/components/dashboard/KeyPair.tsx b/frontend/src/components/dashboard/KeyPair.tsx index c87dd7f96..27409a57a 100644 --- a/frontend/src/components/dashboard/KeyPair.tsx +++ b/frontend/src/components/dashboard/KeyPair.tsx @@ -1,7 +1,8 @@ -import { faEllipsis } from '@fortawesome/free-solid-svg-icons'; +import { faEllipsis, faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { SecretDataProps } from 'public/data/frequentInterfaces'; +import { SecretDataProps, Tag } from 'public/data/frequentInterfaces'; +import AddTagsMenu from './AddTagsMenu'; import DashboardInputField from './DashboardInputField'; import { DeleteActionButton } from './DeleteActionButton'; @@ -11,12 +12,15 @@ interface KeyPairProps { modifyValue: (value: string, position: number) => void; modifyValueOverride: (value: string | undefined, position: number) => void; modifyComment: (value: string, position: number) => void; + modifyTags: (value: Tag[], position: number) => void; isBlurred: boolean; isDuplicate: boolean; toggleSidebar: (id: string) => void; sidebarSecretId: string; isSnapshot: boolean; deleteRow?: (props: DeleteRowFunctionProps) => void; + tags: Tag[]; + togglePITSidebar?: (value: boolean) => void; } export interface DeleteRowFunctionProps { @@ -24,6 +28,23 @@ export interface DeleteRowFunctionProps { secretName: string; } +const colors = [ + 'bg-[#f1c40f]/40', + 'bg-[#cb1c8d]/40', + 'bg-[#badc58]/40', + 'bg-[#ff5400]/40', + 'bg-[#00bbf9]/40' +] + + +const colorsText = [ + 'text-[#fcf0c3]/70', + 'text-[#f2c6e3]/70', + 'text-[#eef6d5]/70', + 'text-[#ffddcc]/70', + 'text-[#f0fffd]/70' +] + /** * This component represent a single row for an environemnt variable on the dashboard * @param {object} obj @@ -32,12 +53,15 @@ export interface DeleteRowFunctionProps { * @param {function} obj.modifyValue - modify the value of a certain environment variable * @param {function} obj.modifyValueOverride - modify the value of a certain environment variable if it is overriden * @param {function} obj.modifyComment - modify the comment of a certain environment variable + * @param {function} obj.modifyTags - modify the tags of a certain environment variable * @param {boolean} obj.isBlurred - if the blurring setting is turned on * @param {boolean} obj.isDuplicate - list of all the duplicates secret names on the dashboard * @param {function} obj.toggleSidebar - open/close/switch sidebar * @param {string} obj.sidebarSecretId - the id of a secret for the side bar is displayed * @param {boolean} obj.isSnapshot - whether this keyPair is in a snapshot. If so, it won't have some features like sidebar * @param {function} obj.deleteRow - a function to delete a certain keyPair + * @param {function} obj.togglePITSidebar - open or close the Point-in-time recovery sidebar + * @param {Tag[]} obj.tags - tags for a certain secret * @returns */ const KeyPair = ({ @@ -46,21 +70,31 @@ const KeyPair = ({ modifyValue, modifyValueOverride, modifyComment, + modifyTags, isBlurred, isDuplicate, toggleSidebar, sidebarSecretId, isSnapshot, - deleteRow -}: KeyPairProps) => ( + deleteRow, + togglePITSidebar, + tags +}: KeyPairProps) => { + const tagData = (tags.map((tag, index) => {return { + ...tag, + color: colors[index%colors.length], + colorText: colorsText[index%colorsText.length] + }})); + + return (
-
{keyPair.pos + 1}
-
+
+
{keyPair.pos + 1}
-
+
@@ -89,7 +123,7 @@ const KeyPair = ({ />
-
+
- {!isSnapshot && ( -
null} - role="button" - tabIndex={0} - onClick={() => toggleSidebar(keyPair.id)} - className="cursor-pointer w-[2.35rem] h-[2.35rem] px-6 rounded-md invisible group-hover:visible flex flex-row justify-center items-center" - > - +
+
+ {keyPair.tags.map((tag, index) => ( + index < 2 &&
tagDp._id === tag._id)[0]?.color} rounded-sm text-sm ${tagData.filter(tagDp => tagDp._id === tag._id)[0]?.colorText} flex items-center`}> + {tag.name} + modifyTags(keyPair.tags.filter(ttag => ttag._id !== tag._id), keyPair.pos)}/> +
+ ))} + +
- )} - {!isSnapshot && ( +
+
null} + role="button" + tabIndex={0} + onClick={() => { + if (togglePITSidebar) { + togglePITSidebar(false); + } + toggleSidebar(keyPair.id) + }} + className={`cursor-pointer w-[1.5rem] h-[2.35rem] ml-auto group-hover:bg-mineshaft-700 z-50 rounded-md invisible group-hover:visible flex flex-row justify-center items-center ${isSnapshot ?? 'invisible'}`} + > + +
+
{ if (deleteRow) { deleteRow({ ids: [keyPair.id], secretName: keyPair?.key }) }}} isPlain /> - )} +
-); +)}; export default KeyPair; diff --git a/frontend/src/components/dashboard/SideBar.tsx b/frontend/src/components/dashboard/SideBar.tsx index a40dc1634..1d0996376 100644 --- a/frontend/src/components/dashboard/SideBar.tsx +++ b/frontend/src/components/dashboard/SideBar.tsx @@ -80,7 +80,7 @@ const SideBar = ({ const { t } = useTranslation(); return ( -
+
{isLoading ? (
secret.key === key && secret.type === 'shared' - )[0]?.comment + )[0]?.comment, + tags: tempDecryptedSecrets.filter( + (secret) => secret.key === key && secret.type === 'shared' + )[0]?.tags })); if (typeof setData === 'function') { diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 7678af3cf..1d072cc89 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -52,7 +52,7 @@ export const DeleteActionModal = ({ >
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx new file mode 100644 index 000000000..6aa4d26be --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -0,0 +1,186 @@ +import { Controller, useForm } from 'react-hook-form'; +import { faPlus, faTrashCan } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { + Button, + DeleteActionModal, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + ModalTrigger, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr, +} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { WorkspaceTag } from '@app/hooks/api/types'; + +const createTagSchema = yup.object({ + name: yup.string().required().label('Tag Name'), +}); + +export type CreateWsTag = yup.InferType; + +type Props = { + tags: WorkspaceTag[]; + workspaceName: string; + onDeleteTag: (tagID: string) => Promise; + onCreateTag: (data: CreateWsTag) => Promise; +}; + +type DeleteModalData = { name: string; id: string }; + +export const SecretTagsSection = ({ + tags = [], + onDeleteTag, + workspaceName, + onCreateTag +}: Props): JSX.Element => { + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + 'CreateSecretTag', + 'deleteTagConfirmation' + ] as const); + + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(createTagSchema) + }); + + const onFormSubmit = async (data: CreateWsTag) => { + console.log(19191, data); + await onCreateTag(data); + handlePopUpClose('CreateSecretTag'); + }; + + const onDeleteApproved = async () => { + await onDeleteTag((popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id); + handlePopUpClose('deleteTagConfirmation'); + }; + + return ( +
+
+
+

Secret Tags

+

Every secret can be assigned to one or more tags. Here you can add and remove tags for the current project.

+
+
+ { + handlePopUpToggle('CreateSecretTag', open); + reset(); + }} + > + + + + +
+ ( + + + + )} + /> +
+ + + + +
+ +
+
+
+
+ + + + + + + + + + {tags?.length > 0 ? ( + tags.map(({ _id, name, slug }) => ( + + + + + + )) + ) : ( + + + + )} + +
TagSlug +
{name}{slug} + + handlePopUpOpen('deleteTagConfirmation', { + name, + id: _id + }) + } + colorSchema="danger" + ariaLabel="update" + > + + +
+ No tags found for this project +
+
+ handlePopUpToggle('deleteTagConfirmation', isOpen)} + deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose('deleteTagConfirmation')} + onDeleteApproved={onDeleteApproved} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/index.tsx new file mode 100644 index 000000000..0d0615293 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/index.tsx @@ -0,0 +1 @@ +export {SecretTagsSection} from './SecretTagsSection' \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx index 163483278..98160b5b7 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx @@ -2,5 +2,6 @@ export { CopyProjectIDSection } from './CopyProjectIDSection'; export { EnvironmentSection } from './EnvironmentSection'; export type { CreateUpdateEnvFormData } from './EnvironmentSection/EnvironmentSection'; export { ProjectNameChangeSection } from './ProjectNameChangeSection'; +export type { CreateWsTag } from './SecretTagsSection/SecretTagsSection'; export { ServiceTokenSection } from './ServiceTokenSection'; export type { CreateServiceToken } from './ServiceTokenSection/ServiceTokenSection'; From 498705f33044acc536b693b37d3947cc6955b659 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Tue, 7 Feb 2023 16:47:05 -0800 Subject: [PATCH 10/11] Fixed the login error with tags --- .../src/components/utilities/attemptLogin.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index acc2b67fe..a47b2c1f0 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -138,7 +138,8 @@ const attemptLogin = async ( value: 'mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net', valueOverride: undefined, comment: 'This is an example of secret referencing.', - id: '' + id: '', + tags: [] }, { pos: 1, @@ -147,7 +148,8 @@ const attemptLogin = async ( valueOverride: undefined, comment: 'This is an example of secret overriding. Your team can have a shared value of a secret, while you can override it to whatever value you need', - id: '' + id: '', + tags: [] }, { pos: 2, @@ -156,7 +158,8 @@ const attemptLogin = async ( valueOverride: undefined, comment: 'This is an example of secret overriding. Your team can have a shared value of a secret, while you can override it to whatever value you need', - id: '' + id: '', + tags: [] }, { pos: 3, @@ -164,7 +167,8 @@ const attemptLogin = async ( value: 'user1234', valueOverride: 'user1234', comment: '', - id: '' + id: '', + tags: [] }, { pos: 4, @@ -172,7 +176,8 @@ const attemptLogin = async ( value: 'example_password', valueOverride: 'example_password', comment: '', - id: '' + id: '', + tags: [] }, { pos: 5, @@ -180,7 +185,8 @@ const attemptLogin = async ( value: 'example_twillio_token', valueOverride: undefined, comment: '', - id: '' + id: '', + tags: [] }, { pos: 6, @@ -188,7 +194,8 @@ const attemptLogin = async ( value: 'http://localhost:3000', valueOverride: undefined, comment: '', - id: '' + id: '', + tags: [] } ]; const secrets = await encryptSecrets({ From ace0e9c56fbb97f8fbcead74126f49d40b498b1b Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Tue, 7 Feb 2023 16:54:13 -0800 Subject: [PATCH 11/11] Fixed the bug of wrong data structure --- .../views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 6de068ec5..9c08e2bcd 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -228,7 +228,7 @@ export const ProjectSettingsPage = () => { text: 'Successfully created a tag', type: 'success' }); - return res?.data?.name; + return res.name; } catch (error) { console.error(error); createNotification({