diff --git a/.env.example b/.env.example
index 3871841c0..989a285e3 100644
--- a/.env.example
+++ b/.env.example
@@ -47,8 +47,12 @@ SMTP_PASSWORD=
# Integration
# Optional only if integration is used
-OAUTH_CLIENT_SECRET_HEROKU=
-OAUTH_TOKEN_URL_HEROKU=
+CLIENT_ID_HEROKU=
+CLIENT_ID_VERCEL=
+CLIENT_ID_NETLIFY=
+CLIENT_SECRET_HEROKU=
+CLIENT_SECRET_VERCEL=
+CLIENT_SECRET_NETLIFY=
# Sentry (optional) for monitoring errors
SENTRY_DSN=
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index fa81e63de..000000000
--- a/.prettierrc
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "semi": true,
- "trailingComma": "none",
- "singleQuote": true,
- "printWidth": 80,
- "useTabs": false
-}
diff --git a/README.md b/README.md
index ba76997f1..46629bef7 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,9 @@
+
+
+
@@ -92,12 +95,6 @@ Not sure where to get started? You can:
We're currently in Public Alpha.
-## 🚨 Stay Up-to-Date
-
-Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of new features are coming very quickly. Watch **releases** of this repository to be notified about future updates:
-
-
-
## 🔌 Integrations
We're currently setting the foundation and building [integrations](https://infisical.com/docs/integrations/overview) so secrets can be synced everywhere. Any help is welcome! :)
@@ -131,10 +128,14 @@ We're currently setting the foundation and building [integrations](https://infis
- 🔜 Vercel (https://github.com/Infisical/infisical/issues/60)
+
+ ✔️ Vercel
+
- 🔜 GitLab CI/CD
+
+ ✔️ Kubernetes
+
🔜 Fly.io
@@ -156,7 +157,7 @@ We're currently setting the foundation and building [integrations](https://infis
🔜 GCP
- 🔜 Kubernetes
+ 🔜 GitLab CI/CD
🔜 CircleCI
@@ -302,6 +303,12 @@ This repo is entirely MIT licensed, with the exception of the `ee` directory whi
Looking to report a security vulnerability? Please don't post about it in GitHub issue. Instead, refer to our [SECURITY.md](./SECURITY.md) file.
+## 🚨 Stay Up-to-Date
+
+Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of new features are coming very quickly. Watch **releases** of this repository to be notified about future updates:
+
+
+
## 🦸 Contributors
[//]: contributor-faces
diff --git a/backend/.eslintrc b/backend/.eslintrc
index 7fe198901..c1ca1a1eb 100644
--- a/backend/.eslintrc
+++ b/backend/.eslintrc
@@ -1,11 +1,10 @@
{
"parser": "@typescript-eslint/parser",
- "plugins": ["@typescript-eslint", "prettier"],
+ "plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
- "plugin:@typescript-eslint/recommended",
- "prettier"
+ "plugin:@typescript-eslint/recommended"
],
"rules": {
"no-console": 2
diff --git a/backend/Dockerfile b/backend/Dockerfile
index ccc76e66e..85b7204fe 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -4,12 +4,12 @@ WORKDIR /app
COPY package.json package-lock.json ./
-RUN npm ci --only-production
+RUN npm ci --only-production --ignore-scripts
COPY . .
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \
- CMD node healthcheck.js
+ CMD node healthcheck.js
CMD ["npm", "run", "start"]
diff --git a/backend/environment.d.ts b/backend/environment.d.ts
index 33827fb2b..853f52e5b 100644
--- a/backend/environment.d.ts
+++ b/backend/environment.d.ts
@@ -14,8 +14,12 @@ declare global {
JWT_SIGNUP_SECRET: string;
MONGO_URL: string;
NODE_ENV: 'development' | 'staging' | 'testing' | 'production';
- OAUTH_CLIENT_SECRET_HEROKU: string;
- OAUTH_TOKEN_URL_HEROKU: string;
+ CLIENT_ID_HEROKU: string;
+ CLIENT_ID_VERCEL: string;
+ CLIENT_ID_NETLIFY: string;
+ CLIENT_SECRET_HEROKU: string;
+ CLIENT_SECRET_VERCEL: string;
+ CLIENT_SECRET_NETLIFY: string;
POSTHOG_HOST: string;
POSTHOG_PROJECT_API_KEY: string;
PRIVATE_KEY: string;
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 82301465c..852f1c0f3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -53,14 +53,11 @@
"@typescript-eslint/parser": "^5.40.1",
"cross-env": "^7.0.3",
"eslint": "^8.26.0",
- "eslint-config-prettier": "^8.5.0",
- "eslint-plugin-prettier": "^4.2.1",
"install": "^0.13.0",
"jest": "^29.3.1",
"jest-junit": "^15.0.0",
"nodemon": "^2.0.19",
"npm": "^8.19.3",
- "prettier": "^2.7.1",
"supertest": "^6.3.3",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1"
@@ -2943,12 +2940,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/prettier": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
- "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==",
- "dev": true
- },
"node_modules/@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@@ -4368,39 +4359,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint-config-prettier": {
- "version": "8.5.0",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz",
- "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==",
- "dev": true,
- "bin": {
- "eslint-config-prettier": "bin/cli.js"
- },
- "peerDependencies": {
- "eslint": ">=7.0.0"
- }
- },
- "node_modules/eslint-plugin-prettier": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",
- "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==",
- "dev": true,
- "dependencies": {
- "prettier-linter-helpers": "^1.0.0"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "eslint": ">=7.28.0",
- "prettier": ">=2.0.0"
- },
- "peerDependenciesMeta": {
- "eslint-config-prettier": {
- "optional": true
- }
- }
- },
"node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -4718,12 +4676,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
- "node_modules/fast-diff": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
- "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
- "dev": true
- },
"node_modules/fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -6027,7 +5979,6 @@
"@jest/transform": "^29.3.1",
"@jest/types": "^29.3.1",
"@types/babel__traverse": "^7.0.6",
- "@types/prettier": "^2.1.5",
"babel-preset-current-node-syntax": "^1.0.0",
"chalk": "^4.0.0",
"expect": "^29.3.1",
@@ -9716,33 +9667,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/prettier": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
- "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
- "dev": true,
- "bin": {
- "prettier": "bin-prettier.js"
- },
- "engines": {
- "node": ">=10.13.0"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/prettier-linter-helpers": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
- "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
- "dev": true,
- "dependencies": {
- "fast-diff": "^1.1.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/pretty-format": {
"version": "29.3.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.3.1.tgz",
@@ -13662,12 +13586,6 @@
"@types/node": "*"
}
},
- "@types/prettier": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz",
- "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==",
- "dev": true
- },
"@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@@ -14723,22 +14641,6 @@
}
}
},
- "eslint-config-prettier": {
- "version": "8.5.0",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz",
- "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==",
- "dev": true,
- "requires": {}
- },
- "eslint-plugin-prettier": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",
- "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==",
- "dev": true,
- "requires": {
- "prettier-linter-helpers": "^1.0.0"
- }
- },
"eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -14961,12 +14863,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
- "fast-diff": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
- "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
- "dev": true
- },
"fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -15927,7 +15823,6 @@
"@jest/transform": "^29.3.1",
"@jest/types": "^29.3.1",
"@types/babel__traverse": "^7.0.6",
- "@types/prettier": "^2.1.5",
"babel-preset-current-node-syntax": "^1.0.0",
"chalk": "^4.0.0",
"expect": "^29.3.1",
@@ -18582,21 +18477,6 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true
},
- "prettier": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
- "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
- "dev": true
- },
- "prettier-linter-helpers": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
- "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
- "dev": true,
- "requires": {
- "fast-diff": "^1.1.2"
- }
- },
"pretty-format": {
"version": "29.3.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.3.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index 215012a79..e45ed85dc 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -31,12 +31,12 @@
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
+ "prepare": "cd .. && npm install",
"start": "npm run build && node build/index.js",
"dev": "nodemon",
- "build": "rimraf ./build && tsc && cp -R ./src/templates ./src/json ./build",
+ "build": "rimraf ./build && tsc && cp -R ./src/templates ./build",
"lint": "eslint . --ext .ts",
"lint-and-fix": "eslint . --ext .ts --fix",
- "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write",
"lint-staged": "lint-staged",
"pretest": "docker compose -f test-resources/docker-compose.test.yml up -d",
"test": "cross-env NODE_ENV=test jest --testTimeout=10000 --detectOpenHandles",
@@ -71,14 +71,11 @@
"@typescript-eslint/parser": "^5.40.1",
"cross-env": "^7.0.3",
"eslint": "^8.26.0",
- "eslint-config-prettier": "^8.5.0",
- "eslint-plugin-prettier": "^4.2.1",
"install": "^0.13.0",
"jest": "^29.3.1",
"jest-junit": "^15.0.0",
"nodemon": "^2.0.19",
"npm": "^8.19.3",
- "prettier": "^2.7.1",
"supertest": "^6.3.3",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1"
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 1d00c03d9..fa11b5f1c 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -13,6 +13,7 @@ import { apiLimiter } from './helpers/rateLimiter';
import {
signup as signupRouter,
auth as authRouter,
+ bot as botRouter,
organization as organizationRouter,
workspace as workspaceRouter,
membershipOrg as membershipOrgRouter,
@@ -52,6 +53,7 @@ if (NODE_ENV === 'production') {
// routers
app.use('/api/v1/signup', signupRouter);
app.use('/api/v1/auth', authRouter);
+app.use('/api/v1/bot', botRouter);
app.use('/api/v1/user', userRouter);
app.use('/api/v1/user-action', userActionRouter);
app.use('/api/v1/organization', organizationRouter);
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index 5575ceb44..e3d62c42c 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -10,8 +10,13 @@ const JWT_SIGNUP_LIFETIME = process.env.JWT_SIGNUP_LIFETIME! || '15m';
const JWT_SIGNUP_SECRET = process.env.JWT_SIGNUP_SECRET!;
const MONGO_URL = process.env.MONGO_URL!;
const NODE_ENV = process.env.NODE_ENV! || 'production';
-const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_HEROKU!;
-const OAUTH_TOKEN_URL_HEROKU = process.env.OAUTH_TOKEN_URL_HEROKU!;
+const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!;
+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_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!;
+const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!;
+const CLIENT_SLUG_VERCEL= process.env.CLIENT_SLUG_VERCEL!;
const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com';
const POSTHOG_PROJECT_API_KEY =
process.env.POSTHOG_PROJECT_API_KEY! ||
@@ -46,8 +51,13 @@ export {
JWT_SIGNUP_SECRET,
MONGO_URL,
NODE_ENV,
- OAUTH_CLIENT_SECRET_HEROKU,
- OAUTH_TOKEN_URL_HEROKU,
+ CLIENT_ID_HEROKU,
+ CLIENT_ID_VERCEL,
+ CLIENT_ID_NETLIFY,
+ CLIENT_SECRET_HEROKU,
+ CLIENT_SECRET_VERCEL,
+ CLIENT_SECRET_NETLIFY,
+ CLIENT_SLUG_VERCEL,
POSTHOG_HOST,
POSTHOG_PROJECT_API_KEY,
PRIVATE_KEY,
diff --git a/backend/src/controllers/botController.ts b/backend/src/controllers/botController.ts
new file mode 100644
index 000000000..7819e32df
--- /dev/null
+++ b/backend/src/controllers/botController.ts
@@ -0,0 +1,107 @@
+import { Request, Response } from 'express';
+import * as Sentry from '@sentry/node';
+import { Bot, BotKey } from '../models';
+import { createBot } from '../helpers/bot';
+
+interface BotKey {
+ encryptedKey: string;
+ nonce: string;
+}
+
+/**
+ * Return bot for workspace with id [workspaceId]. If a workspace bot doesn't exist,
+ * then create and return a new bot.
+ * @param req
+ * @param res
+ * @returns
+ */
+export const getBotByWorkspaceId = async (req: Request, res: Response) => {
+ let bot;
+ try {
+ const { workspaceId } = req.params;
+
+ bot = await Bot.findOne({
+ workspace: workspaceId
+ });
+
+ if (!bot) {
+ // case: bot doesn't exist for workspace with id [workspaceId]
+ // -> create a new bot and return it
+ bot = await createBot({
+ name: 'Infisical Bot',
+ workspaceId
+ });
+ }
+ } catch (err) {
+ Sentry.setUser({ email: req.user.email });
+ Sentry.captureException(err);
+ return res.status(400).send({
+ message: 'Failed to get bot for workspace'
+ });
+ }
+
+ return res.status(200).send({
+ bot
+ });
+};
+
+/**
+ * Return bot with id [req.bot._id] with active state set to [isActive].
+ * @param req
+ * @param res
+ * @returns
+ */
+export const setBotActiveState = async (req: Request, res: Response) => {
+ let bot;
+ try {
+ const { isActive, botKey }: { isActive: boolean, botKey: BotKey } = req.body;
+
+ if (isActive) {
+ // bot state set to active -> share workspace key with bot
+ if (!botKey?.encryptedKey || !botKey?.nonce) {
+ return res.status(400).send({
+ message: 'Failed to set bot state to active - missing bot key'
+ });
+ }
+
+ await BotKey.findOneAndUpdate({
+ workspace: req.bot.workspace
+ }, {
+ encryptedKey: botKey.encryptedKey,
+ nonce: botKey.nonce,
+ sender: req.user._id,
+ bot: req.bot._id,
+ workspace: req.bot.workspace
+ }, {
+ upsert: true,
+ new: true
+ });
+ } else {
+ // case: bot state set to inactive -> delete bot's workspace key
+ await BotKey.deleteOne({
+ bot: req.bot._id
+ });
+ }
+
+ bot = await Bot.findOneAndUpdate({
+ _id: req.bot._id
+ }, {
+ isActive
+ }, {
+ new: true
+ });
+
+ if (!bot) throw new Error('Failed to update bot active state');
+
+ } catch (err) {
+ Sentry.setUser({ email: req.user.email });
+ Sentry.captureException(err);
+ return res.status(400).send({
+ message: 'Failed to update bot active state'
+ });
+ }
+
+ return res.status(200).send({
+ bot
+ });
+};
diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts
index 2d3debfb5..1da61835f 100644
--- a/backend/src/controllers/index.ts
+++ b/backend/src/controllers/index.ts
@@ -1,4 +1,5 @@
import * as authController from './authController';
+import * as botController from './botController';
import * as integrationAuthController from './integrationAuthController';
import * as integrationController from './integrationController';
import * as keyController from './keyController';
@@ -16,6 +17,7 @@ import * as workspaceController from './workspaceController';
export {
authController,
+ botController,
integrationAuthController,
integrationController,
keyController,
diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts
index 009bcd391..c242c239a 100644
--- a/backend/src/controllers/integrationAuthController.ts
+++ b/backend/src/controllers/integrationAuthController.ts
@@ -3,69 +3,45 @@ import * as Sentry from '@sentry/node';
import axios from 'axios';
import { readFileSync } from 'fs';
import { IntegrationAuth, Integration } from '../models';
-import { processOAuthTokenRes } from '../helpers/integrationAuth';
-import { INTEGRATION_SET, ENV_DEV } from '../variables';
-import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config';
+import { INTEGRATION_SET, INTEGRATION_OPTIONS, ENV_DEV } from '../variables';
+import { IntegrationService } from '../services';
+import { getApps, revokeAccess } from '../integrations';
+
+export const getIntegrationOptions = async (
+ req: Request,
+ res: Response
+) => {
+ return res.status(200).send({
+ integrationOptions: INTEGRATION_OPTIONS
+ });
+}
/**
* Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId]
- * Note: integration [integration] must be set up compatible/designed for OAuth2
* @param req
* @param res
* @returns
*/
-export const integrationAuthOauthExchange = async (
+export const oAuthExchange = async (
req: Request,
res: Response
) => {
try {
- let clientSecret;
-
const { workspaceId, code, integration } = req.body;
if (!INTEGRATION_SET.has(integration))
throw new Error('Failed to validate integration');
-
- // use correct client secret
- switch (integration) {
- case 'heroku':
- clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
- }
-
- // TODO: unfinished - make compatible with other integration types
- const res = await axios.post(
- OAUTH_TOKEN_URL_HEROKU!,
- new URLSearchParams({
- grant_type: 'authorization_code',
- code: code,
- client_secret: clientSecret
- } as any)
- );
-
- const integrationAuth = await processOAuthTokenRes({
+
+ await IntegrationService.handleOAuthExchange({
workspaceId,
integration,
- res
+ code
});
-
- // create or replace integration
- const integrationObj = await Integration.findOneAndUpdate(
- { workspace: workspaceId, integration },
- {
- workspace: workspaceId,
- environment: ENV_DEV,
- isActive: false,
- app: null,
- integration,
- integrationAuth: integrationAuth._id
- },
- { upsert: true, new: true }
- );
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
return res.status(400).send({
- message: 'Failed to get OAuth2 token'
+ message: 'Failed to get OAuth2 code-token exchange'
});
}
@@ -75,26 +51,25 @@ export const integrationAuthOauthExchange = async (
};
/**
- * Return list of applications allowed for integration with id [integrationAuthId]
+ * Return list of applications allowed for integration with integration authorization id [integrationAuthId]
* @param req
* @param res
* @returns
*/
export const getIntegrationAuthApps = async (req: Request, res: Response) => {
- // TODO: unfinished - make compatible with other integration types
let apps;
try {
- const res = await axios.get('https://api.heroku.com/apps', {
- headers: {
- Accept: 'application/vnd.heroku+json; version=3',
- Authorization: 'Bearer ' + req.accessToken
- }
+ apps = await getApps({
+ integrationAuth: req.integrationAuth,
+ accessToken: req.accessToken
});
-
- apps = res.data.map((a: any) => ({
- name: a.name
- }));
- } catch (err) {}
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ return res.status(400).send({
+ message: 'Failed to get integration authorization applications'
+ });
+ }
return res.status(200).send({
apps
@@ -108,46 +83,22 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => {
* @returns
*/
export const deleteIntegrationAuth = async (req: Request, res: Response) => {
- // TODO: unfinished - disable application via Heroku API and make compatible with other integration types
try {
const { integrationAuthId } = req.params;
- // TODO: disable application via Heroku API; figure out what authorization id is
-
- const integrations = JSON.parse(
- readFileSync('./src/json/integrations.json').toString()
- );
-
- let authorizationId;
- switch (req.integrationAuth.integration) {
- case 'heroku':
- authorizationId = integrations.heroku.clientId;
- }
-
- // not sure what authorizationId is?
- // // revoke authorization
- // const res2 = await axios.delete(
- // `https://api.heroku.com/oauth/authorizations/${authorizationId}`,
- // {
- // headers: {
- // 'Accept': 'application/vnd.heroku+json; version=3',
- // 'Authorization': 'Bearer ' + req.accessToken
- // }
- // }
- // );
-
- const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
- _id: integrationAuthId
+ await revokeAccess({
+ integrationAuth: req.integrationAuth,
+ accessToken: req.accessToken
});
-
- if (deletedIntegrationAuth) {
- await Integration.deleteMany({
- integrationAuth: deletedIntegrationAuth._id
- });
- }
} catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to delete integration authorization'
});
}
-};
+
+ return res.status(200).send({
+ message: 'Successfully deleted integration authorization'
+ });
+}
\ No newline at end of file
diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts
index b75d9b74a..910c7e825 100644
--- a/backend/src/controllers/integrationController.ts
+++ b/backend/src/controllers/integrationController.ts
@@ -1,11 +1,9 @@
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import * as Sentry from '@sentry/node';
-import axios from 'axios';
-import { Integration } from '../models';
-import { decryptAsymmetric } from '../utils/crypto';
-import { decryptSecrets } from '../helpers/secret';
-import { PRIVATE_KEY } from '../config';
+import { Integration, Bot, BotKey } from '../models';
+import { EventService } from '../services';
+import { eventPushSecrets } from '../events';
interface Key {
encryptedKey: string;
@@ -24,104 +22,58 @@ interface PushSecret {
type: 'shared' | 'personal';
}
-/**
- * Return list of all available integrations on Infisical
- * @param req
- * @param res
- * @returns
- */
-export const getIntegrations = async (req: Request, res: Response) => {
- let integrations;
- try {
- integrations = JSON.parse(
- readFileSync('./src/json/integrations.json').toString()
- );
- } catch (err) {
- Sentry.setUser(null);
- Sentry.captureException(err);
- return res.status(400).send({
- message: 'Failed to get integrations'
- });
- }
-
- return res.status(200).send({
- integrations
- });
-};
-
-/**
- * Sync secrets [secrets] to integration with id [integrationId]
- * @param req
- * @param res
- * @returns
- */
-export const syncIntegration = async (req: Request, res: Response) => {
- // TODO: unfinished - make more versatile to accomodate for other integrations
- try {
- const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body;
- const symmetricKey = decryptAsymmetric({
- ciphertext: key.encryptedKey,
- nonce: key.nonce,
- publicKey: req.user.publicKey,
- privateKey: PRIVATE_KEY
- });
-
- // decrypt secrets with symmetric key
- const content = decryptSecrets({
- secrets,
- key: symmetricKey,
- format: 'object'
- });
-
- // TODO: make integration work for other integrations as well
- const res = await axios.patch(
- `https://api.heroku.com/apps/${req.integration.app}/config-vars`,
- content,
- {
- headers: {
- Accept: 'application/vnd.heroku+json; version=3',
- Authorization: 'Bearer ' + req.accessToken
- }
- }
- );
- } catch (err) {
- Sentry.setUser(null);
- Sentry.captureException(err);
- return res.status(400).send({
- message: 'Failed to sync secrets with integration'
- });
- }
-
- return res.status(200).send({
- message: 'Successfully synced secrets with integration'
- });
-};
-
/**
* Change environment or name of integration with id [integrationId]
* @param req
* @param res
* @returns
*/
-export const modifyIntegration = async (req: Request, res: Response) => {
+export const updateIntegration = async (req: Request, res: Response) => {
let integration;
+
+ // TODO: add integration-specific validation to ensure that each
+ // integration has the correct fields populated in [Integration]
+
try {
- const { update } = req.body;
-
+ const {
+ app,
+ environment,
+ isActive,
+ target, // vercel-specific integration param
+ context, // netlify-specific integration param
+ siteId // netlify-specific integration param
+ } = req.body;
+
integration = await Integration.findOneAndUpdate(
{
_id: req.integration._id
},
- update,
+ {
+ environment,
+ isActive,
+ app,
+ target,
+ context,
+ siteId
+ },
{
new: true
}
);
+
+ if (integration) {
+ // trigger event - push secrets
+ EventService.handleEvent({
+ event: eventPushSecrets({
+ workspaceId: integration.workspace.toString()
+ })
+ });
+ }
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
- message: 'Failed to modify integration'
+ message: 'Failed to update integration'
});
}
@@ -131,7 +83,8 @@ export const modifyIntegration = async (req: Request, res: Response) => {
};
/**
- * Delete integration with id [integrationId]
+ * Delete integration with id [integrationId] and deactivate bot if there are
+ * no integrations left
* @param req
* @param res
* @returns
@@ -144,6 +97,29 @@ export const deleteIntegration = async (req: Request, res: Response) => {
deletedIntegration = await Integration.findOneAndDelete({
_id: integrationId
});
+
+ if (!deletedIntegration) throw new Error('Failed to find integration');
+
+ const integrations = await Integration.find({
+ workspace: deletedIntegration.workspace
+ });
+
+ if (integrations.length === 0) {
+ // case: no integrations left, deactivate bot
+ const bot = await Bot.findOneAndUpdate({
+ workspace: deletedIntegration.workspace
+ }, {
+ isActive: false
+ }, {
+ new: true
+ });
+
+ if (bot) {
+ await BotKey.deleteOne({
+ bot: bot._id
+ });
+ }
+ }
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
diff --git a/backend/src/controllers/keyController.ts b/backend/src/controllers/keyController.ts
index 1c9b5e15c..778d44b60 100644
--- a/backend/src/controllers/keyController.ts
+++ b/backend/src/controllers/keyController.ts
@@ -17,16 +17,6 @@ export const uploadKey = async (req: Request, res: Response) => {
const { workspaceId } = req.params;
const { key } = req.body;
- // validate membership of sender
- const senderMembership = await findMembership({
- user: req.user._id,
- workspace: workspaceId
- });
-
- if (!senderMembership) {
- throw new Error('Failed sender membership validation for workspace');
- }
-
// validate membership of receiver
const receiverMembership = await findMembership({
user: key.userId,
diff --git a/backend/src/controllers/secretController.ts b/backend/src/controllers/secretController.ts
index d1cf5f65d..bfd9aee1f 100644
--- a/backend/src/controllers/secretController.ts
+++ b/backend/src/controllers/secretController.ts
@@ -7,8 +7,9 @@ import {
reformatPullSecrets
} from '../helpers/secret';
import { pushKeys } from '../helpers/key';
+import { eventPushSecrets } from '../events';
+import { EventService } from '../services';
import { ENV_SET } from '../variables';
-
import { postHogClient } from '../services';
interface PushSecret {
@@ -60,7 +61,8 @@ export const pushSecrets = async (req: Request, res: Response) => {
workspaceId,
keys
});
-
+
+
if (postHogClient) {
postHogClient.capture({
event: 'secrets pushed',
@@ -74,6 +76,13 @@ export const pushSecrets = async (req: Request, res: Response) => {
});
}
+ // trigger event - push secrets
+ EventService.handleEvent({
+ event: eventPushSecrets({
+ workspaceId
+ })
+ });
+
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
@@ -192,7 +201,7 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => {
};
if (postHogClient) {
- // capture secrets pushed event in production
+ // capture secrets pulled event in production
postHogClient.capture({
distinctId: req.serviceToken.user.email,
event: 'secrets pulled',
diff --git a/backend/src/events/index.ts b/backend/src/events/index.ts
new file mode 100644
index 000000000..461a3ece6
--- /dev/null
+++ b/backend/src/events/index.ts
@@ -0,0 +1,5 @@
+import { eventPushSecrets } from "./secret"
+
+export {
+ eventPushSecrets
+}
\ No newline at end of file
diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts
new file mode 100644
index 000000000..8bb3a86c3
--- /dev/null
+++ b/backend/src/events/secret.ts
@@ -0,0 +1,37 @@
+import { EVENT_PUSH_SECRETS } from '../variables';
+
+interface PushSecret {
+ ciphertextKey: string;
+ ivKey: string;
+ tagKey: string;
+ hashKey: string;
+ ciphertextValue: string;
+ ivValue: string;
+ tagValue: string;
+ hashValue: string;
+ type: 'shared' | 'personal';
+}
+
+/**
+ * Return event for pushing secrets
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace to push secrets to
+ * @returns
+ */
+const eventPushSecrets = ({
+ workspaceId,
+}: {
+ workspaceId: string;
+}) => {
+ return ({
+ name: EVENT_PUSH_SECRETS,
+ workspaceId,
+ payload: {
+
+ }
+ });
+}
+
+export {
+ eventPushSecrets
+}
diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts
new file mode 100644
index 000000000..abaf73af4
--- /dev/null
+++ b/backend/src/helpers/bot.ts
@@ -0,0 +1,230 @@
+import * as Sentry from '@sentry/node';
+import {
+ Bot,
+ BotKey,
+ Secret,
+ ISecret,
+ IUser
+} from '../models';
+import {
+ generateKeyPair,
+ encryptSymmetric,
+ decryptSymmetric,
+ decryptAsymmetric
+} from '../utils/crypto';
+import { decryptSecrets } from '../helpers/secret';
+import { ENCRYPTION_KEY } from '../config';
+import { SECRET_SHARED } from '../variables';
+
+/**
+ * Create an inactive bot with name [name] for workspace with id [workspaceId]
+ * @param {Object} obj
+ * @param {String} obj.name - name of bot
+ * @param {String} obj.workspaceId - id of workspace that bot belongs to
+ */
+const createBot = async ({
+ name,
+ workspaceId,
+}: {
+ name: string;
+ workspaceId: string;
+}) => {
+ let bot;
+ try {
+ const { publicKey, privateKey } = generateKeyPair();
+ const { ciphertext, iv, tag } = encryptSymmetric({
+ plaintext: privateKey,
+ key: ENCRYPTION_KEY
+ });
+
+ bot = await new Bot({
+ name,
+ workspace: workspaceId,
+ isActive: false,
+ publicKey,
+ encryptedPrivateKey: ciphertext,
+ iv,
+ tag
+ }).save();
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to create bot');
+ }
+
+ return bot;
+}
+
+/**
+ * Return decrypted secrets for workspace with id [workspaceId]
+ * and [environment] using bot
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace
+ * @param {String} obj.environment - environment
+ */
+const getSecretsHelper = async ({
+ workspaceId,
+ environment
+}: {
+ workspaceId: string;
+ environment: string;
+}) => {
+ const content = {} as any;
+ try {
+ const key = await getKey({ workspaceId });
+ const secrets = await Secret.find({
+ workspaceId,
+ environment,
+ type: SECRET_SHARED
+ });
+
+ secrets.forEach((secret: ISecret) => {
+ const secretKey = decryptSymmetric({
+ ciphertext: secret.secretKeyCiphertext,
+ iv: secret.secretKeyIV,
+ tag: secret.secretKeyTag,
+ key
+ });
+
+ const secretValue = decryptSymmetric({
+ ciphertext: secret.secretValueCiphertext,
+ iv: secret.secretValueIV,
+ tag: secret.secretValueTag,
+ key
+ });
+
+ content[secretKey] = secretValue;
+ });
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get secrets');
+ }
+
+ return content;
+}
+
+/**
+ * Return bot's copy of the workspace key for workspace
+ * with id [workspaceId]
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace
+ * @returns {String} key - decrypted workspace key
+ */
+const getKey = async ({ workspaceId }: { workspaceId: string }) => {
+ let key;
+ try {
+ const botKey = await BotKey.findOne({
+ workspace: workspaceId
+ }).populate<{ sender: IUser }>('sender', 'publicKey');
+
+ if (!botKey) throw new Error('Failed to find bot key');
+
+ const bot = await Bot.findOne({
+ workspace: workspaceId
+ }).select('+encryptedPrivateKey +iv +tag');
+
+ if (!bot) throw new Error('Failed to find bot');
+ if (!bot.isActive) throw new Error('Bot is not active');
+
+ const privateKeyBot = decryptSymmetric({
+ ciphertext: bot.encryptedPrivateKey,
+ iv: bot.iv,
+ tag: bot.tag,
+ key: ENCRYPTION_KEY
+ });
+
+ key = decryptAsymmetric({
+ ciphertext: botKey.encryptedKey,
+ nonce: botKey.nonce,
+ publicKey: botKey.sender.publicKey as string,
+ privateKey: privateKeyBot
+ });
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get workspace key');
+ }
+
+ return key;
+}
+
+/**
+ * Return symmetrically encrypted [plaintext] using the
+ * key for workspace with id [workspaceId]
+ * @param {Object} obj1
+ * @param {String} obj1.workspaceId - id of workspace
+ * @param {String} obj1.plaintext - plaintext to encrypt
+ */
+const encryptSymmetricHelper = async ({
+ workspaceId,
+ plaintext
+}: {
+ workspaceId: string;
+ plaintext: string;
+}) => {
+
+ try {
+ const key = await getKey({ workspaceId });
+ const { ciphertext, iv, tag } = encryptSymmetric({
+ plaintext,
+ key
+ });
+
+ return ({
+ ciphertext,
+ iv,
+ tag
+ });
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to perform symmetric encryption with bot');
+ }
+}
+/**
+ * Return symmetrically decrypted [ciphertext] using the
+ * key for workspace with id [workspaceId]
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace
+ * @param {String} obj.ciphertext - ciphertext to decrypt
+ * @param {String} obj.iv - iv
+ * @param {String} obj.tag - tag
+ */
+const decryptSymmetricHelper = async ({
+ workspaceId,
+ ciphertext,
+ iv,
+ tag
+}: {
+ workspaceId: string;
+ ciphertext: string;
+ iv: string;
+ tag: string;
+}) => {
+ let plaintext;
+ try {
+ const key = await getKey({ workspaceId });
+ const plaintext = decryptSymmetric({
+ ciphertext,
+ iv,
+ tag,
+ key
+ });
+
+ return plaintext;
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to perform symmetric decryption with bot');
+ }
+
+ return plaintext;
+}
+
+export {
+ createBot,
+ getSecretsHelper,
+ encryptSymmetricHelper,
+ decryptSymmetricHelper
+}
\ No newline at end of file
diff --git a/backend/src/helpers/event.ts b/backend/src/helpers/event.ts
new file mode 100644
index 000000000..4128752e5
--- /dev/null
+++ b/backend/src/helpers/event.ts
@@ -0,0 +1,51 @@
+import { Bot, IBot } from '../models';
+import * as Sentry from '@sentry/node';
+import { EVENT_PUSH_SECRETS } from '../variables';
+import { IntegrationService } from '../services';
+
+interface Event {
+ name: string;
+ workspaceId: string;
+ payload: any;
+}
+
+/**
+ * Handle event [event]
+ * @param {Object} obj
+ * @param {Event} obj.event - an event
+ * @param {String} obj.event.name - name of event
+ * @param {String} obj.event.workspaceId - id of workspace that event is part of
+ * @param {Object} obj.event.payload - payload of event (depends on event)
+ */
+const handleEventHelper = async ({
+ event
+}: {
+ event: Event;
+}) => {
+ const { workspaceId } = event;
+
+ // TODO: moduralize bot check into separate function
+ const bot = await Bot.findOne({
+ workspace: workspaceId,
+ isActive: true
+ });
+
+ if (!bot) return;
+
+ try {
+ switch (event.name) {
+ case EVENT_PUSH_SECRETS:
+ IntegrationService.syncIntegrations({
+ workspaceId
+ });
+ break;
+ }
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ }
+}
+
+export {
+ handleEventHelper
+}
\ No newline at end of file
diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts
index e69de29bb..800c90bf4 100644
--- a/backend/src/helpers/integration.ts
+++ b/backend/src/helpers/integration.ts
@@ -0,0 +1,350 @@
+import * as Sentry from '@sentry/node';
+import {
+ Bot,
+ Integration,
+ IIntegration,
+ IntegrationAuth,
+ IIntegrationAuth
+} from '../models';
+import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations';
+import { BotService, IntegrationService } from '../services';
+import {
+ ENV_DEV,
+ EVENT_PUSH_SECRETS,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY
+} from '../variables';
+
+interface Update {
+ workspace: string;
+ integration: string;
+ teamId?: string;
+ accountId?: string;
+}
+
+/**
+ * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration
+ * named [integration]
+ * - 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.integration - name of integration
+ * @param {String} obj.code - code
+*/
+const handleOAuthExchangeHelper = async ({
+ workspaceId,
+ integration,
+ code
+}: {
+ workspaceId: string;
+ integration: string;
+ code: string;
+}) => {
+ let action;
+ let integrationAuth;
+ try {
+ const bot = await Bot.findOne({
+ workspace: workspaceId,
+ isActive: true
+ });
+
+ if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange');
+
+ // exchange code for access and refresh tokens
+ const res = await exchangeCode({
+ integration,
+ code
+ });
+
+ const update: Update = {
+ workspace: workspaceId,
+ integration
+ }
+
+ switch (integration) {
+ case INTEGRATION_VERCEL:
+ update.teamId = res.teamId;
+ break;
+ case INTEGRATION_NETLIFY:
+ update.accountId = res.accountId;
+ break;
+ }
+
+ integrationAuth = await IntegrationAuth.findOneAndUpdate({
+ workspace: workspaceId,
+ integration
+ }, update, {
+ new: true,
+ upsert: true
+ });
+
+ if (res.refreshToken) {
+ // case: refresh token returned from exchange
+ // set integration auth refresh token
+ await setIntegrationAuthRefreshHelper({
+ integrationAuthId: integrationAuth._id.toString(),
+ refreshToken: res.refreshToken
+ });
+ }
+
+ if (res.accessToken) {
+ // case: access token returned from exchange
+ // set integration auth access token
+ await setIntegrationAuthAccessHelper({
+ integrationAuthId: integrationAuth._id.toString(),
+ accessToken: res.accessToken,
+ accessExpiresAt: res.accessExpiresAt
+ });
+ }
+
+ // initialize new integration after exchange
+ await new Integration({
+ workspace: workspaceId,
+ environment: ENV_DEV,
+ isActive: false,
+ app: null,
+ integration,
+ integrationAuth: integrationAuth._id
+ }).save();
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to handle OAuth2 code-token exchange')
+ }
+}
+/**
+ * Sync/push environment variables in workspace with id [workspaceId] to
+ * all active integrations for that workspace
+ * @param {Object} obj
+ * @param {Object} obj.workspaceId - id of workspace
+ */
+const syncIntegrationsHelper = async ({
+ workspaceId
+}: {
+ workspaceId: string;
+}) => {
+ let integrations;
+ try {
+
+ integrations = await Integration.find({
+ workspace: workspaceId,
+ isActive: true,
+ app: { $ne: null }
+ });
+
+ // for each workspace integration, sync/push secrets
+ // to that integration
+ for await (const integration of integrations) {
+ // get workspace, environment (shared) secrets
+ const secrets = await BotService.getSecrets({
+ workspaceId: integration.workspace.toString(),
+ environment: integration.environment
+ });
+
+ const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth);
+ if (!integrationAuth) throw new Error('Failed to find integration auth');
+
+ // get integration auth access token
+ const accessToken = await getIntegrationAuthAccessHelper({
+ integrationAuthId: integration.integrationAuth.toString()
+ });
+
+ // sync secrets to integration
+ await syncSecrets({
+ integration,
+ integrationAuth,
+ secrets,
+ accessToken
+ });
+ }
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to sync secrets to integrations');
+ }
+}
+
+/**
+ * Return decrypted refresh token using the bot's copy
+ * of the workspace key for workspace belonging to integration auth
+ * with id [integrationAuthId]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} refreshToken - decrypted refresh token
+ */
+ const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => {
+ let refreshToken;
+ try {
+ const integrationAuth = await IntegrationAuth
+ .findById(integrationAuthId)
+ .select('+refreshCiphertext +refreshIV +refreshTag');
+
+ if (!integrationAuth) throw new Error('Failed to find integration auth');
+
+ refreshToken = await BotService.decryptSymmetric({
+ workspaceId: integrationAuth.workspace.toString(),
+ ciphertext: integrationAuth.refreshCiphertext as string,
+ iv: integrationAuth.refreshIV as string,
+ tag: integrationAuth.refreshTag as string
+ });
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get integration refresh token');
+ }
+
+ return refreshToken;
+}
+
+/**
+ * Return decrypted access token using the bot's copy
+ * of the workspace key for workspace belonging to integration auth
+ * with id [integrationAuthId]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @returns {String} accessToken - decrypted access token
+ */
+const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => {
+ let accessToken;
+ try {
+ const integrationAuth = await IntegrationAuth
+ .findById(integrationAuthId)
+ .select('workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt + refreshCiphertext');
+
+ if (!integrationAuth) throw new Error('Failed to find integration auth');
+
+ accessToken = await BotService.decryptSymmetric({
+ workspaceId: integrationAuth.workspace.toString(),
+ ciphertext: integrationAuth.accessCiphertext as string,
+ iv: integrationAuth.accessIV as string,
+ tag: integrationAuth.accessTag as string
+ });
+
+ if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) {
+ // there is a access token expiration date
+ // and refresh token to exchange with the OAuth2 server
+
+ if (integrationAuth.accessExpiresAt < new Date()) {
+ // access token is expired
+ const refreshToken = await getIntegrationAuthRefreshHelper({ integrationAuthId });
+ accessToken = await exchangeRefresh({
+ integration: integrationAuth.integration,
+ refreshToken
+ });
+ }
+ }
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get integration access token');
+ }
+
+ return accessToken;
+}
+
+/**
+ * Encrypt refresh token [refreshToken] using the bot's copy
+ * of the workspace key for workspace belonging to integration auth
+ * with id [integrationAuthId] and store it
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} obj.refreshToken - refresh token
+ */
+const setIntegrationAuthRefreshHelper = async ({
+ integrationAuthId,
+ refreshToken
+}: {
+ integrationAuthId: string;
+ refreshToken: string;
+}) => {
+
+ let integrationAuth;
+ try {
+ integrationAuth = await IntegrationAuth
+ .findById(integrationAuthId);
+
+ if (!integrationAuth) throw new Error('Failed to find integration auth');
+
+ const obj = await BotService.encryptSymmetric({
+ workspaceId: integrationAuth.workspace.toString(),
+ plaintext: refreshToken
+ });
+
+ integrationAuth = await IntegrationAuth.findOneAndUpdate({
+ _id: integrationAuthId
+ }, {
+ refreshCiphertext: obj.ciphertext,
+ refreshIV: obj.iv,
+ refreshTag: obj.tag
+ }, {
+ new: true
+ });
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to set integration auth refresh token');
+ }
+
+ return integrationAuth;
+}
+
+/**
+ * Encrypt access token [accessToken] using the bot's copy
+ * of the workspace key for workspace belonging to integration auth
+ * with id [integrationAuthId] and store it along with [accessExpiresAt]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} obj.accessToken - access token
+ * @param {Date} obj.accessExpiresAt - expiration date of access token
+ */
+const setIntegrationAuthAccessHelper = async ({
+ integrationAuthId,
+ accessToken,
+ accessExpiresAt
+}: {
+ integrationAuthId: string;
+ accessToken: string;
+ accessExpiresAt: Date;
+}) => {
+ let integrationAuth;
+ try {
+ integrationAuth = await IntegrationAuth.findById(integrationAuthId);
+
+ if (!integrationAuth) throw new Error('Failed to find integration auth');
+
+ const obj = await BotService.encryptSymmetric({
+ workspaceId: integrationAuth.workspace.toString(),
+ plaintext: accessToken
+ });
+
+ integrationAuth = await IntegrationAuth.findOneAndUpdate({
+ _id: integrationAuthId
+ }, {
+ accessCiphertext: obj.ciphertext,
+ accessIV: obj.iv,
+ accessTag: obj.tag,
+ accessExpiresAt
+ }, {
+ new: true
+ });
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to save integration auth access token');
+ }
+
+ return integrationAuth;
+}
+
+export {
+ handleOAuthExchangeHelper,
+ syncIntegrationsHelper,
+ getIntegrationAuthRefreshHelper,
+ getIntegrationAuthAccessHelper,
+ setIntegrationAuthRefreshHelper,
+ setIntegrationAuthAccessHelper
+}
\ No newline at end of file
diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts
index 17f101676..e69de29bb 100644
--- a/backend/src/helpers/integrationAuth.ts
+++ b/backend/src/helpers/integrationAuth.ts
@@ -1,174 +0,0 @@
-import * as Sentry from '@sentry/node';
-import axios from 'axios';
-import { IntegrationAuth } from '../models';
-import { encryptSymmetric, decryptSymmetric } from '../utils/crypto';
-import { IIntegrationAuth } from '../models';
-import {
- ENCRYPTION_KEY,
- OAUTH_CLIENT_SECRET_HEROKU,
- OAUTH_TOKEN_URL_HEROKU
-} from '../config';
-
-/**
- * Process token exchange and refresh responses from respective OAuth2 authorization servers by
- * encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt],
- * and upserting them into the DB for workspace with id [workspaceId] and integration [integration].
- * @param {Object} obj
- * @param {String} obj.workspaceId - id of workspace
- * @param {String} obj.integration - name of integration (e.g. heroku)
- * @param {Object} obj.res - response from OAuth2 authorization server
- */
-const processOAuthTokenRes = async ({
- workspaceId,
- integration,
- res
-}: {
- workspaceId: string;
- integration: string;
- res: any;
-}): Promise => {
- let integrationAuth;
- try {
- // encrypt refresh + access tokens
- const {
- ciphertext: refreshCiphertext,
- iv: refreshIV,
- tag: refreshTag
- } = encryptSymmetric({
- plaintext: res.data.refresh_token,
- key: ENCRYPTION_KEY
- });
-
- const {
- ciphertext: accessCiphertext,
- iv: accessIV,
- tag: accessTag
- } = encryptSymmetric({
- plaintext: res.data.access_token,
- key: ENCRYPTION_KEY
- });
-
- // compute access token expiration date
- const accessExpiresAt = new Date();
- accessExpiresAt.setSeconds(
- accessExpiresAt.getSeconds() + res.data.expires_in
- );
-
- // create or replace integration authorization with encrypted tokens
- // and access token expiration date
- integrationAuth = await IntegrationAuth.findOneAndUpdate(
- { workspace: workspaceId, integration },
- {
- workspace: workspaceId,
- integration,
- refreshCiphertext,
- refreshIV,
- refreshTag,
- accessCiphertext,
- accessIV,
- accessTag,
- accessExpiresAt
- },
- { upsert: true, new: true }
- );
- } catch (err) {
- Sentry.setUser(null);
- Sentry.captureException(err);
- throw new Error(
- 'Failed to process OAuth2 authorization server token response'
- );
- }
-
- return integrationAuth;
-};
-
-/**
- * Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on
- * the integration authorization document or by requesting a new one by decrypting and exchanging the
- * refresh token [refreshCiphertext] with the respective OAuth2 authorization server.
- * @param {Object} obj
- * @param {IIntegrationAuth} obj.integrationAuth - an integration authorization document
- * @returns {String} access token - new access token
- */
-const getOAuthAccessToken = async ({
- integrationAuth
-}: {
- integrationAuth: IIntegrationAuth;
-}) => {
- let accessToken;
- try {
- const {
- refreshCiphertext,
- refreshIV,
- refreshTag,
- accessCiphertext,
- accessIV,
- accessTag,
- accessExpiresAt
- } = integrationAuth;
-
- if (
- refreshCiphertext &&
- refreshIV &&
- refreshTag &&
- accessCiphertext &&
- accessIV &&
- accessTag &&
- accessExpiresAt
- ) {
- if (accessExpiresAt < new Date()) {
- // case: access token expired
- // TODO: fetch another access token
-
- let clientSecret;
- switch (integrationAuth.integration) {
- case 'heroku':
- clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
- }
-
- // record new access token and refresh token
- // encrypt refresh + access tokens
- const refreshToken = decryptSymmetric({
- ciphertext: refreshCiphertext,
- iv: refreshIV,
- tag: refreshTag,
- key: ENCRYPTION_KEY
- });
-
- // TODO: make route compatible with other integration types
- const res = await axios.post(
- OAUTH_TOKEN_URL_HEROKU, // maybe shouldn't be a config variable?
- new URLSearchParams({
- grant_type: 'refresh_token',
- refresh_token: refreshToken,
- client_secret: clientSecret
- } as any)
- );
-
- accessToken = res.data.access_token;
-
- await processOAuthTokenRes({
- workspaceId: integrationAuth.workspace.toString(),
- integration: integrationAuth.integration,
- res
- });
- } else {
- // case: access token still works
- accessToken = decryptSymmetric({
- ciphertext: accessCiphertext,
- iv: accessIV,
- tag: accessTag,
- key: ENCRYPTION_KEY
- });
- }
- }
- } catch (err) {
- Sentry.setUser(null);
- Sentry.captureException(err);
- throw new Error('Failed to get OAuth2 access token');
- }
-
- return accessToken;
-};
-
-export { processOAuthTokenRes, getOAuthAccessToken };
diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts
index 14cd567bb..1ff542f2a 100644
--- a/backend/src/helpers/membership.ts
+++ b/backend/src/helpers/membership.ts
@@ -1,6 +1,51 @@
import * as Sentry from '@sentry/node';
import { Membership, Key } from '../models';
+/**
+ * Validate that user with id [userId] is a member of workspace with id [workspaceId]
+ * and has at least one of the roles in [acceptedRoles] and statuses in [acceptedStatuses]
+ * @param {Object} obj
+ * @param {String} obj.userId - id of user to validate
+ * @param {String} obj.workspaceId - id of workspace
+ */
+const validateMembership = async ({
+ userId,
+ workspaceId,
+ acceptedRoles,
+ acceptedStatuses
+}: {
+ userId: string;
+ workspaceId: string;
+ acceptedRoles: string[];
+ acceptedStatuses: string[];
+}) => {
+
+ let membership;
+ try {
+ membership = await Membership.findOne({
+ user: userId,
+ workspace: workspaceId
+ });
+
+ if (!membership) throw new Error('Failed to find membership');
+
+ if (!acceptedRoles.includes(membership.role)) {
+ throw new Error('Failed to validate membership role');
+ }
+
+ if (!acceptedStatuses.includes(membership.status)) {
+ throw new Error('Failed to validate membership status');
+ }
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to validate membership');
+ }
+
+ return membership;
+}
+
/**
* Return membership matching criteria specified in query [queryObj]
* @param {Object} queryObj - query object
@@ -97,4 +142,9 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
return deletedMembership;
};
-export { addMemberships, findMembership, deleteMembership };
+export {
+ validateMembership,
+ addMemberships,
+ findMembership,
+ deleteMembership
+};
diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts
index 52d7d227b..b43252bf3 100644
--- a/backend/src/helpers/workspace.ts
+++ b/backend/src/helpers/workspace.ts
@@ -1,13 +1,16 @@
import * as Sentry from '@sentry/node';
import {
Workspace,
+ Bot,
Membership,
Key,
Secret
} from '../models';
+import { createBot } from '../helpers/bot';
/**
* Create a workspace with name [name] in organization with id [organizationId]
+ * and a bot for it.
* @param {String} name - name of workspace to create.
* @param {String} organizationId - id of organization to create workspace in
* @param {Object} workspace - new workspace
@@ -21,10 +24,16 @@ const createWorkspace = async ({
}) => {
let workspace;
try {
+ // create workspace
workspace = await new Workspace({
name,
organization: organizationId
}).save();
+
+ const bot = await createBot({
+ name: 'Infisical Bot',
+ workspaceId: workspace._id.toString()
+ });
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
@@ -43,6 +52,9 @@ const createWorkspace = async ({
const deleteWorkspace = async ({ id }: { id: string }) => {
try {
await Workspace.deleteOne({ _id: id });
+ await Bot.deleteOne({
+ workspace: id
+ });
await Membership.deleteMany({
workspace: id
});
diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts
new file mode 100644
index 000000000..70680ef7d
--- /dev/null
+++ b/backend/src/integrations/apps.ts
@@ -0,0 +1,169 @@
+import axios from 'axios';
+import * as Sentry from '@sentry/node';
+import {
+ IIntegrationAuth
+} from '../models';
+import {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY,
+ INTEGRATION_HEROKU_API_URL,
+ INTEGRATION_VERCEL_API_URL,
+ INTEGRATION_NETLIFY_API_URL
+} from '../variables';
+
+/**
+ * Return list of names of apps for integration named [integration]
+ * @param {Object} obj
+ * @param {String} obj.integration - name of integration
+ * @param {String} obj.accessToken - access token for integration
+ * @returns {Object[]} apps - names of integration apps
+ * @returns {String} apps.name - name of integration app
+ */
+const getApps = async ({
+ integrationAuth,
+ accessToken
+}: {
+ integrationAuth: IIntegrationAuth;
+ accessToken: string;
+}) => {
+
+ interface App {
+ name: string;
+ siteId?: string;
+ }
+
+ let apps: App[]; // TODO: add type and define payloads for apps
+ try {
+ switch (integrationAuth.integration) {
+ case INTEGRATION_HEROKU:
+ apps = await getAppsHeroku({
+ accessToken
+ });
+ break;
+ case INTEGRATION_VERCEL:
+ apps = await getAppsVercel({
+ accessToken
+ });
+ break;
+ case INTEGRATION_NETLIFY:
+ apps = await getAppsNetlify({
+ integrationAuth,
+ accessToken
+ });
+ break;
+ }
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get integration apps');
+ }
+
+ return apps;
+}
+
+/**
+ * Return list of names of apps for Heroku integration
+ * @param {Object} obj
+ * @param {String} obj.accessToken - access token for Heroku API
+ * @returns {Object[]} apps - names of Heroku apps
+ * @returns {String} apps.name - name of Heroku app
+ */
+const getAppsHeroku = async ({
+ accessToken
+}: {
+ accessToken: string;
+}) => {
+ let apps;
+ try {
+ const res = (await axios.get(`${INTEGRATION_HEROKU_API_URL}/apps`, {
+ headers: {
+ Accept: 'application/vnd.heroku+json; version=3',
+ Authorization: `Bearer ${accessToken}`
+ }
+ })).data;
+
+ apps = res.map((a: any) => ({
+ name: a.name
+ }));
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get Heroku integration apps');
+ }
+
+ return apps;
+}
+
+/**
+ * Return list of names of apps for Vercel integration
+ * @param {Object} obj
+ * @param {String} obj.accessToken - access token for Vercel API
+ * @returns {Object[]} apps - names of Vercel apps
+ * @returns {String} apps.name - name of Vercel app
+ */
+const getAppsVercel = async ({
+ accessToken
+}: {
+ accessToken: string;
+}) => {
+ let apps;
+ try {
+ const res = (await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ })).data;
+
+ apps = res.projects.map((a: any) => ({
+ name: a.name
+ }));
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get Vercel integration apps');
+ }
+
+ return apps;
+}
+
+/**
+ * Return list of names of sites for Netlify integration
+ * @param {Object} obj
+ * @param {String} obj.accessToken - access token for Netlify API
+ * @returns {Object[]} apps - names of Netlify sites
+ * @returns {String} apps.name - name of Netlify site
+ */
+const getAppsNetlify = async ({
+ integrationAuth,
+ accessToken
+}: {
+ integrationAuth: IIntegrationAuth;
+ accessToken: string;
+}) => {
+ let apps;
+ try {
+ const res = (await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ })).data;
+
+ apps = res.map((a: any) => ({
+ name: a.name,
+ siteId: a.site_id
+ }));
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get Netlify integration apps');
+ }
+
+ return apps;
+}
+
+export {
+ getApps
+}
\ No newline at end of file
diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts
new file mode 100644
index 000000000..265036ee0
--- /dev/null
+++ b/backend/src/integrations/exchange.ts
@@ -0,0 +1,241 @@
+import axios from 'axios';
+import * as Sentry from '@sentry/node';
+import {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY,
+ INTEGRATION_HEROKU_TOKEN_URL,
+ INTEGRATION_VERCEL_TOKEN_URL,
+ INTEGRATION_NETLIFY_TOKEN_URL,
+ ACTION_PUSH_TO_HEROKU
+} from '../variables';
+import {
+ SITE_URL,
+ CLIENT_SECRET_HEROKU,
+ CLIENT_ID_VERCEL,
+ CLIENT_ID_NETLIFY,
+ CLIENT_SECRET_VERCEL,
+ CLIENT_SECRET_NETLIFY
+} from '../config';
+
+interface ExchangeCodeHerokuResponse {
+ token_type: string;
+ access_token: string;
+ expires_in: number;
+ refresh_token: string;
+ user_id: string;
+ session_nonce?: string;
+}
+
+interface ExchangeCodeVercelResponse {
+ token_type: string;
+ access_token: string;
+ installation_id: string;
+ user_id: string;
+ team_id?: string;
+}
+
+interface ExchangeCodeNetlifyResponse {
+ access_token: string;
+ token_type: string;
+ refresh_token: string;
+ scope: string;
+ created_at: number;
+}
+
+/**
+ * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2
+ * code-token exchange for integration named [integration]
+ * @param {Object} obj1
+ * @param {String} obj1.integration - name of integration
+ * @param {String} obj1.code - code for code-token exchange
+ * @returns {Object} obj
+ * @returns {String} obj.accessToken - access token for integration
+ * @returns {String} obj.refreshToken - refresh token for integration
+ * @returns {Date} obj.accessExpiresAt - date of expiration for access token
+ * @returns {String} obj.action - integration action for bot sequence
+ */
+const exchangeCode = async ({
+ integration,
+ code
+}: {
+ integration: string;
+ code: string;
+}) => {
+ let obj = {} as any;
+
+ try {
+ switch (integration) {
+ case INTEGRATION_HEROKU:
+ obj = await exchangeCodeHeroku({
+ code
+ });
+ break;
+ case INTEGRATION_VERCEL:
+ obj = await exchangeCodeVercel({
+ code
+ });
+ break;
+ case INTEGRATION_NETLIFY:
+ obj = await exchangeCodeNetlify({
+ code
+ });
+ break;
+ }
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed OAuth2 code-token exchange');
+ }
+
+ return obj;
+}
+
+/**
+ * Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku
+ * OAuth2 code-token exchange
+ * @param {Object} obj1
+ * @param {Object} obj1.code - code for code-token exchange
+ * @returns {Object} obj2
+ * @returns {String} obj2.accessToken - access token for Heroku API
+ * @returns {String} obj2.refreshToken - refresh token for Heroku API
+ * @returns {Date} obj2.accessExpiresAt - date of expiration for access token
+ */
+const exchangeCodeHeroku = async ({
+ code
+}: {
+ code: string;
+}) => {
+ let res: ExchangeCodeHerokuResponse;
+ const accessExpiresAt = new Date();
+ try {
+ res = (await axios.post(
+ INTEGRATION_HEROKU_TOKEN_URL,
+ new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: code,
+ client_secret: CLIENT_SECRET_HEROKU
+ } as any)
+ )).data;
+
+ accessExpiresAt.setSeconds(
+ accessExpiresAt.getSeconds() + res.expires_in
+ );
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed OAuth2 code-token exchange with Heroku');
+ }
+
+ return ({
+ accessToken: res.access_token,
+ refreshToken: res.refresh_token,
+ accessExpiresAt
+ });
+}
+
+/**
+ * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel
+ * code-token exchange
+ * @param {Object} obj1
+ * @param {Object} obj1.code - code for code-token exchange
+ * @returns {Object} obj2
+ * @returns {String} obj2.accessToken - access token for Heroku API
+ * @returns {String} obj2.refreshToken - refresh token for Heroku API
+ * @returns {Date} obj2.accessExpiresAt - date of expiration for access token
+ */
+const exchangeCodeVercel = async ({
+ code
+}: {
+ code: string;
+}) => {
+ let res: ExchangeCodeVercelResponse;
+ try {
+ res = (await axios.post(
+ INTEGRATION_VERCEL_TOKEN_URL,
+ new URLSearchParams({
+ code: code,
+ client_id: CLIENT_ID_VERCEL,
+ client_secret: CLIENT_SECRET_VERCEL,
+ redirect_uri: `${SITE_URL}/vercel`
+ } as any)
+ )).data;
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed OAuth2 code-token exchange with Vercel');
+ }
+
+ return ({
+ accessToken: res.access_token,
+ refreshToken: null,
+ accessExpiresAt: null,
+ teamId: res.team_id
+ });
+}
+
+/**
+ * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel
+ * code-token exchange
+ * @param {Object} obj1
+ * @param {Object} obj1.code - code for code-token exchange
+ * @returns {Object} obj2
+ * @returns {String} obj2.accessToken - access token for Heroku API
+ * @returns {String} obj2.refreshToken - refresh token for Heroku API
+ * @returns {Date} obj2.accessExpiresAt - date of expiration for access token
+ */
+const exchangeCodeNetlify = async ({
+ code
+}: {
+ code: string;
+}) => {
+ let res: ExchangeCodeNetlifyResponse;
+ let accountId;
+ try {
+ res = (await axios.post(
+ INTEGRATION_NETLIFY_TOKEN_URL,
+ new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: code,
+ client_id: CLIENT_ID_NETLIFY,
+ client_secret: CLIENT_SECRET_NETLIFY,
+ redirect_uri: `${SITE_URL}/netlify`
+ } as any)
+ )).data;
+
+ const res2 = await axios.get(
+ 'https://api.netlify.com/api/v1/sites',
+ {
+ headers: {
+ Authorization: `Bearer ${res.access_token}`
+ }
+ }
+ );
+
+ const res3 = (await axios.get(
+ 'https://api.netlify.com/api/v1/accounts',
+ {
+ headers: {
+ Authorization: `Bearer ${res.access_token}`
+ }
+ }
+ )).data;
+
+ accountId = res3[0].id;
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed OAuth2 code-token exchange with Netlify');
+ }
+
+ return ({
+ accessToken: res.access_token,
+ refreshToken: res.refresh_token,
+ accountId
+ });
+}
+
+export {
+ exchangeCode
+}
\ No newline at end of file
diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts
new file mode 100644
index 000000000..86c22de0c
--- /dev/null
+++ b/backend/src/integrations/index.ts
@@ -0,0 +1,13 @@
+import { exchangeCode } from './exchange';
+import { exchangeRefresh } from './refresh';
+import { getApps } from './apps';
+import { syncSecrets } from './sync';
+import { revokeAccess } from './revoke';
+
+export {
+ exchangeCode,
+ exchangeRefresh,
+ getApps,
+ syncSecrets,
+ revokeAccess
+}
\ No newline at end of file
diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts
new file mode 100644
index 000000000..16870944d
--- /dev/null
+++ b/backend/src/integrations/refresh.ts
@@ -0,0 +1,78 @@
+import axios from 'axios';
+import * as Sentry from '@sentry/node';
+import { INTEGRATION_HEROKU } from '../variables';
+import {
+ CLIENT_SECRET_HEROKU
+} from '../config';
+import {
+ INTEGRATION_HEROKU_TOKEN_URL
+} from '../variables';
+
+/**
+ * Return new access token by exchanging refresh token [refreshToken] for integration
+ * named [integration]
+ * @param {Object} obj
+ * @param {String} obj.integration - name of integration
+ * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku
+ */
+const exchangeRefresh = async ({
+ integration,
+ refreshToken
+}: {
+ integration: string;
+ refreshToken: string;
+}) => {
+ let accessToken;
+ try {
+ switch (integration) {
+ case INTEGRATION_HEROKU:
+ accessToken = await exchangeRefreshHeroku({
+ refreshToken
+ });
+ break;
+ }
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to get new OAuth2 access token');
+ }
+
+ return accessToken;
+}
+
+/**
+ * Return new access token by exchanging refresh token [refreshToken] for the
+ * Heroku integration
+ * @param {Object} obj
+ * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku
+ * @returns
+ */
+const exchangeRefreshHeroku = async ({
+ refreshToken
+}: {
+ refreshToken: string;
+}) => {
+ 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');
+ }
+
+ return accessToken;
+}
+
+export {
+ exchangeRefresh
+}
\ No newline at end of file
diff --git a/backend/src/integrations/revoke.ts b/backend/src/integrations/revoke.ts
new file mode 100644
index 000000000..43a1582b7
--- /dev/null
+++ b/backend/src/integrations/revoke.ts
@@ -0,0 +1,50 @@
+import axios from 'axios';
+import * as Sentry from '@sentry/node';
+import {
+ IIntegrationAuth,
+ IntegrationAuth,
+ Integration
+} from '../models';
+import {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY
+} from '../variables';
+
+const revokeAccess = async ({
+ integrationAuth,
+ accessToken
+}: {
+ integrationAuth: IIntegrationAuth,
+ accessToken: string
+}) => {
+ try {
+ // add any integration-specific revocation logic
+ switch (integrationAuth.integration) {
+ case INTEGRATION_HEROKU:
+ break;
+ case INTEGRATION_VERCEL:
+ break;
+ case INTEGRATION_NETLIFY:
+ break;
+ }
+
+ const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
+ _id: integrationAuth._id
+ });
+
+ if (deletedIntegrationAuth) {
+ await Integration.deleteMany({
+ integrationAuth: deletedIntegrationAuth._id
+ });
+ }
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to delete integration authorization');
+ }
+}
+
+export {
+ revokeAccess
+}
\ No newline at end of file
diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts
new file mode 100644
index 000000000..e6545a102
--- /dev/null
+++ b/backend/src/integrations/sync.ts
@@ -0,0 +1,598 @@
+import axios from 'axios';
+import * as Sentry from '@sentry/node';
+import {
+ IIntegration, IIntegrationAuth
+} from '../models';
+import {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY,
+ INTEGRATION_HEROKU_API_URL,
+ INTEGRATION_VERCEL_API_URL,
+ INTEGRATION_NETLIFY_API_URL
+} from '../variables';
+
+// TODO: need a helper function in the future to handle integration
+// envar priorities (i.e. prioritize secrets within integration or those on Infisical)
+
+/**
+ * Sync/push [secrets] to [app] in integration named [integration]
+ * @param {Object} obj
+ * @param {IIntegration} obj.integration - integration details
+ * @param {IIntegrationAuth} obj.integrationAuth - integration auth details
+ * @param {Object} obj.app - app in integration
+ * @param {Object} obj.target - (optional) target (environment) in integration
+ * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
+ * @param {String} obj.accessToken - access token for integration
+ */
+const syncSecrets = async ({
+ integration,
+ integrationAuth,
+ secrets,
+ accessToken,
+}: {
+ integration: IIntegration;
+ integrationAuth: IIntegrationAuth;
+ secrets: any;
+ accessToken: string;
+}) => {
+ try {
+ switch (integration.integration) {
+ case INTEGRATION_HEROKU:
+ await syncSecretsHeroku({
+ integration,
+ secrets,
+ accessToken
+ });
+ break;
+ case INTEGRATION_VERCEL:
+ await syncSecretsVercel({
+ integration,
+ secrets,
+ accessToken
+ });
+ break;
+ case INTEGRATION_NETLIFY:
+ await syncSecretsNetlify({
+ integration,
+ integrationAuth,
+ secrets,
+ accessToken
+ });
+ break;
+ }
+
+ // TODO: set integration to inactive if it was not synced correctly (send alert?)
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to sync secrets to integration');
+ }
+}
+
+/**
+ * Sync/push [secrets] to Heroku [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)
+ */
+const syncSecretsHeroku = async ({
+ integration,
+ secrets,
+ accessToken
+}: {
+ integration: IIntegration,
+ secrets: any;
+ accessToken: string;
+}) => {
+ try {
+ const herokuSecrets = (await axios.get(
+ `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`,
+ {
+ headers: {
+ Accept: 'application/vnd.heroku+json; version=3',
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ )).data;
+
+ Object.keys(herokuSecrets).forEach(key => {
+ if (!(key in secrets)) {
+ secrets[key] = null;
+ }
+ });
+
+ await axios.patch(
+ `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`,
+ secrets,
+ {
+ headers: {
+ Accept: 'application/vnd.heroku+json; version=3',
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to sync secrets to Heroku');
+ }
+}
+
+/**
+ * Sync/push [secrets] to Heroku [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)
+ */
+const syncSecretsVercel = async ({
+ integration,
+ secrets,
+ accessToken
+}: {
+ integration: IIntegration,
+ secrets: any;
+ accessToken: string;
+}) => {
+
+ interface VercelSecret {
+ id?: string;
+ type: string;
+ key: string;
+ value: string;
+ target: string[];
+ }
+
+ try {
+ // Get all (decrypted) secrets back from Vercel in
+ // decrypted format
+ const params = new URLSearchParams({
+ decrypt: "true"
+ });
+
+ const res = (await Promise.all((await axios.get(
+ `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`,
+ {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ ))
+ .data
+ .envs
+ .filter((secret: VercelSecret) => secret.target.includes(integration.target))
+ .map(async (secret: VercelSecret) => (await axios.get(
+ `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+
+ }
+ )).data)
+ )).reduce((obj: any, secret: any) => ({
+ ...obj,
+ [secret.key]: secret
+ }), {});
+
+ const updateSecrets: VercelSecret[] = [];
+ const deleteSecrets: VercelSecret[] = [];
+ const newSecrets: VercelSecret[] = [];
+
+ // Identify secrets to create
+ Object.keys(secrets).map((key) => {
+ if (!(key in res)) {
+ // case: secret has been created
+ newSecrets.push({
+ key: key,
+ value: secrets[key],
+ type: 'encrypted',
+ target: [integration.target]
+ });
+ }
+ });
+
+ // Identify secrets to update and delete
+ Object.keys(res).map((key) => {
+ if (key in secrets) {
+ if (res[key].value !== secrets[key]) {
+ // case: secret value has changed
+ updateSecrets.push({
+ id: res[key].id,
+ key: key,
+ value: secrets[key],
+ type: 'encrypted',
+ target: [integration.target]
+ });
+ }
+ } else {
+ // case: secret has been deleted
+ deleteSecrets.push({
+ id: res[key].id,
+ key: key,
+ value: res[key].value,
+ type: 'encrypted',
+ target: [integration.target],
+ });
+ }
+ });
+
+ // Sync/push new secrets
+ if (newSecrets.length > 0) {
+ await axios.post(
+ `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`,
+ newSecrets,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ }
+
+ // Sync/push updated secrets
+ if (updateSecrets.length > 0) {
+ updateSecrets.forEach(async (secret: VercelSecret) => {
+ const {
+ id,
+ ...updatedSecret
+ } = secret;
+ await axios.patch(
+ `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
+ updatedSecret,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ });
+ }
+
+ // Delete secrets
+ if (deleteSecrets.length > 0) {
+ deleteSecrets.forEach(async (secret: VercelSecret) => {
+ await axios.delete(
+ `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ });
+ }
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to sync secrets to Vercel');
+ }
+}
+
+/**
+ * Sync/push [secrets] to Netlify site [app]
+ * @param {Object} obj
+ * @param {IIntegration} obj.integration - integration details
+ * @param {IIntegrationAuth} obj.integrationAuth - integration auth details
+ * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
+ */
+const syncSecretsNetlify = async ({
+ integration,
+ integrationAuth,
+ secrets,
+ accessToken
+}: {
+ integration: IIntegration;
+ integrationAuth: IIntegrationAuth;
+ secrets: any;
+ accessToken: string;
+}) => {
+ // TODO: Netlify revision in progress
+ // try {
+ // const getParams = new URLSearchParams({
+ // context_name: integration.context,
+ // site_id: integration.siteId
+ // });
+
+ // const res = (await axios.get(
+ // `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
+ // {
+ // params: getParams,
+ // headers: {
+ // Authorization: `Bearer ${accessToken}`
+ // }
+ // }
+ // ))
+ // .data
+ // .reduce((obj: any, secret: any) => ({
+ // ...obj,
+ // [secret.key]: secret
+ // }), {});
+
+ // res.forEach((r: any) => console.log(r));
+ // console.log('getParams', getParams);
+
+ // interface UpdateNetlifySecret {
+ // key: string;
+ // context: string;
+ // value: string;
+ // }
+
+ // interface DeleteNetlifySecret {
+ // key: string;
+ // }
+
+ // interface NewNetlifySecretValue {
+ // value: string;
+ // context: string;
+ // }
+
+ // interface NewNetlifySecret {
+ // key: string;
+ // values: NewNetlifySecretValue[];
+ // }
+
+ // let updateSecrets: UpdateNetlifySecret[] = [];
+ // let deleteSecrets: DeleteNetlifySecret[] = [];
+ // let newSecrets: NewNetlifySecret[] = [];
+
+ // interface NetlifyValue {
+ // id: string;
+ // value: string;
+ // context: string;
+ // role: string;
+ // }
+
+ // // Identify secrets to create - GOOD
+ // Object.keys(secrets).map((key) => {
+ // if (!(key in res)) {
+ // // case: secret does not exist in Netlify -> create secret
+ // newSecrets.push({
+ // key: key,
+ // values: [{
+ // value: secrets[key],
+ // context: integration.context
+ // }]
+ // });
+ // } else {
+ // // case: secret exists in Netlify
+
+ // const netlifyContextsSet = new Set (res[key].values.map((netlifyValue: NetlifyValue) => netlifyValue.context));
+
+ // // TODO: check context/env.
+ // res[key].values.forEach((netlifyValue: NetlifyValue) => {
+ // if (netlifyValue.context === integration.context) {
+ // // case: Netlify value context matches integration context
+ // // TODO: check if value has changed
+ // }
+ // });
+ // }
+ // });
+
+ // // Identify secrets to update and delete
+ // Object.keys(res).map((key) => {
+ // if (key in secrets) {
+ // // if (res[key] !== secrets[key]) {
+ // // // case: secret value has changed
+ // // updateSecrets.push({
+ // // key: key,
+ // // context: integration.context,
+ // // value: secrets[key]
+ // // });
+ // // }
+
+ // // TODO: modify check and record of updated secrets
+
+ // // case 1: new context added.
+ // } else {
+ // // case: secret has been deleted
+ // deleteSecrets.push({
+ // key
+ // });
+ // }
+ // });
+
+ // const syncParams = new URLSearchParams({
+ // site_id: integration.siteId
+ // });
+
+ // console.log('Netlify newSecrets', newSecrets);
+ // newSecrets.forEach(secret => console.log(secret.values));
+ // // Sync/push new secrets
+ // if (newSecrets.length > 0) {
+ // await axios.post(
+ // `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
+ // newSecrets,
+ // {
+ // params: syncParams,
+ // headers: {
+ // Authorization: `Bearer ${accessToken}`
+ // }
+ // }
+ // );
+ // }
+
+ // console.log('Netlify updateSecrets', updateSecrets);
+ // // Sync/push updated secrets
+ // if (updateSecrets.length > 0) {
+
+ // updateSecrets.forEach(async (secret: UpdateNetlifySecret) => {
+ // await axios.patch(
+ // `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
+ // {
+ // context: secret.context,
+ // value: secret.value
+ // },
+ // {
+ // params: syncParams,
+ // headers: {
+ // Authorization: `Bearer ${accessToken}`
+ // }
+ // }
+ // );
+ // });
+ // }
+
+ // console.log('Netlify deleteSecrets', deleteSecrets);
+ // // Delete secrets
+ // if (deleteSecrets.length > 0) {
+ // deleteSecrets.forEach(async (secret: DeleteNetlifySecret) => {
+ // await axios.delete(
+ // `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
+ // {
+ // params: syncParams,
+ // headers: {
+ // Authorization: `Bearer ${accessToken}`
+ // }
+ // }
+ // );
+ // });
+ // }
+ // } catch (err) {
+ // Sentry.setUser(null);
+ // Sentry.captureException(err);
+ // throw new Error('Failed to sync secrets to Heroku');
+ // }
+
+ try {
+ const getParams = new URLSearchParams({
+ context_name: integration.context,
+ site_id: integration.siteId
+ });
+
+ const res = (await axios.get(
+ `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
+ {
+ params: getParams,
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ ))
+ .data
+ .reduce((obj: any, secret: any) => ({
+ ...obj,
+ [secret.key]: secret.values[0].value
+ }), {});
+
+ interface UpdateNetlifySecret {
+ key: string;
+ context: string;
+ value: string;
+ }
+
+ interface DeleteNetlifySecret {
+ key: string;
+ }
+
+ interface NewNetlifySecretValue {
+ value: string;
+ context: string;
+ }
+
+ interface NewNetlifySecret {
+ key: string;
+ values: NewNetlifySecretValue[];
+ }
+
+ const updateSecrets: UpdateNetlifySecret[] = [];
+ const deleteSecrets: DeleteNetlifySecret[] = [];
+ const newSecrets: NewNetlifySecret[] = [];
+
+ // Identify secrets to create
+ Object.keys(secrets).map((key) => {
+ if (!(key in res)) {
+ // case: secret has been created
+ newSecrets.push({
+ key: key,
+ values: [{
+ value: secrets[key], // include id?
+ context: integration.context
+ }]
+ });
+ }
+ });
+
+ // Identify secrets to update and delete
+ Object.keys(res).map((key) => {
+ if (key in secrets) {
+ if (res[key] !== secrets[key]) {
+ // case: secret value has changed
+ updateSecrets.push({
+ key: key,
+ context: integration.context,
+ value: secrets[key]
+ });
+ }
+ } else {
+ // case: secret has been deleted
+ deleteSecrets.push({
+ key
+ });
+ }
+ });
+
+ const syncParams = new URLSearchParams({
+ site_id: integration.siteId
+ });
+
+ // Sync/push new secrets
+ if (newSecrets.length > 0) {
+ await axios.post(
+ `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
+ newSecrets,
+ {
+ params: syncParams,
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ }
+
+ // Sync/push updated secrets
+ if (updateSecrets.length > 0) {
+
+ updateSecrets.forEach(async (secret: UpdateNetlifySecret) => {
+ await axios.patch(
+ `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
+ {
+ context: secret.context,
+ value: secret.value
+ },
+ {
+ params: syncParams,
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ });
+ }
+
+ // Delete secrets
+ if (deleteSecrets.length > 0) {
+ deleteSecrets.forEach(async (secret: DeleteNetlifySecret) => {
+ await axios.delete(
+ `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
+ {
+ params: syncParams,
+ headers: {
+ Authorization: `Bearer ${accessToken}`
+ }
+ }
+ );
+ });
+ }
+
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ throw new Error('Failed to sync secrets to Heroku');
+ }
+}
+
+export {
+ syncSecrets
+}
\ No newline at end of file
diff --git a/backend/src/json/integrations.json b/backend/src/json/integrations.json
deleted file mode 100644
index 16b09ebf4..000000000
--- a/backend/src/json/integrations.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "heroku": {
- "name": "Heroku",
- "type": "oauth2",
- "clientId": "bc132901-935a-4590-b010-f1857efc380d",
- "docsLink": ""
- },
- "netlify": {
- "name": "Netlify",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- },
- "digitalocean": {
- "name": "Digital Ocean",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- },
- "gcp": {
- "name": "Google Cloud Platform",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- },
- "aws": {
- "name": "Amazon Web Services",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- },
- "azure": {
- "name": "Microsoft Azure",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- },
- "travisci": {
- "name": "Travis CI",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- },
- "circleci": {
- "name": "Circle CI",
- "type": "oauth2",
- "clientId": "",
- "docsLink": ""
- }
-}
diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts
index e445b64cf..7fcba66e1 100644
--- a/backend/src/middleware/index.ts
+++ b/backend/src/middleware/index.ts
@@ -1,4 +1,5 @@
import requireAuth from './requireAuth';
+import requireBotAuth from './requireBotAuth';
import requireSignupAuth from './requireSignupAuth';
import requireWorkspaceAuth from './requireWorkspaceAuth';
import requireOrganizationAuth from './requireOrganizationAuth';
@@ -9,6 +10,7 @@ import validateRequest from './validateRequest';
export {
requireAuth,
+ requireBotAuth,
requireSignupAuth,
requireWorkspaceAuth,
requireOrganizationAuth,
diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts
new file mode 100644
index 000000000..6c5a3820a
--- /dev/null
+++ b/backend/src/middleware/requireBotAuth.ts
@@ -0,0 +1,45 @@
+import * as Sentry from '@sentry/node';
+import { Request, Response, NextFunction } from 'express';
+import { Bot } from '../models';
+import { validateMembership } from '../helpers/membership';
+
+type req = 'params' | 'body' | 'query';
+
+const requireBotAuth = ({
+ acceptedRoles,
+ acceptedStatuses,
+ location = 'params'
+}: {
+ acceptedRoles: string[];
+ acceptedStatuses: string[];
+ location?: req;
+}) => {
+ return async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const bot = await Bot.findOne({ _id: req[location].botId });
+
+ if (!bot) {
+ throw new Error('Failed to find bot');
+ }
+
+ await validateMembership({
+ userId: req.user._id.toString(),
+ workspaceId: bot.workspace.toString(),
+ acceptedRoles,
+ acceptedStatuses
+ });
+
+ req.bot = bot;
+
+ next();
+ } catch (err) {
+ Sentry.setUser(null);
+ Sentry.captureException(err);
+ return res.status(401).send({
+ error: 'Failed bot authorization'
+ });
+ }
+ }
+}
+
+export default requireBotAuth;
\ No newline at end of file
diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts
index 70ca320c3..fe653dbc0 100644
--- a/backend/src/middleware/requireIntegrationAuth.ts
+++ b/backend/src/middleware/requireIntegrationAuth.ts
@@ -1,7 +1,8 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
-import { Integration, IntegrationAuth, Membership } from '../models';
-import { getOAuthAccessToken } from '../helpers/integrationAuth';
+import { Bot, Integration, IntegrationAuth, Membership } from '../models';
+import { IntegrationService } from '../services';
+import { validateMembership } from '../helpers/membership';
/**
* Validate if user on request is a member of workspace with proper roles associated
@@ -31,24 +32,14 @@ const requireIntegrationAuth = ({
if (!integration) {
throw new Error('Failed to find integration');
}
-
- const membership = await Membership.findOne({
- user: req.user._id,
- workspace: integration.workspace
+
+ await validateMembership({
+ userId: req.user._id.toString(),
+ workspaceId: integration.workspace.toString(),
+ acceptedRoles,
+ acceptedStatuses
});
- if (!membership) {
- throw new Error('Failed to find integration workspace membership');
- }
-
- if (!acceptedRoles.includes(membership.role)) {
- throw new Error('Failed to validate workspace membership role');
- }
-
- if (!acceptedStatuses.includes(membership.status)) {
- throw new Error('Failed to validate workspace membership status');
- }
-
const integrationAuth = await IntegrationAuth.findOne({
_id: integration.integrationAuth
}).select(
@@ -60,7 +51,9 @@ const requireIntegrationAuth = ({
}
req.integration = integration;
- req.accessToken = await getOAuthAccessToken({ integrationAuth });
+ req.accessToken = await IntegrationService.getIntegrationAuthAccess({
+ integrationAuthId: integrationAuth._id.toString()
+ });
return next();
} catch (err) {
diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts
index 1f5c6dfc8..ed44ffec5 100644
--- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts
+++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts
@@ -1,8 +1,8 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
-import { IntegrationAuth, Membership } from '../models';
-import { decryptSymmetric } from '../utils/crypto';
-import { getOAuthAccessToken } from '../helpers/integrationAuth';
+import { IntegrationAuth } from '../models';
+import { IntegrationService } from '../services';
+import { validateMembership } from '../helpers/membership';
/**
* Validate if user on request is a member of workspace with proper roles associated
@@ -10,18 +10,18 @@ import { getOAuthAccessToken } from '../helpers/integrationAuth';
* @param {Object} obj
* @param {String[]} obj.acceptedRoles - accepted workspace roles
* @param {String[]} obj.acceptedStatuses - accepted workspace statuses
- * @param {Boolean} obj.attachRefresh - whether or not to decrypt and attach integration authorization refresh token onto request
+ * @param {Boolean} obj.attachAccessToken - whether or not to decrypt and attach integration authorization access token onto request
*/
const requireIntegrationAuthorizationAuth = ({
acceptedRoles,
- acceptedStatuses
+ acceptedStatuses,
+ attachAccessToken = true
}: {
acceptedRoles: string[];
acceptedStatuses: string[];
+ attachAccessToken?: boolean;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
- // (authorization) integration authorization middleware
-
try {
const { integrationAuthId } = req.params;
@@ -34,30 +34,21 @@ const requireIntegrationAuthorizationAuth = ({
if (!integrationAuth) {
throw new Error('Failed to find integration authorization');
}
-
- const membership = await Membership.findOne({
- user: req.user._id,
- workspace: integrationAuth.workspace
+
+ await validateMembership({
+ userId: req.user._id.toString(),
+ workspaceId: integrationAuth.workspace.toString(),
+ acceptedRoles,
+ acceptedStatuses
});
- if (!membership) {
- throw new Error(
- 'Failed to find integration authorization workspace membership'
- );
- }
-
- if (!acceptedRoles.includes(membership.role)) {
- throw new Error('Failed to validate workspace membership role');
- }
-
- if (!acceptedStatuses.includes(membership.status)) {
- throw new Error('Failed to validate workspace membership status');
- }
-
req.integrationAuth = integrationAuth;
-
- // TODO: make compatible with other integration types since they won't necessarily have access tokens
- req.accessToken = await getOAuthAccessToken({ integrationAuth });
+ if (attachAccessToken) {
+ req.accessToken = await IntegrationService.getIntegrationAuthAccess({
+ integrationAuthId: integrationAuth._id.toString()
+ });
+ }
+
return next();
} catch (err) {
Sentry.setUser(null);
diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts
index 03fb7357f..a27611f2d 100644
--- a/backend/src/middleware/requireWorkspaceAuth.ts
+++ b/backend/src/middleware/requireWorkspaceAuth.ts
@@ -1,6 +1,6 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
-import { Membership, IWorkspace } from '../models';
+import { validateMembership } from '../helpers/membership';
type req = 'params' | 'body' | 'query';
@@ -25,24 +25,12 @@ const requireWorkspaceAuth = ({
// workspace authorization middleware
try {
- // validate workspace membership
-
- const membership = await Membership.findOne({
- user: req.user._id,
- workspace: req[location].workspaceId
- }).populate<{ workspace: IWorkspace }>('workspace');
-
- if (!membership) {
- throw new Error('Failed to find workspace membership');
- }
-
- if (!acceptedRoles.includes(membership.role)) {
- throw new Error('Failed to validate workspace membership role');
- }
-
- if (!acceptedStatuses.includes(membership.status)) {
- throw new Error('Failed to validate workspace membership status');
- }
+ const membership = await validateMembership({
+ userId: req.user._id.toString(),
+ workspaceId: req[location].workspaceId,
+ acceptedRoles,
+ acceptedStatuses
+ });
req.membership = membership;
diff --git a/backend/src/models/bot.ts b/backend/src/models/bot.ts
new file mode 100644
index 000000000..c7e5a9abe
--- /dev/null
+++ b/backend/src/models/bot.ts
@@ -0,0 +1,57 @@
+import { Schema, model, Types } from 'mongoose';
+
+export interface IBot {
+ _id: Types.ObjectId;
+ name: string;
+ workspace: Types.ObjectId;
+ isActive: boolean;
+ publicKey: string;
+ encryptedPrivateKey: string;
+ iv: string;
+ tag: string;
+}
+
+const botSchema = new Schema(
+ {
+ name: {
+ type: String,
+ required: true
+ },
+ workspace: {
+ type: Schema.Types.ObjectId,
+ ref: 'Workspace',
+ required: true
+ },
+ isActive: {
+ type: Boolean,
+ required: true,
+ default: false
+ },
+ publicKey: {
+ type: String,
+ required: true
+ },
+ encryptedPrivateKey: {
+ type: String,
+ required: true,
+ select: false
+ },
+ iv: {
+ type: String,
+ required: true,
+ select: false
+ },
+ tag: {
+ type: String,
+ required: true,
+ select: false
+ }
+ },
+ {
+ timestamps: true
+ }
+);
+
+const Bot = model('Bot', botSchema);
+
+export default Bot;
diff --git a/backend/src/models/botKey.ts b/backend/src/models/botKey.ts
new file mode 100644
index 000000000..79555cd53
--- /dev/null
+++ b/backend/src/models/botKey.ts
@@ -0,0 +1,45 @@
+import { Schema, model, Types } from 'mongoose';
+
+export interface IBotKey {
+ _id: Types.ObjectId;
+ encryptedKey: string;
+ nonce: string;
+ sender: Types.ObjectId;
+ bot: Types.ObjectId;
+ workspace: Types.ObjectId;
+}
+
+const botKeySchema = new Schema(
+ {
+ encryptedKey: {
+ type: String,
+ required: true
+ },
+ nonce: {
+ type: String,
+ required: true
+ },
+ sender: {
+ type: Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ bot: {
+ type: Schema.Types.ObjectId,
+ ref: 'Bot',
+ required: true
+ },
+ workspace: {
+ type: Schema.Types.ObjectId,
+ ref: 'Workspace',
+ required: true
+ }
+ },
+ {
+ timestamps: true
+ }
+);
+
+const BotKey = model('BotKey', botKeySchema);
+
+export default BotKey;
diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts
index 9b07f6766..78c38060b 100644
--- a/backend/src/models/index.ts
+++ b/backend/src/models/index.ts
@@ -1,4 +1,6 @@
import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey';
+import Bot, { IBot } from './bot';
+import BotKey, { IBotKey } from './botKey';
import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg';
import Integration, { IIntegration } from './integration';
import IntegrationAuth, { IIntegrationAuth } from './integrationAuth';
@@ -16,6 +18,10 @@ import Workspace, { IWorkspace } from './workspace';
export {
BackupPrivateKey,
IBackupPrivateKey,
+ Bot,
+ IBot,
+ BotKey,
+ IBotKey,
IncidentContactOrg,
IIncidentContactOrg,
Integration,
diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts
index 5e72e8b54..edbe0234e 100644
--- a/backend/src/models/integration.ts
+++ b/backend/src/models/integration.ts
@@ -5,6 +5,7 @@ import {
ENV_STAGING,
ENV_PROD,
INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
INTEGRATION_NETLIFY
} from '../variables';
@@ -14,7 +15,10 @@ export interface IIntegration {
environment: 'dev' | 'test' | 'staging' | 'prod';
isActive: boolean;
app: string;
- integration: 'heroku' | 'netlify';
+ target: string;
+ context: string;
+ siteId: string;
+ integration: 'heroku' | 'vercel' | 'netlify';
integrationAuth: Types.ObjectId;
}
@@ -34,15 +38,29 @@ const integrationSchema = new Schema(
type: Boolean,
required: true
},
- app: {
- // name of app in provider
+ app: { // name of app in provider
type: String,
- default: null,
- required: true
+ default: null
+ },
+ target: { // vercel-specific target (environment)
+ type: String,
+ default: null
+ },
+ context: { // netlify-specific context (deploy)
+ type: String,
+ default: null
+ },
+ siteId: { // netlify-specific site (app) id
+ type: String,
+ default: null
},
integration: {
type: String,
- enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY],
+ enum: [
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY
+ ],
required: true
},
integrationAuth: {
diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts
index 0e9542a20..0da3eb0d8 100644
--- a/backend/src/models/integrationAuth.ts
+++ b/backend/src/models/integrationAuth.ts
@@ -1,10 +1,16 @@
import { Schema, model, Types } from 'mongoose';
-import { INTEGRATION_HEROKU, INTEGRATION_NETLIFY } from '../variables';
+import {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY
+} from '../variables';
export interface IIntegrationAuth {
_id: Types.ObjectId;
workspace: Types.ObjectId;
- integration: 'heroku' | 'netlify';
+ integration: 'heroku' | 'vercel' | 'netlify';
+ teamId: string;
+ accountId: string;
refreshCiphertext?: string;
refreshIV?: string;
refreshTag?: string;
@@ -22,9 +28,19 @@ const integrationAuthSchema = new Schema(
},
integration: {
type: String,
- enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY],
+ enum: [
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY
+ ],
required: true
},
+ teamId: { // vercel-specific integration param
+ type: String
+ },
+ accountId: { // netlify-specific integration param
+ type: String
+ },
refreshCiphertext: {
type: String,
select: false
diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts
new file mode 100644
index 000000000..3189bec44
--- /dev/null
+++ b/backend/src/routes/bot.ts
@@ -0,0 +1,38 @@
+import express from 'express';
+const router = express.Router();
+import { body, param } from 'express-validator';
+import {
+ requireAuth,
+ requireBotAuth,
+ requireWorkspaceAuth,
+ validateRequest
+} from '../middleware';
+import { botController } from '../controllers';
+import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables';
+
+router.get(
+ '/:workspaceId',
+ requireAuth,
+ requireWorkspaceAuth({
+ acceptedRoles: [ADMIN, MEMBER],
+ acceptedStatuses: [COMPLETED, GRANTED]
+ }),
+ param('workspaceId').exists().trim().notEmpty(),
+ validateRequest,
+ botController.getBotByWorkspaceId
+);
+
+router.patch(
+ '/:botId/active',
+ requireAuth,
+ requireBotAuth({
+ acceptedRoles: [ADMIN, MEMBER],
+ acceptedStatuses: [COMPLETED, GRANTED]
+ }),
+ body('isActive').isBoolean(),
+ body('botKey'),
+ validateRequest,
+ botController.setBotActiveState
+);
+
+export default router;
\ No newline at end of file
diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts
index cf015abfb..2dfe58baa 100644
--- a/backend/src/routes/index.ts
+++ b/backend/src/routes/index.ts
@@ -1,4 +1,5 @@
import signup from './signup';
+import bot from './bot';
import auth from './auth';
import user from './user';
import userAction from './userAction';
@@ -18,6 +19,7 @@ import integrationAuth from './integrationAuth';
export {
signup,
auth,
+ bot,
user,
userAction,
organization,
diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts
index d16154172..e6738a803 100644
--- a/backend/src/routes/integration.ts
+++ b/backend/src/routes/integration.ts
@@ -9,22 +9,6 @@ import { ADMIN, MEMBER, GRANTED } from '../variables';
import { body, param } from 'express-validator';
import { integrationController } from '../controllers';
-router.get('/integrations', requireAuth, integrationController.getIntegrations);
-
-router.post(
- '/:integrationId/sync',
- requireAuth,
- requireIntegrationAuth({
- acceptedRoles: [ADMIN, MEMBER],
- acceptedStatuses: [GRANTED]
- }),
- param('integrationId').exists().trim(),
- body('key').exists(),
- body('secrets').exists(),
- validateRequest,
- integrationController.syncIntegration
-);
-
router.patch(
'/:integrationId',
requireAuth,
@@ -32,10 +16,15 @@ router.patch(
acceptedRoles: [ADMIN, MEMBER],
acceptedStatuses: [GRANTED]
}),
- param('integrationId'),
- body('update'),
+ param('integrationId').exists().trim(),
+ body('app').exists().trim(),
+ body('environment').exists().trim(),
+ body('isActive').exists().isBoolean(),
+ body('target').exists(),
+ body('context').exists(),
+ body('siteId').exists(),
validateRequest,
- integrationController.modifyIntegration
+ integrationController.updateIntegration
);
router.delete(
@@ -45,7 +34,7 @@ router.delete(
acceptedRoles: [ADMIN, MEMBER],
acceptedStatuses: [GRANTED]
}),
- param('integrationId'),
+ param('integrationId').exists().trim(),
validateRequest,
integrationController.deleteIntegration
);
diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts
index 61e5f56bf..ef80a2dcc 100644
--- a/backend/src/routes/integrationAuth.ts
+++ b/backend/src/routes/integrationAuth.ts
@@ -10,6 +10,12 @@ import {
import { ADMIN, MEMBER, GRANTED } from '../variables';
import { integrationAuthController } from '../controllers';
+router.get(
+ '/integration-options',
+ requireAuth,
+ integrationAuthController.getIntegrationOptions
+);
+
router.post(
'/oauth-token',
requireAuth,
@@ -22,7 +28,7 @@ router.post(
body('code').exists().trim().notEmpty(),
body('integration').exists().trim().notEmpty(),
validateRequest,
- integrationAuthController.integrationAuthOauthExchange
+ integrationAuthController.oAuthExchange
);
router.get(
@@ -42,7 +48,8 @@ router.delete(
requireAuth,
requireIntegrationAuthorizationAuth({
acceptedRoles: [ADMIN, MEMBER],
- acceptedStatuses: [GRANTED]
+ acceptedStatuses: [GRANTED],
+ attachAccessToken: false
}),
param('integrationAuthId'),
validateRequest,
diff --git a/backend/src/routes/password.ts b/backend/src/routes/password.ts
index 955e532a0..8032cba83 100644
--- a/backend/src/routes/password.ts
+++ b/backend/src/routes/password.ts
@@ -27,7 +27,6 @@ router.post(
passwordController.changePassword
);
-// NEW
router.post(
'/email/password-reset',
passwordLimiter,
@@ -36,7 +35,6 @@ router.post(
passwordController.emailPasswordReset
);
-// NEW
router.post(
'/email/password-reset-verify',
passwordLimiter,
@@ -46,7 +44,6 @@ router.post(
passwordController.emailPasswordResetVerify
);
-// NEW
router.get(
'/backup-private-key',
passwordLimiter,
@@ -68,7 +65,6 @@ router.post(
passwordController.createBackupPrivateKey
);
-// NEW
router.post(
'/password-reset',
requireSignupAuth,
diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts
new file mode 100644
index 000000000..792bd8e35
--- /dev/null
+++ b/backend/src/services/BotService.ts
@@ -0,0 +1,82 @@
+import {
+ getSecretsHelper,
+ encryptSymmetricHelper,
+ decryptSymmetricHelper
+} from '../helpers/bot';
+
+/**
+ * Class to handle bot actions
+ */
+class BotService {
+
+ /**
+ * Return decrypted secrets for workspace with id [workspaceId] and
+ * environment [environmen] shared to bot.
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace of secrets
+ * @param {String} obj.environment - environment for secrets
+ * @returns {Object} secretObj - object where keys are secret keys and values are secret values
+ */
+ static async getSecrets({
+ workspaceId,
+ environment
+ }: {
+ workspaceId: string;
+ environment: string;
+ }) {
+ return await getSecretsHelper({
+ workspaceId,
+ environment
+ });
+ }
+
+ /**
+ * Return symmetrically encrypted [plaintext] using the
+ * bot's copy of the workspace key for workspace with id [workspaceId]
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace
+ * @param {String} obj.plaintext - plaintext to encrypt
+ */
+ static async encryptSymmetric({
+ workspaceId,
+ plaintext
+ }: {
+ workspaceId: string;
+ plaintext: string;
+ }) {
+ return await encryptSymmetricHelper({
+ workspaceId,
+ plaintext
+ });
+ }
+
+ /**
+ * Return symmetrically decrypted [ciphertext] using the
+ * bot's copy of the workspace key for workspace with id [workspaceId]
+ * @param {Object} obj
+ * @param {String} obj.workspaceId - id of workspace
+ * @param {String} obj.ciphertext - ciphertext to decrypt
+ * @param {String} obj.iv - iv
+ * @param {String} obj.tag - tag
+ */
+ static async decryptSymmetric({
+ workspaceId,
+ ciphertext,
+ iv,
+ tag
+ }: {
+ workspaceId: string;
+ ciphertext: string;
+ iv: string;
+ tag: string;
+ }) {
+ return await decryptSymmetricHelper({
+ workspaceId,
+ ciphertext,
+ iv,
+ tag
+ });
+ }
+}
+
+export default BotService;
\ No newline at end of file
diff --git a/backend/src/services/EventService.ts b/backend/src/services/EventService.ts
new file mode 100644
index 000000000..fcbac9ad0
--- /dev/null
+++ b/backend/src/services/EventService.ts
@@ -0,0 +1,30 @@
+import { Bot, IBot } from '../models';
+import * as Sentry from '@sentry/node';
+import { handleEventHelper } from '../helpers/event';
+
+interface Event {
+ name: string;
+ workspaceId: string;
+ payload: any;
+}
+
+/**
+ * Class to handle events.
+ */
+class EventService {
+ /**
+ * Handle event [event]
+ * @param {Object} obj
+ * @param {Event} obj.event - an event
+ * @param {String} obj.event.name - name of event
+ * @param {String} obj.event.workspaceId - id of workspace that event is part of
+ * @param {Object} obj.event.payload - payload of event (depends on event)
+ */
+ static async handleEvent({ event }: { event: Event }): Promise {
+ await handleEventHelper({
+ event
+ });
+ }
+}
+
+export default EventService;
\ No newline at end of file
diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts
new file mode 100644
index 000000000..32f5f5a88
--- /dev/null
+++ b/backend/src/services/IntegrationService.ts
@@ -0,0 +1,145 @@
+import * as Sentry from '@sentry/node';
+import {
+ Integration
+} from '../models';
+import {
+ handleOAuthExchangeHelper,
+ syncIntegrationsHelper,
+ getIntegrationAuthRefreshHelper,
+ getIntegrationAuthAccessHelper,
+ setIntegrationAuthRefreshHelper,
+ setIntegrationAuthAccessHelper,
+} from '../helpers/integration';
+import { exchangeCode } from '../integrations';
+import {
+ ENV_DEV,
+ EVENT_PUSH_SECRETS
+} from '../variables';
+
+// should sync stuff be here too? Probably.
+// TODO: move bot functions to IntegrationService.
+
+/**
+ * Class to handle integrations
+ */
+class IntegrationService {
+
+ /**
+ * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration
+ * named [integration]
+ * - 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.integration - name of integration
+ * @param {String} obj.code - code
+ */
+ static async handleOAuthExchange({
+ workspaceId,
+ integration,
+ code
+ }: {
+ workspaceId: string;
+ integration: string;
+ code: string;
+ }) {
+ await handleOAuthExchangeHelper({
+ workspaceId,
+ integration,
+ code
+ });
+ }
+
+ /**
+ * Sync/push environment variables in workspace with id [workspaceId] to
+ * all associated integrations
+ * @param {Object} obj
+ * @param {Object} obj.workspaceId - id of workspace
+ */
+ static async syncIntegrations({
+ workspaceId
+ }: {
+ workspaceId: string;
+ }) {
+ return await syncIntegrationsHelper({
+ workspaceId
+ });
+ }
+
+ /**
+ * Return decrypted refresh token for integration auth
+ * with id [integrationAuthId]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} refreshToken - decrypted refresh token
+ */
+ static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) {
+ return await getIntegrationAuthRefreshHelper({
+ integrationAuthId
+ });
+ }
+
+ /**
+ * Return decrypted access token for integration auth
+ * with id [integrationAuthId]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} accessToken - decrypted access token
+ */
+ static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) {
+ return await getIntegrationAuthAccessHelper({
+ integrationAuthId
+ });
+ }
+
+ /**
+ * Encrypt refresh token [refreshToken] using the bot's copy
+ * of the workspace key for workspace belonging to integration auth
+ * with id [integrationAuthId]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} obj.refreshToken - refresh token
+ * @returns {IntegrationAuth} integrationAuth - updated integration auth
+ */
+ static async setIntegrationAuthRefresh({
+ integrationAuthId,
+ refreshToken
+ }: {
+ integrationAuthId: string;
+ refreshToken: string;
+ }) {
+ return await setIntegrationAuthRefreshHelper({
+ integrationAuthId,
+ refreshToken
+ });
+ }
+
+ /**
+ * Encrypt access token [accessToken] using the bot's copy
+ * of the workspace key for workspace belonging to integration auth
+ * with id [integrationAuthId]
+ * @param {Object} obj
+ * @param {String} obj.integrationAuthId - id of integration auth
+ * @param {String} obj.accessToken - access token
+ * @param {String} obj.accessExpiresAt - expiration date of access token
+ * @returns {IntegrationAuth} - updated integration auth
+ */
+ static async setIntegrationAuthAccess({
+ integrationAuthId,
+ accessToken,
+ accessExpiresAt
+ }: {
+ integrationAuthId: string;
+ accessToken: string;
+ accessExpiresAt: Date;
+ }) {
+ return await setIntegrationAuthAccessHelper({
+ integrationAuthId,
+ accessToken,
+ accessExpiresAt
+ });
+ }
+}
+
+export default IntegrationService;
\ No newline at end of file
diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts
index 54cdf94f4..531033f30 100644
--- a/backend/src/services/index.ts
+++ b/backend/src/services/index.ts
@@ -1,5 +1,11 @@
import postHogClient from './PostHogClient';
+import BotService from './BotService';
+import EventService from './EventService';
+import IntegrationService from './IntegrationService';
export {
- postHogClient
+ postHogClient,
+ BotService,
+ EventService,
+ IntegrationService
}
\ No newline at end of file
diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts
index 8fdb2b3fe..9a9e81449 100644
--- a/backend/src/types/express/index.d.ts
+++ b/backend/src/types/express/index.d.ts
@@ -11,6 +11,7 @@ declare global {
membershipOrg: any;
integration: any;
integrationAuth: any;
+ bot: any;
serviceToken: any;
accessToken: any;
query?: any;
diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts
index 742e65e80..585dae51d 100644
--- a/backend/src/utils/crypto.ts
+++ b/backend/src/utils/crypto.ts
@@ -2,6 +2,21 @@ import nacl from 'tweetnacl';
import util from 'tweetnacl-util';
import AesGCM from './aes-gcm';
+/**
+ * Return new base64, NaCl, public-private key pair.
+ * @returns {Object} obj
+ * @returns {String} obj.publicKey - base64, NaCl, public key
+ * @returns {String} obj.privateKey - base64, NaCl, private key
+ */
+const generateKeyPair = () => {
+ const pair = nacl.box.keyPair();
+
+ return ({
+ publicKey: util.encodeBase64(pair.publicKey),
+ privateKey: util.encodeBase64(pair.secretKey)
+ });
+}
+
/**
* Return assymmetrically encrypted [plaintext] using [publicKey] where
* [publicKey] likely belongs to the recipient.
@@ -81,7 +96,7 @@ const decryptAsymmetric = ({
* Return symmetrically encrypted [plaintext] using [key].
* @param {Object} obj
* @param {String} obj.plaintext - plaintext to encrypt
- * @param {String} obj.key - 16-byte hex key
+ * @param {String} obj.key - hex key
*/
const encryptSymmetric = ({
plaintext,
@@ -114,7 +129,7 @@ const encryptSymmetric = ({
* @param {String} obj.ciphertext - ciphertext to decrypt
* @param {String} obj.iv - iv
* @param {String} obj.tag - tag
- * @param {String} obj.key - 32-byte hex key
+ * @param {String} obj.key - hex key
*
*/
const decryptSymmetric = ({
@@ -139,6 +154,7 @@ const decryptSymmetric = ({
};
export {
+ generateKeyPair,
encryptAsymmetric,
decryptAsymmetric,
encryptSymmetric,
diff --git a/backend/src/variables.ts b/backend/src/variables.ts
deleted file mode 100644
index cdd771b71..000000000
--- a/backend/src/variables.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-// membership roles
-const OWNER = 'owner';
-const ADMIN = 'admin';
-const MEMBER = 'member';
-
-// membership statuses
-const INVITED = 'invited';
-
-// -- organization
-const ACCEPTED = 'accepted';
-
-// -- workspace
-const COMPLETED = 'completed';
-const GRANTED = 'granted';
-
-// subscriptions
-const PLAN_STARTER = 'starter';
-const PLAN_PRO = 'pro';
-
-// secrets
-const SECRET_SHARED = 'shared';
-const SECRET_PERSONAL = 'personal';
-
-// environments
-const ENV_DEV = 'dev';
-const ENV_TESTING = 'test';
-const ENV_STAGING = 'staging';
-const ENV_PROD = 'prod';
-const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]);
-
-// integrations
-const INTEGRATION_HEROKU = 'heroku';
-const INTEGRATION_NETLIFY = 'netlify';
-const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]);
-
-// integration types
-const INTEGRATION_OAUTH2 = 'oauth2';
-
-export {
- OWNER,
- ADMIN,
- MEMBER,
- INVITED,
- ACCEPTED,
- COMPLETED,
- GRANTED,
- PLAN_STARTER,
- PLAN_PRO,
- SECRET_SHARED,
- SECRET_PERSONAL,
- ENV_DEV,
- ENV_TESTING,
- ENV_STAGING,
- ENV_PROD,
- ENV_SET,
- INTEGRATION_HEROKU,
- INTEGRATION_NETLIFY,
- INTEGRATION_SET,
- INTEGRATION_OAUTH2
-};
diff --git a/backend/src/variables/action.ts b/backend/src/variables/action.ts
new file mode 100644
index 000000000..1f913bbe9
--- /dev/null
+++ b/backend/src/variables/action.ts
@@ -0,0 +1,5 @@
+const ACTION_PUSH_TO_HEROKU = 'pushToHeroku';
+
+export {
+ ACTION_PUSH_TO_HEROKU
+}
\ No newline at end of file
diff --git a/backend/src/variables/environment.ts b/backend/src/variables/environment.ts
new file mode 100644
index 000000000..44d7cdbb2
--- /dev/null
+++ b/backend/src/variables/environment.ts
@@ -0,0 +1,14 @@
+// environments
+const ENV_DEV = 'dev';
+const ENV_TESTING = 'test';
+const ENV_STAGING = 'staging';
+const ENV_PROD = 'prod';
+const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]);
+
+export {
+ ENV_DEV,
+ ENV_TESTING,
+ ENV_STAGING,
+ ENV_PROD,
+ ENV_SET
+}
\ No newline at end of file
diff --git a/backend/src/variables/event.ts b/backend/src/variables/event.ts
new file mode 100644
index 000000000..4477e8e02
--- /dev/null
+++ b/backend/src/variables/event.ts
@@ -0,0 +1,7 @@
+const EVENT_PUSH_SECRETS = 'pushSecrets';
+const EVENT_PULL_SECRETS = 'pullSecrets';
+
+export {
+ EVENT_PUSH_SECRETS,
+ EVENT_PULL_SECRETS
+}
\ No newline at end of file
diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts
new file mode 100644
index 000000000..b21324423
--- /dev/null
+++ b/backend/src/variables/index.ts
@@ -0,0 +1,79 @@
+import {
+ ENV_DEV,
+ ENV_TESTING,
+ ENV_STAGING,
+ ENV_PROD,
+ ENV_SET
+} from './environment';
+import {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY,
+ INTEGRATION_SET,
+ INTEGRATION_OAUTH2,
+ INTEGRATION_HEROKU_TOKEN_URL,
+ INTEGRATION_VERCEL_TOKEN_URL,
+ INTEGRATION_NETLIFY_TOKEN_URL,
+ INTEGRATION_HEROKU_API_URL,
+ INTEGRATION_VERCEL_API_URL,
+ INTEGRATION_NETLIFY_API_URL,
+ INTEGRATION_OPTIONS
+} from './integration';
+import {
+ OWNER,
+ ADMIN,
+ MEMBER,
+ INVITED,
+ ACCEPTED,
+ COMPLETED,
+ GRANTED
+} from './organization';
+import {
+ SECRET_SHARED,
+ SECRET_PERSONAL
+} from './secret';
+import {
+ PLAN_STARTER,
+ PLAN_PRO
+} from './stripe';
+import {
+ EVENT_PUSH_SECRETS,
+ EVENT_PULL_SECRETS
+} from './event';
+import {
+ ACTION_PUSH_TO_HEROKU
+} from './action';
+
+export {
+ OWNER,
+ ADMIN,
+ MEMBER,
+ INVITED,
+ ACCEPTED,
+ COMPLETED,
+ GRANTED,
+ PLAN_STARTER,
+ PLAN_PRO,
+ SECRET_SHARED,
+ SECRET_PERSONAL,
+ ENV_DEV,
+ ENV_TESTING,
+ ENV_STAGING,
+ ENV_PROD,
+ ENV_SET,
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY,
+ INTEGRATION_SET,
+ INTEGRATION_OAUTH2,
+ INTEGRATION_HEROKU_TOKEN_URL,
+ INTEGRATION_VERCEL_TOKEN_URL,
+ INTEGRATION_NETLIFY_TOKEN_URL,
+ INTEGRATION_HEROKU_API_URL,
+ INTEGRATION_VERCEL_API_URL,
+ INTEGRATION_NETLIFY_API_URL,
+ EVENT_PUSH_SECRETS,
+ EVENT_PULL_SECRETS,
+ ACTION_PUSH_TO_HEROKU,
+ INTEGRATION_OPTIONS
+};
\ No newline at end of file
diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts
new file mode 100644
index 000000000..35c632389
--- /dev/null
+++ b/backend/src/variables/integration.ts
@@ -0,0 +1,119 @@
+import {
+ CLIENT_ID_HEROKU,
+ CLIENT_ID_NETLIFY,
+ CLIENT_SLUG_VERCEL
+} from '../config';
+
+// integrations
+const INTEGRATION_HEROKU = 'heroku';
+const INTEGRATION_VERCEL = 'vercel';
+const INTEGRATION_NETLIFY = 'netlify';
+const INTEGRATION_SET = new Set([
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY
+]);
+
+// integration types
+const INTEGRATION_OAUTH2 = 'oauth2';
+
+// integration oauth endpoints
+const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token';
+const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token';
+const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token';
+
+// integration apps endpoints
+const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com';
+const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com';
+const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com';
+
+const INTEGRATION_OPTIONS = [
+ {
+ name: 'Heroku',
+ slug: 'heroku',
+ image: 'Heroku',
+ isAvailable: true,
+ type: 'oauth2',
+ clientId: CLIENT_ID_HEROKU,
+ docsLink: ''
+ },
+ {
+ name: 'Vercel',
+ slug: 'vercel',
+ image: 'Vercel',
+ isAvailable: true,
+ type: 'vercel',
+ clientId: '',
+ clientSlug: CLIENT_SLUG_VERCEL,
+ docsLink: ''
+ },
+ {
+ name: 'Netlify',
+ slug: 'netlify',
+ image: 'Netlify',
+ isAvailable: false,
+ type: 'oauth2',
+ clientId: CLIENT_ID_NETLIFY,
+ docsLink: ''
+ },
+ {
+ name: 'Google Cloud Platform',
+ slug: 'gcp',
+ image: 'Google Cloud Platform',
+ isAvailable: false,
+ type: '',
+ clientId: '',
+ docsLink: ''
+ },
+ {
+ name: 'Amazon Web Services',
+ slug: 'aws',
+ image: 'Amazon Web Services',
+ isAvailable: false,
+ type: '',
+ clientId: '',
+ docsLink: ''
+ },
+ {
+ name: 'Microsoft Azure',
+ slug: 'azure',
+ image: 'Microsoft Azure',
+ isAvailable: false,
+ type: '',
+ clientId: '',
+ docsLink: ''
+ },
+ {
+ name: 'Travis CI',
+ slug: 'travisci',
+ image: 'Travis CI',
+ isAvailable: false,
+ type: '',
+ clientId: '',
+ docsLink: ''
+ },
+ {
+ name: 'Circle CI',
+ slug: 'circleci',
+ image: 'Circle CI',
+ isAvailable: false,
+ type: '',
+ clientId: '',
+ docsLink: ''
+ }
+]
+
+export {
+ INTEGRATION_HEROKU,
+ INTEGRATION_VERCEL,
+ INTEGRATION_NETLIFY,
+ INTEGRATION_SET,
+ INTEGRATION_OAUTH2,
+ INTEGRATION_HEROKU_TOKEN_URL,
+ INTEGRATION_VERCEL_TOKEN_URL,
+ INTEGRATION_NETLIFY_TOKEN_URL,
+ INTEGRATION_HEROKU_API_URL,
+ INTEGRATION_VERCEL_API_URL,
+ INTEGRATION_NETLIFY_API_URL,
+ INTEGRATION_OPTIONS
+}
\ No newline at end of file
diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts
new file mode 100644
index 000000000..f91e1f5d3
--- /dev/null
+++ b/backend/src/variables/organization.ts
@@ -0,0 +1,24 @@
+// membership roles
+const OWNER = 'owner';
+const ADMIN = 'admin';
+const MEMBER = 'member';
+
+// membership statuses
+const INVITED = 'invited';
+
+// -- organization
+const ACCEPTED = 'accepted';
+
+// -- workspace
+const COMPLETED = 'completed';
+const GRANTED = 'granted';
+
+export {
+ OWNER,
+ ADMIN,
+ MEMBER,
+ INVITED,
+ ACCEPTED,
+ COMPLETED,
+ GRANTED
+}
\ No newline at end of file
diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts
new file mode 100644
index 000000000..31cbcf951
--- /dev/null
+++ b/backend/src/variables/secret.ts
@@ -0,0 +1,8 @@
+// secrets
+const SECRET_SHARED = 'shared';
+const SECRET_PERSONAL = 'personal';
+
+export {
+ SECRET_SHARED,
+ SECRET_PERSONAL
+}
\ No newline at end of file
diff --git a/backend/src/variables/stripe.ts b/backend/src/variables/stripe.ts
new file mode 100644
index 000000000..ecdbd98ae
--- /dev/null
+++ b/backend/src/variables/stripe.ts
@@ -0,0 +1,7 @@
+const PLAN_STARTER = 'starter';
+const PLAN_PRO = 'pro';
+
+export {
+ PLAN_STARTER,
+ PLAN_PRO
+}
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 623462d5b..15a200783 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -51,6 +51,7 @@ services:
env_file: .env
environment:
- NEXT_PUBLIC_ENV=development
+ - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED}
- NEXT_PUBLIC_STRIPE_PRODUCT_PRO=${STRIPE_PRODUCT_PRO}
- NEXT_PUBLIC_STRIPE_PRODUCT_STARTER=${STRIPE_PRODUCT_STARTER}
networks:
diff --git a/docker-compose.yml b/docker-compose.yml
index bd9022cef..206e7afa3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,7 +9,7 @@ services:
- 80:80
- 443:443
volumes:
- - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- frontend
- backend
diff --git a/docs/mint.json b/docs/mint.json
index 8a6b95487..19a499fe8 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -178,5 +178,8 @@
]
}
],
- "backgroundImage": "/images/background.png"
+ "backgroundImage": "/images/background.png",
+ "integrations": {
+ "intercom": "hsg644ru"
+ }
}
diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx
index a55efbebc..9c6697df5 100644
--- a/docs/self-hosting/configuration/envars.mdx
+++ b/docs/self-hosting/configuration/envars.mdx
@@ -28,6 +28,9 @@ Configuring Infisical requires setting some environment variables. There is a fi
| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` |
| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` |
| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` |
-| `OAUTH_CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` |
-| `OAUTH_TOKEN_URL_HEROKU` | OAuth token URL for Heroku integration | `None` |
+| `CLIENT_ID_VERCEL` | OAuth client id for Vercel integration | `None` |
+| `CLIENT_ID_NETLIFY` | OAuth client id for Netlify integration | `None` |
+| `CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` |
+| `CLIENT_SECRET_VERCEL` | OAuth client secret for Vercel integration | `None` |
+| `CLIENT_SECRET_NETLIFY` | OAuth client secret for Netlify integration | `None` |
| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` |
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 5deb468f7..520f0fb7f 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -10,7 +10,7 @@ WORKDIR /app
COPY package.json package-lock.json next.config.js ./
# Install dependencies
-RUN npm ci --only-production
+RUN npm ci --only-production --ignore-scripts
# Rebuild the source code only when needed
diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev
index 2bae23823..cb462bbc4 100644
--- a/frontend/Dockerfile.dev
+++ b/frontend/Dockerfile.dev
@@ -9,7 +9,7 @@ COPY package.json ./
COPY package-lock.json ./
# Install
-RUN npm install
+RUN npm install --ignore-scripts
# Copy over next.js config
COPY next.config.js ./next.config.js
@@ -17,4 +17,4 @@ COPY next.config.js ./next.config.js
# Copy all files
COPY . .
-CMD ["npm", "run", "dev"]
\ No newline at end of file
+CMD ["npm", "run", "dev"]
diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod
deleted file mode 100644
index d95c00883..000000000
--- a/frontend/Dockerfile.prod
+++ /dev/null
@@ -1,20 +0,0 @@
-# Base layer
-FROM node:16-alpine
-
-# Set the working directory
-WORKDIR /app
-
-# Copy over dependency files
-COPY package.json ./
-COPY package-lock.json ./
-
-# Install
-RUN npm install
-
-# Copy over next.js config
-COPY next.config.js ./next.config.js
-
-# Copy all files
-COPY . .
-
-CMD ["npm", "run", "start:docker"]
diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx
index aa8c41f85..e726b7f56 100644
--- a/frontend/components/basic/Listbox.tsx
+++ b/frontend/components/basic/Listbox.tsx
@@ -10,10 +10,10 @@ import { Listbox, Transition } from "@headlessui/react";
interface ListBoxProps {
selected: string;
- onChange: () => void;
- data: string[];
- text: string;
- buttonAction: () => void;
+ onChange: (arg: string) => void;
+ data: string[] | null;
+ text?: string;
+ buttonAction?: () => void;
isFull?: boolean;
}
diff --git a/frontend/components/basic/buttons/Button.tsx b/frontend/components/basic/buttons/Button.tsx
index 9f8d481b4..562a82a36 100644
--- a/frontend/components/basic/buttons/Button.tsx
+++ b/frontend/components/basic/buttons/Button.tsx
@@ -9,7 +9,7 @@ import {
const classNames = require("classnames");
type ButtonProps = {
- text: string;
+ text?: string;
onButtonPressed: () => void;
loading?: boolean;
color?: string;
diff --git a/frontend/components/basic/dialog/ActivateBotDialog.js b/frontend/components/basic/dialog/ActivateBotDialog.js
new file mode 100644
index 000000000..79d8cd693
--- /dev/null
+++ b/frontend/components/basic/dialog/ActivateBotDialog.js
@@ -0,0 +1,92 @@
+import { Fragment } from "react";
+import { Dialog, Transition } from "@headlessui/react";
+
+import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus";
+import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey";
+import {
+ decryptAssymmetric,
+ encryptAssymmetric
+} from "../../utilities/cryptography/crypto";
+import Button from "../buttons/Button";
+
+const ActivateBotDialog = ({
+ isOpen,
+ closeModal,
+ selectedIntegrationOption,
+ handleBotActivate,
+ handleIntegrationOption
+}) => {
+
+ const submit = async () => {
+ try {
+ // 1. activate bot
+ await handleBotActivate();
+
+ // 2. start integration
+ await handleIntegrationOption({
+ integrationOption: selectedIntegrationOption
+ });
+ } catch (err) {
+ console.log(err);
+ }
+
+ closeModal();
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Grant Infisical access to your secrets
+
+
+
+ Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ActivateBotDialog;
\ No newline at end of file
diff --git a/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js b/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js
new file mode 100644
index 000000000..375dc8804
--- /dev/null
+++ b/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js
@@ -0,0 +1,101 @@
+import { Fragment } from "react";
+import { Dialog, Transition } from "@headlessui/react";
+
+import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus";
+import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey";
+import {
+ decryptAssymmetric,
+ encryptAssymmetric
+} from "../../utilities/cryptography/crypto";
+import Button from "../buttons/Button";
+import InputField from "../InputField";
+
+const IntegrationAccessTokenDialog = ({
+ isOpen,
+ closeModal,
+ selectedIntegrationOption,
+ handleBotActivate,
+ handleIntegrationOption
+}) => {
+
+ const submit = async () => {
+ try {
+ // 1. activate bot
+ await handleBotActivate();
+
+ // 2. start integration
+ await handleIntegrationOption({
+ integrationOption: selectedIntegrationOption
+ });
+ } catch (err) {
+ console.log(err);
+ }
+
+ closeModal();
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Grant Infisical access to your secrets
+
+
+
+ Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over.
+
+
+
+ {/* */}
+ {}}
+ type="varName"
+ value={"Hello"}
+ placeholder=""
+ isRequired
+ />
+
+
+
+
+
+
+
+
+ );
+}
+
+export default IntegrationAccessTokenDialog;
\ No newline at end of file
diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx
new file mode 100644
index 000000000..75a8019a5
--- /dev/null
+++ b/frontend/components/integrations/CloudIntegration.tsx
@@ -0,0 +1,121 @@
+import React from "react";
+import Image from "next/image";
+import { useRouter } from "next/router";
+import {
+ faCheck,
+ faX,
+ } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import deleteIntegrationAuth from "../../pages/api/integrations/DeleteIntegrationAuth";
+
+interface CloudIntegrationOption {
+ isAvailable: boolean;
+ name: string;
+ type: string;
+ clientId: string;
+ docsLink: string;
+ slug: string;
+}
+
+interface IntegrationAuth {
+ _id: string;
+ integration: string;
+}
+
+interface Props {
+ cloudIntegrationOption: CloudIntegrationOption;
+ setSelectedIntegrationOption: (cloudIntegration: CloudIntegrationOption) => void;
+ integrationOptionPress: (cloudIntegrationOption: CloudIntegrationOption) => void;
+ integrationAuths: IntegrationAuth[];
+}
+
+const CloudIntegration = ({
+ cloudIntegrationOption,
+ setSelectedIntegrationOption,
+ integrationOptionPress,
+ integrationAuths
+}: Props) => {
+ const router = useRouter();
+ return integrationAuths ? (
+ {
+ if (!cloudIntegrationOption.isAvailable) return;
+ setSelectedIntegrationOption(cloudIntegrationOption);
+ integrationOptionPress(cloudIntegrationOption);
+ }}
+ key={cloudIntegrationOption.name}
+ >
+
+ {cloudIntegrationOption.name.split(" ").length > 2 ? (
+
+
{cloudIntegrationOption.name.split(" ")[0]}
+
+ {cloudIntegrationOption.name.split(" ")[1]}{" "}
+ {cloudIntegrationOption.name.split(" ")[2]}
+
+
+ ) : (
+
+ {cloudIntegrationOption.name}
+
+ )}
+ {cloudIntegrationOption.isAvailable &&
+ integrationAuths
+ .map((authorization) => authorization.integration)
+ .includes(cloudIntegrationOption.name.toLowerCase()) && (
+
+
{
+ event.stopPropagation();
+ deleteIntegrationAuth({
+ integrationAuthId: integrationAuths
+ .filter(
+ (authorization) =>
+ authorization.integration ==
+ cloudIntegrationOption.name.toLowerCase()
+ )
+ .map((authorization) => authorization._id)[0],
+ });
+
+ router.reload();
+ }}
+ className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200"
+ >
+
+ Revoke
+
+
+
+ Authorized
+
+
+ )}
+ {!cloudIntegrationOption.isAvailable && (
+
+ )}
+
+ ) :
+}
+
+export default CloudIntegration;
\ No newline at end of file
diff --git a/frontend/components/integrations/CloudIntegrationSection.tsx b/frontend/components/integrations/CloudIntegrationSection.tsx
new file mode 100644
index 000000000..58fb92cb2
--- /dev/null
+++ b/frontend/components/integrations/CloudIntegrationSection.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+
+import CloudIntegration from "./CloudIntegration";
+
+interface CloudIntegrationOption {
+ isAvailable: boolean;
+ name: string;
+ type: string;
+ clientId: string;
+ docsLink: string;
+ slug: string;
+}
+
+interface Props {
+ cloudIntegrationOptions: CloudIntegrationOption[];
+ setSelectedIntegrationOption: () => void;
+ integrationOptionPress: () => void;
+ integrationAuths: any;
+}
+
+const CloudIntegrationSection = ({
+ cloudIntegrationOptions,
+ setSelectedIntegrationOption,
+ integrationOptionPress,
+ integrationAuths
+}: Props) => {
+ return (
+ <>
+
+
Cloud Integrations
+
+ Click on an integration to begin syncing secrets to it.
+
+
+
+ {cloudIntegrationOptions.map((cloudIntegrationOption) => (
+
+ ))}
+
+ >
+ );
+}
+
+export default CloudIntegrationSection;
\ No newline at end of file
diff --git a/frontend/components/integrations/FrameworkIntegration.tsx b/frontend/components/integrations/FrameworkIntegration.tsx
new file mode 100644
index 000000000..432dbad51
--- /dev/null
+++ b/frontend/components/integrations/FrameworkIntegration.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import Image from "next/image";
+
+interface Framework {
+ name: string;
+ slug: string;
+ image: string;
+ docsLink: string;
+}
+
+const FrameworkIntegration = ({
+ framework
+}: {
+ framework: Framework;
+}) => {
+ return (
+
+ 1 ? "text-sm px-1" : "text-xl px-2"} text-center w-full max-w-xs`}>
+ {framework?.image &&
}
+ {framework?.name && framework?.image &&
}
+ {framework?.name && framework.name}
+
+
+ );
+}
+
+export default FrameworkIntegration;
diff --git a/frontend/components/integrations/FrameworkIntegrationSection.tsx b/frontend/components/integrations/FrameworkIntegrationSection.tsx
new file mode 100644
index 000000000..8535c595b
--- /dev/null
+++ b/frontend/components/integrations/FrameworkIntegrationSection.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+
+import FrameworkIntegration from "./FrameworkIntegration";
+
+interface Framework {
+ name: string;
+ image: string;
+ link: string;
+ slug: string;
+ docsLink: string;
+}
+
+interface Props {
+ frameworks: [Framework]
+}
+
+const FrameworkIntegrationSection = ({ frameworks }: Props) => {
+ return (
+ <>
+
+
Framework Integrations
+
+ Click on a framework to get the setup instructions.
+
+
+
+ {frameworks.map((framework) => (
+
+ ))}
+
+ >
+ );
+}
+
+export default FrameworkIntegrationSection;
+
diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx
new file mode 100644
index 000000000..053269be4
--- /dev/null
+++ b/frontend/components/integrations/Integration.tsx
@@ -0,0 +1,231 @@
+import React, { useEffect, useState } from "react";
+import { useRouter } from "next/router";
+import {
+ faArrowRight,
+ faRotate,
+ faX,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import Button from "~/components/basic/buttons/Button";
+import ListBox from "~/components/basic/Listbox";
+
+import deleteIntegration from "../../pages/api/integrations/DeleteIntegration"
+import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps";
+import updateIntegration from "../../pages/api/integrations/updateIntegration"
+import {
+ contextNetlifyMapping,
+ envMapping,
+ reverseContextNetlifyMapping,
+ reverseEnvMapping,
+} from "../../public/data/frequentConstants";
+
+interface Integration {
+ _id: string;
+ app?: string;
+ environment: string;
+ integration: string;
+ integrationAuth: string;
+ isActive: boolean;
+ context: string;
+}
+
+interface IntegrationApp {
+ name: string;
+ siteId: string;
+}
+
+const Integration = ({
+ integration
+}: {
+ integration: Integration;
+}) => {
+ const [integrationEnvironment, setIntegrationEnvironment] = useState(
+ reverseEnvMapping[integration.environment]
+ );
+ const [fileState, setFileState] = useState([]);
+ const router = useRouter();
+ const [apps, setApps] = useState([]); // integration app objects
+ const [integrationApp, setIntegrationApp] = useState(""); // integration app name
+ const [integrationTarget, setIntegrationTarget] = useState(""); // vercel-specific integration param
+ const [integrationContext, setIntegrationContext] = useState(""); // netlify-specific integration param
+
+ useEffect(() => {
+
+ const loadIntegration = async () => {
+ interface App {
+ name: string;
+ siteId?: string;
+ }
+
+ const tempApps: [IntegrationApp] = await getIntegrationApps({
+ integrationAuthId: integration.integrationAuth,
+ });
+
+ setApps(tempApps);
+ setIntegrationApp(
+ integration.app ? integration.app : tempApps[0].name
+ );
+
+ switch (integration.integration) {
+ case "vercel":
+ setIntegrationTarget("Development");
+ break;
+ case "netlify":
+ setIntegrationContext(integration?.context ? contextNetlifyMapping[integration.context] : "Local development");
+ break;
+ default:
+ break;
+ }
+ }
+
+ loadIntegration();
+ }, []);
+
+ const renderIntegrationSpecificParams = (integration: Integration) => {
+ try {
+ switch (integration.integration) {
+ case "vercel":
+ return (
+
+ );
+ case "netlify":
+ return (
+
+ );
+ default:
+ return
;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ if (!integrationApp || apps.length === 0) return
+
+ return (
+
+
+
+
ENVIRONMENT
+
{
+ setIntegrationEnvironment(environment);
+ }}
+ isFull={true}
+ />
+
+
+
+
+
+
+ INTEGRATION
+
+
+ {integration.integration.charAt(0).toUpperCase() +
+ integration.integration.slice(1)}
+
+
+
+
+ APP
+
+
app.name) : null}
+ selected={integrationApp}
+ onChange={(app) => {
+ setIntegrationApp(app);
+ }}
+ />
+
+ {renderIntegrationSpecificParams(integration)}
+
+
+ {integration.isActive ? (
+
+ ) : (
+
{
+
+ const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined
+ const siteId = siteApp?.siteId ? siteApp.siteId : null;
+
+ const result = await updateIntegration({
+ integrationId: integration._id,
+ environment: envMapping[integrationEnvironment],
+ app: integrationApp,
+ isActive: true,
+ target: integrationTarget ? integrationTarget.toLowerCase() : null,
+ context: integrationContext ? reverseContextNetlifyMapping[integrationContext] : null,
+ siteId
+ });
+
+ router.reload();
+ }}
+ color="mineshaft"
+ size="md"
+ />
+ )}
+
+ {
+ await deleteIntegration({
+ integrationId: integration._id,
+ });
+ router.reload();
+ }}
+ color="red"
+ size="icon-md"
+ icon={faX}
+ />
+
+
+
+ );
+ };
+
+export default Integration;
\ No newline at end of file
diff --git a/frontend/components/integrations/IntegrationSection.tsx b/frontend/components/integrations/IntegrationSection.tsx
new file mode 100644
index 000000000..52d5565ff
--- /dev/null
+++ b/frontend/components/integrations/IntegrationSection.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+
+import guidGenerator from "~/utilities/randomId";
+
+import Integration from "./Integration";
+
+interface Props {
+ integrations: any
+}
+
+interface IntegrationType {
+ _id: string;
+ app?: string;
+ environment: string;
+ integration: string;
+ integrationAuth: string;
+ isActive: boolean;
+ context: string;
+}
+
+const ProjectIntegrationSection = ({
+ integrations
+}: Props) => {
+ return integrations.length > 0 ? (
+
+
+
Current Integrations
+
+ Manage your integrations of Infisical with third-party services.
+
+
+ {integrations.map((integration: IntegrationType) => (
+
+ ))}
+
+ ) :
+}
+
+export default ProjectIntegrationSection;
\ No newline at end of file
diff --git a/frontend/components/utilities/attemptLogin.js b/frontend/components/utilities/attemptLogin.js
index 228f58727..b0d70146e 100644
--- a/frontend/components/utilities/attemptLogin.js
+++ b/frontend/components/utilities/attemptLogin.js
@@ -73,7 +73,7 @@ const attemptLogin = async (
tag,
privateKey
});
-
+
const userOrgs = await getOrganizations();
const userOrgsData = userOrgs.map((org) => org._id);
diff --git a/frontend/components/utilities/config/index.ts b/frontend/components/utilities/config/index.ts
index d0ffed00c..7570785ba 100644
--- a/frontend/components/utilities/config/index.ts
+++ b/frontend/components/utilities/config/index.ts
@@ -10,5 +10,5 @@ export {
POSTHOG_API_KEY,
POSTHOG_HOST,
STRIPE_PRODUCT_PRO,
- STRIPE_PRODUCT_STARTER,
-};
+ STRIPE_PRODUCT_STARTER
+};
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5e3bb010b..b65657f85 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -60,7 +60,6 @@
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-simple-import-sort": "^8.0.0",
"postcss": "^8.4.14",
- "prettier": "2.7.1",
"tailwindcss": "^3.1.4",
"typescript": "^4.9.3"
}
@@ -5999,21 +5998,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/prettier": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
- "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
- "dev": true,
- "bin": {
- "prettier": "bin-prettier.js"
- },
- "engines": {
- "node": ">=10.13.0"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -11950,12 +11934,6 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true
},
- "prettier": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
- "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
- "dev": true
- },
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7f7926c88..12b2e9ec2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,7 @@
{
"private": true,
"scripts": {
+ "prepare": "cd .. && npm install",
"dev": "next dev",
"build": "next build",
"start": "next start",
@@ -63,7 +64,6 @@
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-simple-import-sort": "^8.0.0",
"postcss": "^8.4.14",
- "prettier": "2.7.1",
"tailwindcss": "^3.1.4",
"typescript": "^4.9.3"
}
diff --git a/frontend/pages/api/bot/getBot.ts b/frontend/pages/api/bot/getBot.ts
new file mode 100644
index 000000000..145b50891
--- /dev/null
+++ b/frontend/pages/api/bot/getBot.ts
@@ -0,0 +1,31 @@
+import SecurityClient from "~/utilities/SecurityClient";
+
+interface Props {
+ workspaceId: string;
+}
+
+/**
+ * This function fetches the bot for a project
+ * @param {Object} obj
+ * @param {String} obj.workspaceId
+ * @returns
+ */
+const getBot = async ({ workspaceId }: Props) => {
+ return SecurityClient.fetchCall(
+ "/api/v1/bot/" + workspaceId,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ }
+ }
+ ).then(async (res) => {
+ if (res && res.status == 200) {
+ return (await res.json()).bot;
+ } else {
+ console.log("Failed to get bot for project");
+ }
+ });
+};
+
+export default getBot;
\ No newline at end of file
diff --git a/frontend/pages/api/bot/setBotActiveStatus.ts b/frontend/pages/api/bot/setBotActiveStatus.ts
new file mode 100644
index 000000000..0a974a588
--- /dev/null
+++ b/frontend/pages/api/bot/setBotActiveStatus.ts
@@ -0,0 +1,46 @@
+import SecurityClient from "~/utilities/SecurityClient";
+
+interface BotKey {
+ encryptedKey: string;
+ nonce: string;
+}
+
+interface Props {
+ botId: string;
+ isActive: boolean;
+ botKey: BotKey;
+}
+
+/**
+ * This function sets the active status of a bot and shares a copy of
+ * the project key (encrypted under the bot's public key) with the
+ * project's bot
+ * @param {Object} obj
+ * @param {String} obj.botId
+ * @param {String} obj.isActive
+ * @param {Object} obj.botKey
+ * @returns
+ */
+const setBotActiveStatus = async ({ botId, isActive, botKey }: Props) => {
+ return SecurityClient.fetchCall(
+ "/api/v1/bot/" + botId + "/active",
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ isActive,
+ botKey
+ })
+ }
+ ).then(async (res) => {
+ if (res && res.status == 200) {
+ return await res.json();
+ } else {
+ console.log("Failed to get bot for project");
+ }
+ });
+};
+
+export default setBotActiveStatus;
\ No newline at end of file
diff --git a/frontend/pages/api/integrations/GetIntegrationOptions.ts b/frontend/pages/api/integrations/GetIntegrationOptions.ts
new file mode 100644
index 000000000..caf0c8626
--- /dev/null
+++ b/frontend/pages/api/integrations/GetIntegrationOptions.ts
@@ -0,0 +1,21 @@
+import SecurityClient from '~/utilities/SecurityClient';
+
+const getIntegrationOptions = () => {
+ return SecurityClient.fetchCall(
+ '/api/v1/integration-auth/integration-options',
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }
+ ).then(async (res) => {
+ if (res && res.status == 200) {
+ return (await res.json()).integrationOptions;
+ } else {
+ console.log('Failed to get (cloud) integration options');
+ }
+ });
+};
+
+export default getIntegrationOptions;
diff --git a/frontend/pages/api/integrations/GetIntegrations.ts b/frontend/pages/api/integrations/GetIntegrations.ts
deleted file mode 100644
index c189010a4..000000000
--- a/frontend/pages/api/integrations/GetIntegrations.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import SecurityClient from '~/utilities/SecurityClient';
-
-const getIntegrations = () => {
- return SecurityClient.fetchCall('/api/v1/integration/integrations', {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json'
- }
- }).then(async (res) => {
- if (res && res.status == 200) {
- return (await res.json()).integrations;
- } else {
- console.log('Failed to get project integrations');
- }
- });
-};
-
-export default getIntegrations;
diff --git a/frontend/pages/api/integrations/updateIntegration.ts b/frontend/pages/api/integrations/updateIntegration.ts
new file mode 100644
index 000000000..8afcfc0a9
--- /dev/null
+++ b/frontend/pages/api/integrations/updateIntegration.ts
@@ -0,0 +1,60 @@
+import SecurityClient from "~/utilities/SecurityClient";
+
+/**
+ * This route starts the integration after teh default one if gonna set up.
+ * Update integration with id [integrationId] to sync envars from the project's
+ * [environment] to the integration [app] with active state [isActive]
+ * @param {Object} obj
+ * @param {String} obj.integrationId - id of integration
+ * @param {String} obj.app - name of app
+ * @param {String} obj.environment - project environment to push secrets from
+ * @param {Boolean} obj.isActive - active state
+ * @param {String} obj.target - (optional) target (environment) for Vercel integration
+ * @param {String} obj.context - (optional) context (environment) for Netlify integration
+ * @param {String} obj.siteId - (optional) app (site_id) for Netlify integration
+ * @returns
+ */
+const updateIntegration = ({
+ integrationId,
+ app,
+ environment,
+ isActive,
+ target,
+ context,
+ siteId
+}: {
+ integrationId: string,
+ app: string,
+ environment: string,
+ isActive: boolean,
+ target: string | null,
+ context: string | null,
+ siteId: string | null
+
+}) => {
+ return SecurityClient.fetchCall(
+ "/api/v1/integration/" + integrationId,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ app,
+ environment,
+ isActive,
+ target,
+ context,
+ siteId
+ }),
+ }
+ ).then(async (res) => {
+ if (res && res.status == 200) {
+ return res;
+ } else {
+ console.log("Failed to start an integration");
+ }
+ });
+};
+
+export default updateIntegration;
diff --git a/frontend/pages/dashboard/[id].js b/frontend/pages/dashboard/[id].js
index 4bb09c80f..36f5eb3f2 100644
--- a/frontend/pages/dashboard/[id].js
+++ b/frontend/pages/dashboard/[id].js
@@ -413,29 +413,6 @@ export default function Dashboard() {
setButtonReady(false);
pushKeys({ obj, workspaceId: router.query.id, env });
- /**
- * Check which integrations are active for this project and environment
- * If there are any, update environment variables for those integrations
- */
- let integrations = await getWorkspaceIntegrations({
- workspaceId: router.query.id
- });
- integrations.map(async (integration) => {
- if (
- envMapping[env] == integration.environment &&
- integration.isActive == true
- ) {
- let objIntegration = Object.assign(
- {},
- ...data.map((row) => ({ [row.key]: row.value }))
- );
- await pushKeysIntegration({
- obj: objIntegration,
- integrationId: integration._id
- });
- }
- });
-
// If this user has never saved environment variables before, show them a prompt to read docs
if (!hasUserEverPushed) {
setCheckDocsPopUpVisible(true);
diff --git a/frontend/pages/heroku.js b/frontend/pages/heroku.js
index 82947b1b9..088c96500 100644
--- a/frontend/pages/heroku.js
+++ b/frontend/pages/heroku.js
@@ -16,16 +16,17 @@ export default function Heroku() {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(async () => {
try {
- if (state == localStorage.getItem("latestCSRFToken")) {
+ if (state === localStorage.getItem('latestCSRFToken')) {
+ localStorage.removeItem('latestCSRFToken');
await AuthorizeIntegration({
- workspaceId: localStorage.getItem("projectData.id"),
+ workspaceId: localStorage.getItem('projectData.id'),
code,
integration: "heroku",
});
router.push("/integrations/" + localStorage.getItem("projectData.id"));
}
} catch (error) {
- console.log("Error - Not logged in yet");
+ console.error('Heroku integration error: ', error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js
index dc13d284c..a7b5f6b13 100644
--- a/frontend/pages/integrations/[id].js
+++ b/frontend/pages/integrations/[id].js
@@ -1,199 +1,179 @@
-import React, { useEffect, useState } from 'react';
-import Head from 'next/head';
-import Image from 'next/image';
-import { useRouter } from 'next/router';
-import {
- faArrowRight,
- faCheck,
- faRotate,
- faX
-} from '@fortawesome/free-solid-svg-icons';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import React, { useEffect, useState } from "react";
+import Head from "next/head";
+import Image from "next/image";
+import { useRouter } from "next/router";
-import Button from '~/components/basic/buttons/Button';
-import ListBox from '~/components/basic/Listbox';
-import NavHeader from '~/components/navigation/NavHeader';
-import getSecretsForProject from '~/components/utilities/secrets/getSecretsForProject';
-import pushKeysIntegration from '~/components/utilities/secrets/pushKeysIntegration';
-import guidGenerator from '~/utilities/randomId';
+import ActivateBotDialog from "~/components/basic/dialog/ActivateBotDialog";
+import IntegrationAccessTokenDialog from "~/components/basic/dialog/IntegrationAccessTokenDialog";
+import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection";
+import FrameworkIntegrationSection from "~/components/integrations/FrameworkIntegrationSection";
+import Integration from "~/components/integrations/Integration";
+import IntegrationSection from "~/components/integrations/IntegrationSection";
+import NavHeader from "~/components/navigation/NavHeader";
-import {
- envMapping,
- frameworks,
- reverseEnvMapping
-} from '../../public/data/frequentConstants';
-import deleteIntegration from '../api/integrations/DeleteIntegration';
-import deleteIntegrationAuth from '../api/integrations/DeleteIntegrationAuth';
-import getIntegrationApps from '../api/integrations/GetIntegrationApps';
-import getIntegrations from '../api/integrations/GetIntegrations';
-import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations';
-import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations';
-import startIntegration from '../api/integrations/StartIntegration';
-
-const crypto = require('crypto');
-
-const Integration = ({ projectIntegration }) => {
- const [integrationEnvironment, setIntegrationEnvironment] = useState(
- reverseEnvMapping[projectIntegration.environment]
- );
- const [fileState, setFileState] = useState([]);
- const [data, setData] = useState();
- const [isKeyAvailable, setIsKeyAvailable] = useState(true);
- const router = useRouter();
- const [apps, setApps] = useState([]);
- const [integrationApp, setIntegrationApp] = useState(
- projectIntegration.app ? projectIntegration.app : apps[0]
- );
-
- useEffect(async () => {
- const tempHerokuApps = await getIntegrationApps({
- integrationAuthId: projectIntegration.integrationAuth
- });
- const tempHerokuAppNames = tempHerokuApps.map((app) => app.name);
- setApps(tempHerokuAppNames);
- setIntegrationApp(
- projectIntegration.app ? projectIntegration.app : tempHerokuAppNames[0]
- );
- }, []);
-
- return (
-
-
-
-
-
-
-
- INTEGRATION
-
-
- {projectIntegration.integration.charAt(0).toUpperCase() +
- projectIntegration.integration.slice(1)}
-
-
-
-
-
- {projectIntegration.isActive ? (
-
- ) : (
-
{
- const result = await startIntegration({
- integrationId: projectIntegration._id,
- environment: envMapping[integrationEnvironment],
- appName: integrationApp
- });
- if (result?.status == 200) {
- let currentSecrets = await getSecretsForProject({
- env: integrationEnvironment,
- setFileState,
- setIsKeyAvailable,
- setData,
- workspaceId: router.query.id
- });
-
- let obj = Object.assign(
- {},
- ...currentSecrets.map((row) => ({
- [row.key]: row.value
- }))
- );
- await pushKeysIntegration({
- obj,
- integrationId: projectIntegration._id
- });
- router.reload();
- }
- }}
- color="mineshaft"
- size="md"
- />
- )}
-
- {
- await deleteIntegration({
- integrationId: projectIntegration._id
- });
- router.reload();
- }}
- color="red"
- size="icon-md"
- icon={faX}
- />
-
-
-
-
- );
-};
+import frameworkIntegrationOptions from "../../public/json/frameworkIntegrations.json";
+import getBot from "../api/bot/getBot";
+import setBotActiveStatus from "../api/bot/setBotActiveStatus";
+import getIntegrationOptions from "../api/integrations/GetIntegrationOptions";
+import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations";
+import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations";
+import getLatestFileKey from "../api/workspace/getLatestFileKey";
+const {
+ decryptAssymmetric,
+ encryptAssymmetric
+} = require('../../components/utilities/cryptography/crypto');
+const crypto = require("crypto");
export default function Integrations() {
- const [integrations, setIntegrations] = useState();
- const [projectIntegrations, setProjectIntegrations] = useState();
- const [authorizations, setAuthorizations] = useState();
+ const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]);
+ const [integrationAuths, setIntegrationAuths] = useState([]);
+ const [integrations, setIntegrations] = useState([]);
+ const [bot, setBot] = useState(null);
+ const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false);
+ // const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true);
+ const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null);
+
const router = useRouter();
- const [csrfToken, setCsrfToken] = useState('');
useEffect(async () => {
- const tempCSRFToken = crypto.randomBytes(16).toString('hex');
- setCsrfToken(tempCSRFToken);
- localStorage.setItem('latestCSRFToken', tempCSRFToken);
-
- let projectAuthorizations = await getWorkspaceAuthorizations({
- workspaceId: router.query.id
- });
- setAuthorizations(projectAuthorizations);
-
- const projectIntegrations = await getWorkspaceIntegrations({
- workspaceId: router.query.id
- });
- setProjectIntegrations(projectIntegrations);
-
try {
- const integrationsData = await getIntegrations();
- setIntegrations(integrationsData);
- } catch (error) {
- console.log('Error', error);
+ // get cloud integration options
+ setCloudIntegrationOptions(
+ await getIntegrationOptions()
+ );
+
+ // get project integration authorizations
+ setIntegrationAuths(
+ await getWorkspaceAuthorizations({
+ workspaceId: router.query.id,
+ })
+ );
+
+ // get project integrations
+ setIntegrations(
+ await getWorkspaceIntegrations({
+ workspaceId: router.query.id,
+ })
+ );
+
+ // get project bot
+ setBot(
+ await getBot({
+ workspaceId: router.query.id
+ }
+ ));
+
+ } catch (err) {
+ console.log(err);
}
}, []);
- return integrations ? (
+ /**
+ * Activate bot for project by performing the following steps:
+ * 1. Get the (encrypted) project key
+ * 2. Decrypt project key with user's private key
+ * 3. Encrypt project key with bot's public key
+ * 4. Send encrypted project key to backend and set bot status to active
+ */
+ const handleBotActivate = async () => {
+ let botKey;
+ try {
+
+ if (bot) {
+ // case: there is a bot
+ const key = await getLatestFileKey({ workspaceId: router.query.id });
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY');
+
+ const WORKSPACE_KEY = decryptAssymmetric({
+ ciphertext: key.latestKey.encryptedKey,
+ nonce: key.latestKey.nonce,
+ publicKey: key.latestKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ const { ciphertext, nonce } = encryptAssymmetric({
+ plaintext: WORKSPACE_KEY,
+ publicKey: bot.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ botKey = {
+ encryptedKey: ciphertext,
+ nonce
+ }
+
+ setBot((await setBotActiveStatus({
+ botId: bot._id,
+ isActive: bot.isActive ? false : true,
+ botKey
+ })).bot);
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ /**
+ * Start integration for a given integration option [integrationOption]
+ * @param {Object} obj
+ * @param {Object} obj.integrationOption - an integration option
+ * @param {String} obj.name
+ * @param {String} obj.type
+ * @param {String} obj.docsLink
+ * @returns
+ */
+ const handleIntegrationOption = async ({ integrationOption }) => {
+
+ try {
+ // generate CSRF token for OAuth2 code-token exchange integrations
+ const state = crypto.randomBytes(16).toString("hex");
+ localStorage.setItem('latestCSRFToken', state);
+
+ switch (integrationOption.name) {
+ case 'Heroku':
+ window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
+ break;
+ case 'Vercel':
+ window.location = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`;
+ break;
+ case 'Netlify':
+ window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`;
+ break;
+ // case 'Fly.io':
+ // console.log('fly.io');
+ // setIntegrationAccessTokenDialogOpen(true);
+ // break;
+ }
+ } catch (err) {
+ console.log(err);
+ }
+ }
+
+ /**
+ * Open dialog to activate bot if bot is not active.
+ * Otherwise, start integration [integrationOption]
+ * @param {Object} integrationOption - an integration option
+ * @param {String} integrationOption.name
+ * @param {String} integrationOption.type
+ * @param {String} integrationOption.docsLink
+ * @returns
+ */
+ const integrationOptionPress = (integrationOption) => {
+ try {
+ if (bot.isActive) {
+ // case: bot is active -> proceed with integration
+ handleIntegrationOption({ integrationOption });
+ return;
+ }
+
+ // case: bot is not active -> open modal to activate bot
+ setIsActivateBotDialogOpen(true);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ return (
Dashboard
@@ -205,200 +185,41 @@ export default function Integrations() {
content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files."
/>
-
-
-
-
-
-
Current Project Integrations
-
-
- Manage your integrations of Infisical with third-party services.
-
-
- {projectIntegrations.length > 0 ? (
- projectIntegrations.map((projectIntegration) => (
-
- ))
- ) : (
-
-
-
- You {"don't"} have any integrations set up yet. When you do,
- they will appear here.
-
-
- To start, click on any of the options below. It takes 5 clicks
- to set up.
-
-
-
- )}
-
-
-
- Platform & Cloud Integrations
-
-
-
- Click on the itegration you want to connect. This will let your
- environment variables flow automatically into selected third-party
- services.
-
-
- Note: during an integration with Heroku, for security reasons, it
- is impossible to maintain end-to-end encryption. In theory, this
- lets Infisical decrypt yor environment variables. In practice, we
- can assure you that this will never be done, and it allows us to
- protect your secrets from bad actors online. The core Infisical
- service will always stay end-to-end encrypted. With any questions,
- reach out support@infisical.com.
-
-
-
- {Object.keys(integrations).map((integration) => (
-
- ))}
-
-
-
-
Framework Integrations
-
-
- Click on a framework to get the setup instructions.
-
-
-
- {frameworks.map((framework) => (
-
- ))}
-
-
+
+
+
setIsActivateBotDialogOpen(false)}
+ selectedIntegrationOption={selectedIntegrationOption}
+ handleBotActivate={handleBotActivate}
+ handleIntegrationOption={handleIntegrationOption}
+ />
+ {/* setIntegrationAccessTokenDialogOpen(false)}
+ selectedIntegrationOption={selectedIntegrationOption}
+ handleBotActivate={handleBotActivate}
+ handleIntegrationOption={handleIntegrationOption}
+ /> */}
+
+ {cloudIntegrationOptions.length > 0 ? (
+
+ ) : (
+
+ )}
+
- ) : (
-
);
}
diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js
new file mode 100644
index 000000000..6907d6db4
--- /dev/null
+++ b/frontend/pages/netlify.js
@@ -0,0 +1,41 @@
+import React, { useEffect } from "react";
+import Head from "next/head";
+import { useRouter } from "next/router";
+const queryString = require("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.code;
+ const state = parsedUrl.state;
+ // 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 (state === localStorage.getItem('latestCSRFToken')) {
+ localStorage.removeItem('latestCSRFToken');
+
+ await AuthorizeIntegration({
+ workspaceId: localStorage.getItem('projectData.id'),
+ code,
+ 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/pages/vercel.js b/frontend/pages/vercel.js
new file mode 100644
index 000000000..adfffe77e
--- /dev/null
+++ b/frontend/pages/vercel.js
@@ -0,0 +1,40 @@
+import React, { useEffect } from "react";
+import Head from "next/head";
+import { useRouter } from "next/router";
+const queryString = require("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.code;
+ const state = parsedUrl.state
+
+ /**
+ * Here we forward to the default workspace if a user opens this url
+ */
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(async () => {
+ try {
+ if (state === localStorage.getItem('latestCSRFToken')) {
+ localStorage.removeItem('latestCSRFToken');
+
+ await AuthorizeIntegration({
+ workspaceId: localStorage.getItem('projectData.id'),
+ code,
+ 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/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts
index fbc0034d3..d2beee6d2 100644
--- a/frontend/public/data/frequentConstants.ts
+++ b/frontend/public/data/frequentConstants.ts
@@ -1,82 +1,37 @@
-const envMapping = {
+interface Mapping {
+ [key: string]: string;
+}
+
+const envMapping: Mapping = {
Development: "dev",
Staging: "staging",
Production: "prod",
Testing: "test",
};
-
-const reverseEnvMapping = {
+
+const reverseEnvMapping: Mapping = {
dev: "Development",
staging: "Staging",
prod: "Production",
test: "Testing",
};
-const frameworks = [{
- "name": "Docker",
- "image": "Docker",
- "link": "https://infisical.com/docs/integrations/platforms/docker"
- }, {
- "name": "Docker Compose",
- "image": "Docker Compose",
- "link": "https://infisical.com/docs/integrations/platforms/docker-compose"
- }, {
- "name": "React",
- "image": "React",
- "link": "https://infisical.com/docs/integrations/frameworks/react"
- }, {
- "name": "Vue",
- "image": "Vue",
- "link": "https://infisical.com/docs/integrations/frameworks/vue"
- }, {
- "image": "Express",
- "link": "https://infisical.com/docs/integrations/frameworks/express"
- },{
- "image": "Next.js",
- "link": "https://infisical.com/docs/integrations/frameworks/nextjs"
- }, {
- "name": "Django",
- "image": "Django",
- "link": "https://infisical.com/docs/integrations/frameworks/django"
- }, {
- "name": "NestJS",
- "image": "NestJS",
- "link": "https://infisical.com/docs/integrations/frameworks/nestjs"
- }, {
- "name": "Nuxt",
- "image": "Nuxt",
- "link": "https://infisical.com/docs/integrations/frameworks/nuxt"
- }, {
- "name": "Gatsby",
- "image": "Gatsby",
- "link": "https://infisical.com/docs/integrations/frameworks/gatsby"
- }, {
- "name": "Remix",
- "image": "Remix",
- "link": "https://infisical.com/docs/integrations/frameworks/remix"
- }, {
- "name": "Vite",
- "image": "Vite",
- "link": "https://infisical.com/docs/integrations/frameworks/vite"
- }, {
- "image": "Fiber",
- "link": "https://infisical.com/docs/integrations/frameworks/fiber"
- }, {
- "name": "Flask",
- "image": "Flask",
- "link": "https://infisical.com/docs/integrations/frameworks/flask"
- }, {
- "name": "Laravel",
- "image": "Laravel",
- "link": "https://infisical.com/docs/integrations/frameworks/laravel"
- }, {
- "image": "Rails",
- "link": "https://infisical.com/docs/integrations/frameworks/rails"
- }
-]
+const contextNetlifyMapping: Mapping = {
+ "dev": "Local development",
+ "branch-deploy": "Branch deploys",
+ "deploy-review": "Deploy Previews",
+ "production": "Production"
+}
+
+const reverseContextNetlifyMapping: Mapping = {
+ "Local development": "dev",
+ "Branch deploys": "branch-deploy",
+ "Deploy Previews": "deploy-preview",
+ "Production": "production"
+}
export {
+ contextNetlifyMapping,
envMapping,
- frameworks,
- reverseEnvMapping
-};
+ reverseContextNetlifyMapping,
+ reverseEnvMapping}
diff --git a/frontend/public/images/integrations/Vercel.png b/frontend/public/images/integrations/Vercel.png
new file mode 100644
index 000000000..7bdcd2a19
Binary files /dev/null and b/frontend/public/images/integrations/Vercel.png differ
diff --git a/frontend/public/json/frameworkIntegrations.json b/frontend/public/json/frameworkIntegrations.json
new file mode 100644
index 000000000..fb8b30c9e
--- /dev/null
+++ b/frontend/public/json/frameworkIntegrations.json
@@ -0,0 +1,98 @@
+[
+ {
+ "name": "Docker",
+ "slug": "docker",
+ "image": "Docker",
+ "docsLink": "https://infisical.com/docs/integrations/platforms/docker"
+ },
+ {
+ "name": "Docker Compose",
+ "slug": "docker-compose",
+ "image": "Docker Compose",
+ "docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose"
+ },
+ {
+ "name": "React",
+ "slug": "react",
+ "image": "React",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/react"
+ },
+ {
+ "name": "Vue",
+ "slug": "vue",
+ "image": "Vue",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/vue"
+ },
+ {
+ "name": "Express",
+ "slug": "express",
+ "image": "Express",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/express"
+ },
+ {
+ "name": "Next.js",
+ "slug": "nextjs",
+ "image": "Next.js",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/nextjs"
+ },
+ {
+ "name": "Django",
+ "slug": "django",
+ "image": "Django",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/django"
+ },
+ {
+ "name": "NestJS",
+ "slug": "nestjs",
+ "image": "NestJS",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/nestjs"
+ },
+ {
+ "name": "Nuxt",
+ "slug": "nuxt",
+ "image": "Nuxt",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/nuxt"
+ },
+ {
+ "name": "Gatsby",
+ "slug": "gatsby",
+ "image": "Gatsby",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/gatsby"
+ },
+ {
+ "name": "Remix",
+ "slug": "remix",
+ "image": "Remix",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/remix"
+ },
+ {
+ "name": "Vite",
+ "slug": "vite",
+ "image": "Vite",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/vite"
+ },
+ {
+ "name": "Fiber",
+ "slug": "fiber",
+ "image": "Fiber",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/fiber"
+ },
+ {
+ "name": "Flask",
+ "slug": "flask",
+ "image": "Flask",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/flask"
+ },
+ {
+ "name": "Laravel",
+ "slug": "laravel",
+ "image": "Laravel",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/laravel"
+ },
+ {
+ "name": "Rails",
+ "slug": "rails",
+ "image": "Rails",
+ "docsLink": "https://infisical.com/docs/integrations/frameworks/rails"
+ }
+]
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 3a4157902..cf02a1760 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,7 @@
"license": "ISC",
"devDependencies": {
"eslint": "^8.29.0",
- "husky": "^8.0.2",
- "prettier": "^2.8.1"
+ "husky": "^8.0.2"
}
},
"node_modules/@eslint/eslintrc": {
@@ -899,21 +898,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/prettier": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz",
- "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==",
- "dev": true,
- "bin": {
- "prettier": "bin-prettier.js"
- },
- "engines": {
- "node": ">=10.13.0"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
"node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -1809,12 +1793,6 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true
},
- "prettier": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz",
- "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==",
- "dev": true
- },
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
diff --git a/package.json b/package.json
index b0d02f3a0..4d042a13d 100644
--- a/package.json
+++ b/package.json
@@ -14,15 +14,12 @@
"prepare": "husky install"
},
"lint-staged": {
- "**/*": "prettier --write --ignore-unknown",
"*.{js,jsx,ts,tsx}": [
- "eslint --fix",
- "prettier --write"
+ "eslint --fix"
]
},
"devDependencies": {
"eslint": "^8.29.0",
- "husky": "^8.0.2",
- "prettier": "^2.8.1"
+ "husky": "^8.0.2"
}
}