From 0ef4ac1cdc6b018e6103170adc6ee43474645cc9 Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Sat, 24 Jun 2023 23:25:18 +0530 Subject: [PATCH] feat(integration-page): implemented new optimized integrations page --- frontend/src/pages/integrations/[id].tsx | 471 +----------------- .../IntegrationPage.utils.tsx | 102 ++++ .../IntegrationsPage/IntegrationsPage.tsx | 223 +++++++++ .../CloudIntegrationSection.tsx | 140 ++++++ .../CloudIntegrationSection/index.tsx | 1 + .../FrameworkIntegrationSection.tsx | 54 ++ .../FrameworkIntegrationSection/index.tsx | 1 + .../IntegrationsSection.tsx | 145 ++++++ .../components/IntegrationsSection/index.tsx | 1 + frontend/src/views/IntegrationsPage/index.tsx | 1 + 10 files changed, 695 insertions(+), 444 deletions(-) create mode 100644 frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx create mode 100644 frontend/src/views/IntegrationsPage/IntegrationsPage.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/index.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/index.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx create mode 100644 frontend/src/views/IntegrationsPage/components/IntegrationsSection/index.tsx create mode 100644 frontend/src/views/IntegrationsPage/index.tsx diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index 54a29a5c4..143e3ad2f 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -1,409 +1,18 @@ -import crypto from "crypto"; - -import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Head from "next/head"; -import { useRouter } from "next/router"; import frameworkIntegrationOptions from "public/json/frameworkIntegrations.json"; -import ActivateBotDialog from "@app/components/basic/dialog/ActivateBotDialog"; -import CloudIntegrationSection from "@app/components/integrations/CloudIntegrationSection"; -import FrameworkIntegrationSection from "@app/components/integrations/FrameworkIntegrationSection"; -import IntegrationSection from "@app/components/integrations/IntegrationSection"; -import NavHeader from "@app/components/navigation/NavHeader"; +import { IntegrationsPage } from "@app/views/IntegrationsPage"; -import { - decryptAssymmetric, - encryptAssymmetric -} from "../../components/utilities/cryptography/crypto"; -import getBot from "../api/bot/getBot"; -import setBotActiveStatus from "../api/bot/setBotActiveStatus"; -import deleteIntegration from "../api/integrations/DeleteIntegration"; -import getIntegrationOptions from "../api/integrations/GetIntegrationOptions"; -import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; -import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; -import getAWorkspace from "../api/workspace/getAWorkspace"; -import getLatestFileKey from "../api/workspace/getLatestFileKey"; - -interface IntegrationAuth { - _id: string; - integration: string; - workspace: string; - createdAt: string; - updatedAt: string; -} - -interface Integration { - _id: string; - isActive: boolean; - app: string | null; - appId: string | null; - createdAt: string; - updatedAt: string; - environment: string; - integration: string; - targetEnvironment: string; - workspace: string; - secretPath:string; - integrationAuth: string; -} - -interface IntegrationOption { - tenantId?: string; - clientId: string; - clientSlug?: string; // vercel-integration specific - docsLink: string; - image: string; - isAvailable: boolean; - name: string; - slug: string; - type: string; -} - -export default function Integrations() { - const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]); - const [integrationAuths, setIntegrationAuths] = useState([]); - const [environments, setEnvironments] = useState< - { - name: string; - slug: string; - }[] - >([]); - const [integrations, setIntegrations] = useState([]); - // TODO: These will have its type when migratiing towards react-query - const [bot, setBot] = useState(null); - const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); - const [selectedIntegrationOption, setSelectedIntegrationOption] = - useState(null); - - const router = useRouter(); - const workspaceId = router.query.id as string; +type Props = { + frameworkIntegrations: typeof frameworkIntegrationOptions; +}; +const Integration = ({ frameworkIntegrations }: Props) => { const { t } = useTranslation(); - useEffect(() => { - (async () => { - try { - const workspace = await getAWorkspace(workspaceId); - setEnvironments(workspace.environments); - - // get cloud integration options - setCloudIntegrationOptions(await getIntegrationOptions()); - - // get project integration authorizations - setIntegrationAuths( - await getWorkspaceAuthorizations({ - workspaceId - }) - ); - - // get project integrations - setIntegrations( - await getWorkspaceIntegrations({ - workspaceId - }) - ); - - // get project bot - setBot(await getBot({ workspaceId })); - } catch (err) { - console.error(err); - } - })(); - }, []); - - /** - * 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 }); - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); - - if (!PRIVATE_KEY) { - throw new Error("Private Key missing"); - } - - 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: true, - botKey - }) - ).bot - ); - } - } catch (err) { - console.error(err); - } - }; - - const handleUnauthorizedIntegrationOptionPress = (integrationOption: IntegrationOption) => { - try { - // generate CSRF token for OAuth2 code-token exchange integrations - const state = crypto.randomBytes(16).toString("hex"); - localStorage.setItem("latestCSRFToken", state); - - let link = ""; - switch (integrationOption.slug) { - case "azure-key-vault": - link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; - break; - case "aws-parameter-store": - link = `${window.location.origin}/integrations/aws-parameter-store/authorize`; - break; - case "aws-secret-manager": - link = `${window.location.origin}/integrations/aws-secret-manager/authorize`; - break; - case "heroku": - link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; - break; - case "vercel": - link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; - break; - case "netlify": - link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; - break; - case "github": - link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; - break; - case "gitlab": - link = `https://gitlab.com/oauth/authorize?client_id=${integrationOption.clientId}&redirect_uri=${window.location.origin}/integrations/gitlab/oauth2/callback&response_type=code&state=${state}`; - break; - case "render": - link = `${window.location.origin}/integrations/render/authorize`; - break; - case "flyio": - link = `${window.location.origin}/integrations/flyio/authorize`; - break; - case "circleci": - link = `${window.location.origin}/integrations/circleci/authorize`; - break; - case "travisci": - link = `${window.location.origin}/integrations/travisci/authorize`; - break; - case "supabase": - link = `${window.location.origin}/integrations/supabase/authorize`; - break; - case "checkly": - link = `${window.location.origin}/integrations/checkly/authorize`; - break; - case "railway": - link = `${window.location.origin}/integrations/railway/authorize`; - break; - case "hashicorp-vault": - link = `${window.location.origin}/integrations/hashicorp-vault/authorize`; - break; - case "cloudflare-pages": - link = `${window.location.origin}/integrations/cloudflare-pages/authorize`; - break; - default: - break; - } - - if (link !== "") { - window.location.assign(link); - } - } catch (err) { - console.error(err); - } - }; - - const handleAuthorizedIntegrationOptionPress = (integrationAuth: IntegrationAuth) => { - try { - let link = ""; - switch (integrationAuth.integration) { - case "azure-key-vault": - link = `${window.location.origin}/integrations/azure-key-vault/create?integrationAuthId=${integrationAuth._id}`; - break; - case "aws-parameter-store": - link = `${window.location.origin}/integrations/aws-parameter-store/create?integrationAuthId=${integrationAuth._id}`; - break; - case "aws-secret-manager": - link = `${window.location.origin}/integrations/aws-secret-manager/create?integrationAuthId=${integrationAuth._id}`; - break; - case "heroku": - link = `${window.location.origin}/integrations/heroku/create?integrationAuthId=${integrationAuth._id}`; - break; - case "vercel": - link = `${window.location.origin}/integrations/vercel/create?integrationAuthId=${integrationAuth._id}`; - break; - case "netlify": - link = `${window.location.origin}/integrations/netlify/create?integrationAuthId=${integrationAuth._id}`; - break; - case "github": - link = `${window.location.origin}/integrations/github/create?integrationAuthId=${integrationAuth._id}`; - break; - case "gitlab": - link = `${window.location.origin}/integrations/gitlab/create?integrationAuthId=${integrationAuth._id}`; - break; - case "render": - link = `${window.location.origin}/integrations/render/create?integrationAuthId=${integrationAuth._id}`; - break; - case "flyio": - link = `${window.location.origin}/integrations/flyio/create?integrationAuthId=${integrationAuth._id}`; - break; - case "circleci": - link = `${window.location.origin}/integrations/circleci/create?integrationAuthId=${integrationAuth._id}`; - break; - case "travisci": - link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`; - break; - case "supabase": - link = `${window.location.origin}/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`; - break; - case "checkly": - link = `${window.location.origin}/integrations/checkly/create?integrationAuthId=${integrationAuth._id}`; - break; - case "railway": - link = `${window.location.origin}/integrations/railway/create?integrationAuthId=${integrationAuth._id}`; - break; - case "hashicorp-vault": - link = `${window.location.origin}/integrations/hashicorp-vault/create?integrationAuthId=${integrationAuth._id}`; - break; - case "cloudflare-pages": - link = `${window.location.origin}/integrations/cloudflare-pages/create?integrationAuthId=${integrationAuth._id}`; - break; - default: - break; - } - - if (link !== "") { - window.location.assign(link); - } - } catch (err) { - console.error(err); - } - }; - - /** - * Open dialog to activate bot if bot is not active. - * 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 = async (integrationOption: IntegrationOption) => { - try { - const integrationAuthX = integrationAuths.find( - (integrationAuth) => integrationAuth.integration === integrationOption.slug - ); - - if (!bot.isActive) { - await handleBotActivate(); - } - - if (!integrationAuthX) { - // case: integration has not been authorized - handleUnauthorizedIntegrationOptionPress(integrationOption); - return; - } - - handleAuthorizedIntegrationOptionPress(integrationAuthX); - } catch (err) { - console.error(err); - } - }; - - /** - * Handle deleting integration authorization [integrationAuth] and corresponding integrations from state where applicable - * @param {Object} obj - * @param {IntegrationAuth} obj.integrationAuth - integrationAuth to delete - */ - const handleDeleteIntegrationAuth = async ({ - integrationAuth: deletedIntegrationAuth - }: { - integrationAuth: IntegrationAuth; - }) => { - try { - const newIntegrations = integrations.filter( - (integration) => integration.integrationAuth !== deletedIntegrationAuth._id - ); - setIntegrationAuths( - integrationAuths.filter( - (integrationAuth) => integrationAuth._id !== deletedIntegrationAuth._id - ) - ); - setIntegrations(newIntegrations); - - // handle updating bot - if (newIntegrations.length < 1) { - // case: no integrations left - setBot( - ( - await setBotActiveStatus({ - botId: bot._id, - isActive: false - }) - ).bot - ); - } - } catch (err) { - console.error(err); - } - }; - - /** - * Handle deleting integration [integration] - * @param {Object} obj - * @param {Integration} obj.integration - integration to delete - */ - const handleDeleteIntegration = async ({ integration }: { integration: Integration }) => { - try { - const deletedIntegration = await deleteIntegration({ - integrationId: integration._id - }); - - const newIntegrations = integrations.filter((i) => i._id !== deletedIntegration._id); - setIntegrations(newIntegrations); - - // handle updating bot - if (newIntegrations.length < 1) { - // case: no integrations left - setBot( - ( - await setBotActiveStatus({ - botId: bot._id, - isActive: false - }) - ).bot - ); - } - } catch (err) { - console.error(err); - } - }; - return ( -
+ <> {t("common.head-title", { title: t("integrations.title") })} @@ -411,52 +20,26 @@ export default function Integrations() { -
- - setIsActivateBotDialogOpen(false)} - selectedIntegrationOption={selectedIntegrationOption} - integrationOptionPress={integrationOptionPress} - /> - - {cloudIntegrationOptions.length > 0 && bot ? ( - { - if (!bot.isActive) { - // case: bot is not active -> open modal to activate bot - setIsActivateBotDialogOpen(true); - return; - } - integrationOptionPress(integrationOption); - }} - integrationAuths={integrationAuths} - handleDeleteIntegrationAuth={handleDeleteIntegrationAuth} - /> - ) : ( - <> -
-

{t("integrations.cloud-integrations")}

-

{t("integrations.click-to-start")}

-
-
- {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].map(elem =>
)} -
- - )} - -
-
+ + ); -} +}; -Integrations.requireAuth = true; +export const getStaticProps = () => { + return { + props: { + frameworkIntegrations: frameworkIntegrationOptions + } + }; +}; + +export const getStaticPaths = async () => { + return { + paths: [], // indicates that no page needs be created at build time + fallback: "blocking" // indicates the type of fallback + }; +}; + +Integration.requireAuth = true; + +export default Integration; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx new file mode 100644 index 000000000..3a8c3e130 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -0,0 +1,102 @@ +import crypto from 'crypto'; + +import { UserWsKeyPair, TCloudIntegration } from '@app/hooks/api/types'; + +import { + decryptAssymmetric, + encryptAssymmetric +} from '../../components/utilities/cryptography/crypto'; + +export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) => { + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + + if (!PRIVATE_KEY) { + throw new Error('Private Key missing'); + } + + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: botPublicKey, + privateKey: PRIVATE_KEY + }); + + return { encryptedKey: ciphertext, nonce }; +}; + +export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => { + try { + // generate CSRF token for OAuth2 code-token exchange integrations + const state = crypto.randomBytes(16).toString('hex'); + localStorage.setItem('latestCSRFToken', state); + + let link = ''; + switch (integrationOption.slug) { + case 'azure-key-vault': + link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`; + break; + case 'aws-parameter-store': + link = `${window.location.origin}/integrations/aws-parameter-store/authorize`; + break; + case 'aws-secret-manager': + link = `${window.location.origin}/integrations/aws-secret-manager/authorize`; + break; + case 'heroku': + link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; + break; + case 'vercel': + link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; + break; + case 'netlify': + link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; + break; + case 'github': + link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; + break; + case 'gitlab': + link = `https://gitlab.com/oauth/authorize?client_id=${integrationOption.clientId}&redirect_uri=${window.location.origin}/integrations/gitlab/oauth2/callback&response_type=code&state=${state}`; + break; + case 'render': + link = `${window.location.origin}/integrations/render/authorize`; + break; + case 'flyio': + link = `${window.location.origin}/integrations/flyio/authorize`; + break; + case 'circleci': + link = `${window.location.origin}/integrations/circleci/authorize`; + break; + case 'travisci': + link = `${window.location.origin}/integrations/travisci/authorize`; + break; + case 'supabase': + link = `${window.location.origin}/integrations/supabase/authorize`; + break; + case 'checkly': + link = `${window.location.origin}/integrations/checkly/authorize`; + break; + case 'railway': + link = `${window.location.origin}/integrations/railway/authorize`; + break; + case 'hashicorp-vault': + link = `${window.location.origin}/integrations/hashicorp-vault/authorize`; + break; + default: + break; + } + + if (link !== '') { + window.location.assign(link); + } + } catch (err) { + console.error(err); + } +}; + +export const redirectToIntegrationAppConfigScreen = (provider: string, integrationAuthId: string) => + `/integrations/${provider}/create?integrationAuthId=${integrationAuthId}`; diff --git a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx new file mode 100644 index 000000000..e90cabf13 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useRouter } from 'next/router'; + +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; +import NavHeader from '@app/components/navigation/NavHeader'; +import { Button,Modal, ModalContent } from '@app/components/v2'; +import { useWorkspace } from '@app/context'; +import { usePopUp } from '@app/hooks'; +import { + useDeleteIntegration, + useDeleteIntegrationAuth, + useGetCloudIntegrations, + useGetUserWsKey, + useGetWorkspaceAuthorizations, + useGetWorkspaceBot, + useGetWorkspaceIntegrations, + useUpdateBotActiveStatus} from '@app/hooks/api'; +import { IntegrationAuth } from '@app/hooks/api/types'; + +import { CloudIntegrationSection } from './components/CloudIntegrationSection'; +import { FrameworkIntegrationSection } from './components/FrameworkIntegrationSection'; +import { IntegrationsSection } from './components/IntegrationsSection'; +import { + generateBotKey, + redirectForProviderAuth, + redirectToIntegrationAppConfigScreen +} from './IntegrationPage.utils'; + +type Props = { + frameworkIntegrations: Array<{ name: string; slug: string; image: string; docsLink: string }>; +}; + +export const IntegrationsPage = ({ frameworkIntegrations }: Props) => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const router = useRouter(); + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ''; + const environments = currentWorkspace?.environments || []; + + const { data: latestWsKey } = useGetUserWsKey(workspaceId); + + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + 'activeBot', + 'revokeProviderPermissionConf', + 'removeIntegrationConf' + ] as const); + + const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } = + useGetCloudIntegrations(); + const { data: integrationAuths, isLoading: isIntegrationAuthLoading } = + useGetWorkspaceAuthorizations( + workspaceId, + useCallback((data: IntegrationAuth[]) => { + const groupBy: Record = {}; + data.forEach((el) => { + groupBy[el.integration] = el; + }); + return groupBy; + }, []) + ); + // mutation + const { + data: integrations, + isLoading: isIntegrationLoading, + isFetching: isIntegrationFetching + } = useGetWorkspaceIntegrations(workspaceId); + + const { data: bot } = useGetWorkspaceBot(workspaceId); + + // mutation + const { mutateAsync: updateBotActiveStatus, mutate: updateBotActiveStatusSync } = + useUpdateBotActiveStatus(); + const { mutateAsync: deleteIntegration } = useDeleteIntegration(); + const { + mutateAsync: deleteIntegrationAuth, + isLoading: isDeleteIntegrationAuthSuccess, + reset: resetDeleteIntegrationAuth + } = useDeleteIntegrationAuth(); + + // summary: this use effect is trigger when all integration auths are removed thus deactivate bot + // details: so onsuccessfully deleting an integration auth, immediately integration list is refeteched + // After the refetch is completed check if its empty. Then set bot active and reset the submit hook + useEffect(() => { + if (isDeleteIntegrationAuthSuccess && !isIntegrationFetching && !integrations?.length) { + if (bot?._id) + updateBotActiveStatusSync({ + isActive: false, + botId: bot._id, + workspaceId + }); + resetDeleteIntegrationAuth(); + } + }, [isIntegrationFetching, isDeleteIntegrationAuthSuccess, integrations?.length]); + + const handleProviderIntegration = async (provider: string) => { + const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); + if (!selectedCloudIntegration) return; + + try { + if (bot && !bot.isActive) { + const botKey = generateBotKey(bot.publicKey, latestWsKey!); + await updateBotActiveStatus({ + workspaceId, + botKey, + isActive: true, + botId: bot._id + }); + } + const integrationAuthForProvider = integrationAuths?.[provider]; + if (!integrationAuthForProvider) { + redirectForProviderAuth(selectedCloudIntegration); + return; + } + + const url = redirectToIntegrationAppConfigScreen(provider, integrationAuthForProvider._id); + router.push(url); + } catch (error) { + console.error(error); + } + }; + + // function to strat integration for a provider + // confirmation to user passing the bot key for provider to get secret access + const handleProviderIntegrationStart = (provider: string) => { + if (!bot?.isActive) { + handlePopUpOpen('activeBot', { provider }); + return; + } + handleProviderIntegration(provider); + }; + + const handleUserAcceptBotCondition = () => { + const { provider } = popUp.activeBot?.data as { provider: string }; + handleProviderIntegration(provider); + handlePopUpClose('activeBot'); + }; + + const handleIntegrationDelete = async (integrationId: string, cb: () => void) => { + try { + await deleteIntegration({ id: integrationId, workspaceId }); + if (cb) cb(); + createNotification({ + type: 'success', + text: 'Deleted integration' + }); + } catch (err) { + console.log(err); + createNotification({ + type: 'error', + text: 'Failed to delete integration' + }); + } + }; + + const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => { + const integrationAuthForProvider = integrationAuths?.[provider]; + if (!integrationAuthForProvider) return; + try { + await deleteIntegrationAuth({ + id: integrationAuthForProvider._id, + workspaceId + }); + if (cb) cb(); + createNotification({ + type: 'success', + text: 'Revoked provider authentication' + }); + } catch (err) { + console.error(err); + createNotification({ + type: 'error', + text: 'Failed to revoke provider authentication' + }); + } + }; + + return ( +
+ + handleIntegrationDelete(id, cb)} + /> + + handlePopUpToggle('activeBot', isOpen)} + > + + + +
+ } + > + {t('integrations.why-infisical-needs-access')} + + + +
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx new file mode 100644 index 000000000..5a41f9984 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx @@ -0,0 +1,140 @@ +import { useTranslation } from 'react-i18next'; +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { DeleteActionModal,Skeleton, Tooltip } from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { IntegrationAuth, TCloudIntegration } from '@app/hooks/api/types'; + +type Props = { + isLoading?: boolean; + integrationAuths?: Record; + cloudIntegrations?: TCloudIntegration[]; + onIntegrationStart: (slug: string) => void; + // cb: handle popUpClose child->parent communication pattern + onIntegrationRevoke: (slug: string, cb: () => void) => void; +}; + +type TRevokeIntegrationPopUp = { provider: string }; + +export const CloudIntegrationSection = ({ + isLoading, + cloudIntegrations = [], + integrationAuths = {}, + onIntegrationStart, + onIntegrationRevoke +}: Props) => { + const { t } = useTranslation(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + 'deleteConfirmation' + ] as const); + + const isEmpty = !isLoading && !cloudIntegrations?.length; + + return ( +
+
+

{t('integrations.cloud-integrations')}

+

{t('integrations.click-to-start')}

+
+
+ {isLoading && + Array.from({ length: 12 }).map((_, index) => ( + + ))} + {!isLoading && + cloudIntegrations?.map((cloudIntegration) => ( +
null} + role="button" + tabIndex={0} + className={`group relative ${ + cloudIntegration.isAvailable + ? 'cursor-pointer duration-200 hover:bg-mineshaft-700' + : 'opacity-50' + } flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`} + onClick={() => { + if (!cloudIntegration.isAvailable) return; + onIntegrationStart(cloudIntegration.slug); + }} + key={cloudIntegration.slug} + > + integration logo + {cloudIntegration.name.split(' ').length > 2 ? ( +
+
{cloudIntegration.name.split(' ')[0]}
+
+ {cloudIntegration.name.split(' ')[1]} {cloudIntegration.name.split(' ')[2]} +
+
+ ) : ( +
+ {cloudIntegration.name} +
+ )} + {cloudIntegration.isAvailable && + Boolean(integrationAuths?.[cloudIntegration.slug]) && ( +
+
+
+ + Authorized +
+ +
null} + role="button" + tabIndex={0} + onClick={async (event) => { + event.stopPropagation(); + handlePopUpOpen('deleteConfirmation', { + provider: cloudIntegration.slug + }); + }} + className="absolute top-0 right-0 flex h-0 w-12 cursor-pointer items-center justify-center overflow-hidden rounded-r-md bg-red text-xs opacity-50 transition-all duration-300 hover:opacity-100 group-hover:h-full" + > + +
+
+
+
+ )} +
+ ))} +
+ {isEmpty && ( +
+ {Array.from({ length: 16 }).map((_, index) => ( +
+ ))} +
+ )} + handlePopUpToggle('deleteConfirmation', isOpen)} + deleteKey={(popUp?.deleteConfirmation?.data as TRevokeIntegrationPopUp)?.provider || ''} + onDeleteApproved={async () => { + onIntegrationRevoke( + (popUp.deleteConfirmation.data as TRevokeIntegrationPopUp)?.provider, + () => handlePopUpClose('deleteConfirmation') + ); + }} + /> +
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/index.tsx b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/index.tsx new file mode 100644 index 000000000..ffb6c738d --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/index.tsx @@ -0,0 +1 @@ +export { CloudIntegrationSection } from './CloudIntegrationSection'; diff --git a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx new file mode 100644 index 000000000..c51b2d3ee --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; + +type Props = { + frameworks: Array<{ + name: string; + image: string; + slug: string; + docsLink: string; + }>; +}; + +export const FrameworkIntegrationSection = ({ frameworks }: Props) => { + const { t } = useTranslation(); + + return ( + <> +
+

{t('integrations.framework-integrations')}

+

{t('integrations.click-to-setup')}

+
+
+ {frameworks.map((framework) => ( + + + + ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/index.tsx b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/index.tsx new file mode 100644 index 000000000..04bbda30b --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/index.tsx @@ -0,0 +1 @@ +export { FrameworkIntegrationSection } from './FrameworkIntegrationSection'; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx new file mode 100644 index 000000000..c38242990 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -0,0 +1,145 @@ +import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { integrationSlugNameMapping } from 'public/data/frequentConstants'; + +import { + DeleteActionModal, + EmptyState, + FormControl, + FormLabel, + IconButton, + Select, + SelectItem, + Skeleton +} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { TIntegration } from '@app/hooks/api/types'; + +type Props = { + environments: Array<{ name: string; slug: string }>; + integrations?: TIntegration[]; + isLoading?: boolean; + onIntegrationDelete: (integration: TIntegration, cb: () => void) => void; +}; + +export const IntegrationsSection = ({ + integrations = [], + environments = [], + isLoading, + onIntegrationDelete +}: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + 'deleteConfirmation' + ] as const); + + return ( +
+
+

Current Integrations

+

Manage integrations with third-party services.

+
+ {isLoading && ( +
+ +
+ )} + {!isLoading && !integrations.length && ( + + )} + {!isLoading && ( +
+ {integrations?.map((integration) => ( +
+
+
+ + + +
+
+ +
+ {integration.secretPath} +
+
+
+ +
+
+ +
+ {integrationSlugNameMapping[integration.integration]} +
+
+
+ +
+ {integration.integration === 'hashicorp-vault' + ? `${integration.app} - path: ${integration.path}` + : integration.app} +
+
+ {(integration.integration === 'vercel' || + integration.integration === 'netlify' || + integration.integration === 'railway' || + integration.integration === 'gitlab') && ( +
+ +
+ {integration.targetEnvironment} +
+
+ )} +
+
+
+ handlePopUpOpen('deleteConfirmation', integration)} + ariaLabel="delete" + colorSchema="danger" + > + + +
+
+
+ ))} +
+ )} + handlePopUpToggle('deleteConfirmation', isOpen)} + deleteKey={(popUp?.deleteConfirmation?.data as TIntegration)?.app || ''} + onDeleteApproved={async () => + onIntegrationDelete(popUp?.deleteConfirmation.data as TIntegration, () => + handlePopUpClose('deleteConfirmation') + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/index.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/index.tsx new file mode 100644 index 000000000..a171edb8a --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/index.tsx @@ -0,0 +1 @@ +export { IntegrationsSection } from './IntegrationsSection'; diff --git a/frontend/src/views/IntegrationsPage/index.tsx b/frontend/src/views/IntegrationsPage/index.tsx new file mode 100644 index 000000000..5cef86ea2 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/index.tsx @@ -0,0 +1 @@ +export { IntegrationsPage } from './IntegrationsPage';