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) 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/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..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'); @@ -40,12 +64,16 @@ export const oAuthExchange = async ( throw new Error("Failed to get environments") } - await IntegrationService.handleOAuthExchange({ + const integrationAuth = await IntegrationService.handleOAuthExchange({ workspaceId, integration, code, environment: environments[0].slug, }); + + return res.status(200).send({ + integrationAuth + }); } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); @@ -53,10 +81,6 @@ export const oAuthExchange = async ( message: 'Failed to get OAuth2 code-token exchange' }); } - - return res.status(200).send({ - message: 'Successfully enabled integration authorization' - }); }; /** @@ -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 4e66ce0aa..779cb3cb7 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, @@ -18,15 +19,40 @@ import { eventPushSecrets } from '../../events'; export const createIntegration = async (req: Request, res: Response) => { let integration; try { + const { + integrationAuthId, + 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, - isActive: false, - app: null, - environment: req.integrationAuth.workspace?.environments[0].slug, + environment: sourceEnvironment, + isActive, + app, + appId, + targetEnvironment, + owner, integration: req.integrationAuth.integration, - integrationAuth: req.integrationAuth._id + integrationAuth: new Types.ObjectId(integrationAuthId) }).save(); + + if (integration) { + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString() + }) + }); + } + } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); 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/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 1618e5e67..17338ee68 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, @@ -42,7 +43,6 @@ const handleOAuthExchangeHelper = async ({ code: string; environment: string; }) => { - let action; let integrationAuth; try { const bot = await Bot.findOne({ @@ -98,21 +98,13 @@ const handleOAuthExchangeHelper = async ({ accessExpiresAt: res.accessExpiresAt }); } - - // initialize new integration after exchange - 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; } /** * 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..3d834d876 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 @@ -247,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; @@ -257,6 +274,7 @@ const getAppsRender = async ({ name: a.service.name, appId: a.service.id })); + } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -296,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 26aca5fdb..8650b31a0 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}/integrations/azure-key-vault/oauth2/callback` + } 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 @@ -168,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; @@ -208,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; @@ -260,10 +319,11 @@ 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' + 'Accept': 'application/json', + 'Accept-Encoding': 'application/json' } }) ).data; 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..401c7f9a1 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,11 +1,10 @@ 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, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -18,7 +17,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 +39,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 +98,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/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index d587bd2e5..90cea3a38 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,13 @@ 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('sourceEnvironment').trim(), + body('targetEnvironment').trim(), + body('owner').trim(), validateRequest, integrationController.createIntegration ); 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/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 4ee991cdd..5b0195427 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,12 @@ 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 {IntegrationAuth} integrationAuth - integration authorization after OAuth2 code-token exchange */ static async handleOAuthExchange({ workspaceId, @@ -43,7 +39,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/docs/images/k8-diagram.png b/docs/images/k8-diagram.png new file mode 100644 index 000000000..f45d301bc Binary files /dev/null and b/docs/images/k8-diagram.png differ diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 189dc9d7b..07ce0aae3 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -3,10 +3,12 @@ title: 'Kubernetes' description: "This page explains how to use Infisical to inject secrets into Kubernetes clusters." --- -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. +![title](../../images/k8-diagram.png) + + +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 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 2ff525e54..f25da26d3 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", @@ -19,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" } @@ -49,6 +59,7 @@ const plans = plansProd || plansDev; export { contextNetlifyMapping, envMapping, + integrationSlugNameMapping, plans, reverseContextNetlifyMapping, reverseEnvMapping} 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/Layout.tsx b/frontend/src/components/basic/Layout.tsx index ba4cf73b2..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'].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'].includes(intendedWorkspaceId) && + !['callback', 'create', 'authorize'].includes(intendedWorkspaceId) && !userWorkspaces .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) 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/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/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 00c9c0de7..2b01fa62e 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; @@ -101,8 +100,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 c2f8debef..03d22d39c 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,6 +12,7 @@ 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; @@ -18,6 +20,8 @@ interface KeyPairProps { isSnapshot: boolean; isCapitalized: boolean; deleteRow?: (props: DeleteRowFunctionProps) => void; + tags: Tag[]; + togglePITSidebar?: (value: boolean) => void; } export interface DeleteRowFunctionProps { @@ -25,6 +29,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 @@ -33,12 +54,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 = ({ @@ -47,22 +71,32 @@ const KeyPair = ({ modifyValue, modifyValueOverride, modifyComment, + modifyTags, isBlurred, isDuplicate, toggleSidebar, sidebarSecretId, isCapitalized, 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}
-
+
@@ -92,7 +126,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 ? (
{ + // 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/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({ diff --git a/frontend/src/components/utilities/secrets/encryptSecrets.ts b/frontend/src/components/utilities/secrets/encryptSecrets.ts index 53ce0066d..11d9e8d22 100644 --- a/frontend/src/components/utilities/secrets/encryptSecrets.ts +++ b/frontend/src/components/utilities/secrets/encryptSecrets.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import { SecretDataProps } from 'public/data/frequentInterfaces'; +import { SecretDataProps, Tag } from 'public/data/frequentInterfaces'; import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey'; @@ -20,6 +20,7 @@ interface EncryptedSecretProps { secretValueIV: string; secretValueTag: string; type: 'personal' | 'shared'; + tags: Tag[]; } /** @@ -105,7 +106,8 @@ const encryptSecrets = async ({ type: secret.valueOverride === undefined || secret?.value !== secret?.valueOverride ? 'shared' - : 'personal' + : 'personal', + tags: secret.tags }; return result; diff --git a/frontend/src/components/utilities/secrets/getSecretsForProject.ts b/frontend/src/components/utilities/secrets/getSecretsForProject.ts index 62c1e2e2d..28491a3e4 100644 --- a/frontend/src/components/utilities/secrets/getSecretsForProject.ts +++ b/frontend/src/components/utilities/secrets/getSecretsForProject.ts @@ -1,3 +1,5 @@ +import { Tag } from 'public/data/frequentInterfaces'; + import getSecrets from '@app/pages/api/files/GetSecrets'; import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey'; @@ -17,6 +19,7 @@ interface EncryptedSecretProps { secretValueIV: string; secretValueTag: string; type: 'personal' | 'shared'; + tags: Tag[]; } interface SecretProps { @@ -25,6 +28,7 @@ interface SecretProps { type: 'personal' | 'shared'; comment: string; id: string; + tags: Tag[]; } interface FunctionProps { @@ -107,7 +111,8 @@ const getSecretsForProject = async ({ key: plainTextKey, value: plainTextValue, type: secret.type, - comment: plainTextComment + comment: plainTextComment, + tags: secret.tags }); }); } @@ -130,7 +135,10 @@ const getSecretsForProject = async ({ )[0]?.value, comment: tempDecryptedSecrets.filter( (secret) => 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 = ({ > + +
+ ) :
+} + +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..2e185d9fe --- /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 { useGetIntegrationAuthApps,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..3b39fa2d1 --- /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 { useGetIntegrationAuthApps,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..a3fd7e84c --- /dev/null +++ b/frontend/src/pages/integrations/github/oauth2/callback.tsx @@ -0,0 +1,40 @@ +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..46e379462 --- /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 { useGetIntegrationAuthApps,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..321255c9f --- /dev/null +++ b/frontend/src/pages/integrations/heroku/oauth2/callback.tsx @@ -0,0 +1,40 @@ +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 + 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: 'heroku' + }); + + router.push( + `/integrations/heroku/create?integrationAuthId=${integrationAuth._id}` + ); + + } catch (err) { + console.error(err); + } + })(); + }, []); + + 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..a8b6bcd05 --- /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 { useGetIntegrationAuthApps,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..3429def89 --- /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 { useGetIntegrationAuthApps,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..5e0e8ad7e --- /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 { useGetIntegrationAuthApps,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 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; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index da89e9882..1b30eb4e9 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -18,21 +18,29 @@ import { useToggle } from '@app/hooks'; import { useCreateServiceToken, useCreateWsEnvironment, + useCreateWsTag, useDeleteServiceToken, useDeleteWorkspace, useDeleteWsEnvironment, + useDeleteWsTag, useGetUserWsKey, useGetUserWsServiceTokens, + useGetWsTags, useRenameWorkspace, useToggleAutoCapitalization, useUpdateWsEnvironment } from '@app/hooks/api'; + import { AutoCapitalizationSection } from './components/AutoCapitalizationSection/AutoCapitalizationSection'; + +import { SecretTagsSection } from './components/SecretTagsSection'; + import { CopyProjectIDSection, CreateServiceToken, CreateUpdateEnvFormData, + CreateWsTag, EnvironmentSection, ProjectNameChangeSection, ServiceTokenSection @@ -65,6 +73,11 @@ export const ProjectSettingsPage = () => { const createServiceToken = useCreateServiceToken(); const deleteServiceToken = useDeleteServiceToken(); + // tag + const { data: wsTags } = useGetWsTags(workspaceID); + const createWsTag = useCreateWsTag(); + const deleteWsTag = useDeleteWsTag(); + // get user subscription const { subscriptionPlan } = useSubscription(); const host = window.location.origin; @@ -231,6 +244,44 @@ export const ProjectSettingsPage = () => { return ''; }; + const onCreateWsTag = async ({ name }: CreateWsTag) => { + try { + const res = await createWsTag.mutateAsync({ + workspaceID, + tagName: name, + tagSlug: name.replace(" ", "_") + }); + createNotification({ + text: 'Successfully created a tag', + type: 'success' + }); + return res.name; + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to create a tag', + type: 'error' + }); + } + return ''; + }; + + const onDeleteTag = async (tagID: string) => { + try { + await deleteWsTag.mutateAsync({ tagID }); + createNotification({ + text: 'Successfully deleted tag', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to delete the tag', + type: 'error' + }); + } + }; + const onDeleteServiceToken = async (tokenID: string) => { try { await deleteServiceToken.mutateAsync(tokenID); @@ -248,7 +299,7 @@ export const ProjectSettingsPage = () => { }; return ( -
+
{/* TODO(akhilmhdh): Remove this right when layout is refactored */}
@@ -284,6 +335,12 @@ export const ProjectSettingsPage = () => { workspaceName={currentWorkspace?.name || ''} onCreateToken={onCreateServiceToken} /> +

{t('settings-project:danger-zone')}

{t('settings-project:danger-zone-note')}

diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 521394f9f..ddcf7f4b3 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -102,7 +102,7 @@ export const EnvironmentSection = ({ colorSchema="primary" leftIcon={} > - Add New Env + Add New Environment
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';