diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx index 9d5774d5dd..efc68ef53e 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -1,7 +1,5 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { faWarning } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -14,7 +12,6 @@ import { useProject } from "@app/context"; import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; -import { useGetWorkspaceIntegrations } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/projects/types"; import { IntegrationsListPageTabs } from "@app/types/integrations"; @@ -35,9 +32,6 @@ export const IntegrationsListPage = () => { from: ROUTE_PATHS.SecretManager.IntegrationsListPage.id }); - const { data: integrations } = useGetWorkspaceIntegrations(currentProject.id); - const hasNativeIntegrations = Boolean(integrations?.length); - const updateSelectedTab = (tab: string) => { navigate({ to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path, @@ -77,11 +71,9 @@ export const IntegrationsListPage = () => { Infrastructure Integrations - {hasNativeIntegrations && ( - - Native Integrations - - )} + + Native Integrations + { - {hasNativeIntegrations && ( - -
-
- -
-

- We're moving Native Integrations to{" "} - - Secret Syncs - - . -

-

- If the integration you need isn't available in the Secret Syncs menu, - please get in touch with us at{" "} - - team@infisical.com - - . -

-
-
-
- - - -
- )} + + + + + diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.utils.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.utils.tsx index bb9d02d3da..9332023f56 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.utils.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/IntegrationsListPage.utils.tsx @@ -1,4 +1,10 @@ +import crypto from "crypto"; + +import { NavigateFn } from "@tanstack/react-router"; + import { createNotification } from "@app/components/notifications"; +import { localStorageService } from "@app/helpers/localStorage"; +import { TCloudIntegration } from "@app/hooks/api/types"; export const createIntegrationMissingEnvVarsNotification = ( slug: string, @@ -21,3 +27,349 @@ export const createIntegrationMissingEnvVarsNotification = ( ), title: "Missing Environment Variables" }); + +export const redirectForProviderAuth = ( + orgId: string, + projectId: string, + navigate: NavigateFn, + integrationOption: TCloudIntegration +) => { + try { + // generate CSRF token for OAuth2 code-token exchange integrations + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + localStorageService.setIntegrationProjectId(projectId); + + switch (integrationOption.slug) { + case "gcp-secret-manager": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/gcp-secret-manager/authorize", + params: { + orgId, + projectId + } + }); + break; + case "azure-key-vault": { + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/azure-key-vault/authorize", + params: { + orgId, + projectId + }, + search: { + clientId: integrationOption.clientId, + state + } + }); + break; + } + case "azure-app-configuration": { + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`; + window.location.assign(link); + break; + } + case "aws-parameter-store": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/aws-parameter-store/authorize", + params: { + orgId, + projectId + } + }); + break; + case "aws-secret-manager": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/aws-secret-manager/authorize", + params: { + orgId, + projectId + } + }); + break; + case "heroku": { + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; + window.location.assign(link); + break; + } + case "vercel": { + if (!integrationOption.clientSlug) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; + window.location.assign(link); + break; + } + case "netlify": { + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; + + window.location.assign(link); + break; + } + case "github": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/github/auth-mode-selection", + params: { + orgId, + projectId + } + }); + break; + case "gitlab": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/gitlab/authorize", + params: { + orgId, + projectId + } + }); + break; + case "render": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/render/authorize", + params: { + orgId, + projectId + } + }); + break; + case "flyio": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/flyio/authorize", + params: { + orgId, + projectId + } + }); + break; + case "circleci": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/circleci/authorize", + params: { + orgId, + projectId + } + }); + break; + case "databricks": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/databricks/authorize", + params: { + orgId, + projectId + } + }); + break; + case "laravel-forge": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/laravel-forge/authorize", + params: { + orgId, + projectId + } + }); + break; + case "travisci": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/travisci/authorize", + params: { + orgId, + projectId + } + }); + break; + case "supabase": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/supabase/authorize", + params: { + orgId, + projectId + } + }); + break; + case "checkly": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/checkly/authorize", + params: { + orgId, + projectId + } + }); + break; + case "qovery": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/qovery/authorize", + params: { + orgId, + projectId + } + }); + break; + case "railway": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/railway/authorize", + params: { + orgId, + projectId + } + }); + break; + case "terraform-cloud": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/terraform-cloud/authorize", + params: { + orgId, + projectId + } + }); + break; + case "hashicorp-vault": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/hashicorp-vault/authorize", + params: { + orgId, + projectId + } + }); + break; + case "cloudflare-pages": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/cloudflare-pages/authorize", + params: { + orgId, + projectId + } + }); + break; + case "cloudflare-workers": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/cloudflare-workers/authorize", + params: { + orgId, + projectId + } + }); + break; + case "bitbucket": { + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } + const link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`; + window.location.assign(link); + break; + } + case "codefresh": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/codefresh/authorize", + params: { + orgId, + projectId + } + }); + break; + case "digital-ocean-app-platform": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/digital-ocean-app-platform/authorize", + params: { + orgId, + projectId + } + }); + break; + case "cloud-66": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/cloud-66/authorize", + params: { + orgId, + projectId + } + }); + break; + case "northflank": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/northflank/authorize", + params: { + orgId, + projectId + } + }); + break; + case "windmill": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/windmill/authorize", + params: { + orgId, + projectId + } + }); + break; + case "teamcity": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/teamcity/authorize", + params: { + orgId, + projectId + } + }); + break; + case "hasura-cloud": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/hasura-cloud/authorize", + params: { + orgId, + projectId + } + }); + break; + case "rundeck": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/rundeck/authorize", + params: { + orgId, + projectId + } + }); + break; + case "azure-devops": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/azure-devops/authorize", + params: { + orgId, + projectId + } + }); + break; + case "octopus-deploy": + navigate({ + to: "/organizations/$orgId/projects/secret-management/$projectId/integrations/octopus-deploy/authorize", + params: { + orgId, + projectId + } + }); + break; + default: + break; + } + } catch (err) { + console.error(err); + } +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx new file mode 100644 index 0000000000..ff92f9f413 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx @@ -0,0 +1,258 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + faCheck, + faChevronLeft, + faMagnifyingGlass, + faSearch, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + DeleteActionModal, + EmptyState, + Input, + Skeleton, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useProject, + useProjectPermission +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { IntegrationAuth, TCloudIntegration } from "@app/hooks/api/types"; +import { IntegrationsListPageTabs } from "@app/types/integrations"; + +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; + onViewActiveIntegrations?: () => void; +}; + +type TRevokeIntegrationPopUp = { provider: string }; + +const SECRET_SYNCS = Object.values(SecretSync) as string[]; +const isSecretSyncAvailable = (type: string) => SECRET_SYNCS.includes(type); + +export const CloudIntegrationSection = ({ + isLoading, + cloudIntegrations = [], + integrationAuths = {}, + onIntegrationStart, + onIntegrationRevoke, + onViewActiveIntegrations +}: Props) => { + const { t } = useTranslation(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "deleteConfirmation" + ] as const); + const { permission } = useProjectPermission(); + const { currentOrg } = useOrganization(); + const { currentProject } = useProject(); + const navigate = useNavigate(); + + const isEmpty = !isLoading && !cloudIntegrations?.length; + + const sortedCloudIntegrations = useMemo(() => { + const sortedIntegrations = cloudIntegrations.sort((a, b) => a.name.localeCompare(b.name)); + + if (currentProject?.environments.length === 0) { + return sortedIntegrations.map((integration) => ({ ...integration, isAvailable: false })); + } + + return sortedIntegrations; + }, [cloudIntegrations, currentProject?.environments]); + + const [search, setSearch] = useState(""); + + const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) => + cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim()) + ); + + return ( +
+ {currentProject?.environments.length === 0 && ( +
+ +
+ )} +
+ {onViewActiveIntegrations && ( + + )} +
+
+

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

+

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

+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search cloud integrations..." + containerClassName="flex-1 h-min text-base" + /> +
+
+
+ {isLoading && + Array.from({ length: 12 }).map((_, index) => ( + + ))} + + {!isLoading && filteredIntegrations.length ? ( + filteredIntegrations.map((cloudIntegration) => { + const syncSlug = cloudIntegration.syncSlug ?? cloudIntegration.slug; + const isSyncAvailable = isSecretSyncAvailable(syncSlug); + + return ( +
null} + role="button" + tabIndex={0} + className={`group relative ${ + cloudIntegration.isAvailable + ? "cursor-pointer duration-200 hover:bg-mineshaft-700" + : "opacity-50" + } flex h-36 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3`} + onClick={() => { + if (isSyncAvailable) { + navigate({ + to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path, + params: { + orgId: currentOrg.id, + projectId: currentProject.id + }, + search: { + selectedTab: IntegrationsListPageTabs.SecretSyncs, + addSync: syncSlug as SecretSync + } + }); + return; + } + if (!cloudIntegration.isAvailable) return; + if ( + permission.cannot( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ) + ) { + createNotification({ + type: "error", + text: "You do not have permission to create an integration" + }); + return; + } + onIntegrationStart(cloudIntegration.slug); + }} + key={cloudIntegration.slug} + > +
+ integration logo +
+ {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 group-hover:h-full hover:opacity-100" + > + +
+
+
+
+ )} + {isSyncAvailable && ( +
+
+
+ Secret Sync Available +
+
+
+ )} +
+ ); + }) + ) : ( + + )} +
+ {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/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/index.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/index.tsx new file mode 100644 index 0000000000..62f7a006c4 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/CloudIntegrationSection/index.tsx @@ -0,0 +1 @@ +export { CloudIntegrationSection } from "./CloudIntegrationSection"; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx index b51c06df42..9670278578 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/NativeIntegrationsTab/NativeIntegrationsTab.tsx @@ -1,8 +1,11 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; import { createNotification } from "@app/components/notifications"; -import { Checkbox, DeleteActionModal, Spinner } from "@app/components/v2"; -import { useProject } from "@app/context"; +import { Button, Checkbox, DeleteActionModal, Spinner } from "@app/components/v2"; +import { useOrganization, useProject } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useDeleteIntegration, @@ -14,26 +17,38 @@ import { import { IntegrationAuth } from "@app/hooks/api/integrationAuth/types"; import { TIntegration } from "@app/hooks/api/integrations/types"; +import { redirectForProviderAuth } from "../../IntegrationsListPage.utils"; +import { CloudIntegrationSection } from "../CloudIntegrationSection"; import { IntegrationsTable } from "./IntegrationsTable"; +enum IntegrationView { + List = "list", + New = "new" +} + export const NativeIntegrationsTab = () => { + const { currentOrg } = useOrganization(); const { currentProject } = useProject(); const { environments, id: workspaceId } = currentProject; + const navigate = useNavigate(); const { data: cloudIntegrations, isPending: isCloudIntegrationsLoading } = useGetCloudIntegrations(); - const { data: integrationAuths, isFetching: isIntegrationAuthFetching } = - useGetWorkspaceAuthorizations( - workspaceId, - useCallback((data: IntegrationAuth[]) => { - const groupBy: Record = {}; - data.forEach((el) => { - groupBy[el.integration] = el; - }); - return groupBy; - }, []) - ); + const { + data: integrationAuths, + isPending: isIntegrationAuthLoading, + isFetching: isIntegrationAuthFetching + } = useGetWorkspaceAuthorizations( + workspaceId, + useCallback((data: IntegrationAuth[]) => { + const groupBy: Record = {}; + data.forEach((el) => { + groupBy[el.integration] = el; + }); + return groupBy; + }, []) + ); // mutation const { @@ -43,8 +58,11 @@ export const NativeIntegrationsTab = () => { } = useGetWorkspaceIntegrations(workspaceId); const { mutateAsync: deleteIntegration } = useDeleteIntegration(); - - const { reset: resetDeleteIntegrationAuths } = useDeleteIntegrationAuths(); + const { + mutateAsync: deleteIntegrationAuths, + isSuccess: isDeleteIntegrationAuthSuccess, + reset: resetDeleteIntegrationAuths + } = useDeleteIntegrationAuths(); const isIntegrationsAuthorizedEmpty = !Object.keys(integrationAuths || {}).length; const isIntegrationsEmpty = !integrations?.length; @@ -53,6 +71,7 @@ export const NativeIntegrationsTab = () => { // After the refetch is completed check if its empty. Then set bot active and reset the submit hook for isSuccess to go back to false useEffect(() => { if ( + isDeleteIntegrationAuthSuccess && !isIntegrationFetching && !isIntegrationAuthFetching && isIntegrationsAuthorizedEmpty && @@ -62,11 +81,29 @@ export const NativeIntegrationsTab = () => { } }, [ isIntegrationFetching, + isDeleteIntegrationAuthSuccess, isIntegrationAuthFetching, isIntegrationsAuthorizedEmpty, isIntegrationsEmpty ]); + const handleProviderIntegration = async (provider: string) => { + const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); + if (!selectedCloudIntegration) return; + + try { + redirectForProviderAuth(currentOrg.id, currentProject.id, navigate, selectedCloudIntegration); + } 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) => { + handleProviderIntegration(provider); + }; + const handleIntegrationDelete = async ( integrationId: string, shouldDeleteIntegrationSecrets: boolean, @@ -80,11 +117,28 @@ export const NativeIntegrationsTab = () => { }); }; + const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => { + const integrationAuthForProvider = integrationAuths?.[provider]; + if (!integrationAuthForProvider) return; + + await deleteIntegrationAuths({ + integration: provider, + workspaceId + }); + if (cb) cb(); + createNotification({ + type: "success", + text: "Revoked provider authentication" + }); + }; + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteConfirmation", "deleteSecretsConfirmation" ] as const); + const [view, setView] = useState(IntegrationView.List); + const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); if (isIntegrationLoading || isCloudIntegrationsLoading) @@ -96,10 +150,18 @@ export const NativeIntegrationsTab = () => { return ( <> - {integrations?.length && ( + {view === IntegrationView.List ? (

Native Integrations

+
{ }} />
+ ) : ( + setView(IntegrationView.List)} + /> )}