diff --git a/frontend/public/images/integrations/GitHub.png b/frontend/public/images/integrations/GitHub.png index 9490ffc6d..7492fcb54 100644 Binary files a/frontend/public/images/integrations/GitHub.png and b/frontend/public/images/integrations/GitHub.png differ diff --git a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx index ad82a218c..cea246f2f 100644 --- a/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx +++ b/frontend/src/pages/integrations/gcp-secret-manager/authorize.tsx @@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2"; @@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() { const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } + const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`; window.location.assign(link); }; diff --git a/frontend/src/pages/integrations/github/auth-mode-selection.tsx b/frontend/src/pages/integrations/github/auth-mode-selection.tsx index 5fcb4f0dd..4a512ff60 100644 --- a/frontend/src/pages/integrations/github/auth-mode-selection.tsx +++ b/frontend/src/pages/integrations/github/auth-mode-selection.tsx @@ -18,6 +18,7 @@ import { SelectItem } from "@app/components/v2"; import { useGetCloudIntegrations } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; enum AuthMethod { APP = "APP", @@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() { if (selectedAuthMethod === AuthMethod.APP) { router.push("/integrations/select-integration-auth?integrationSlug=github"); } else { + if (!githubIntegration?.clientId) { + createIntegrationMissingEnvVarsNotification( + "githubactions", + "cicd", + "connecting-with-github-oauth" + ); + return; + } + const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); diff --git a/frontend/src/pages/integrations/gitlab/authorize.tsx b/frontend/src/pages/integrations/gitlab/authorize.tsx index 380aad08e..d6c80c2c1 100644 --- a/frontend/src/pages/integrations/gitlab/authorize.tsx +++ b/frontend/src/pages/integrations/gitlab/authorize.tsx @@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useGetCloudIntegrations } from "@app/hooks/api"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; @@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() { if (!integrationOption) return; + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } + const baseURL = (gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim(); diff --git a/frontend/src/pages/integrations/select-integration-auth.tsx b/frontend/src/pages/integrations/select-integration-auth.tsx index a9d2766a4..1f9f17afa 100644 --- a/frontend/src/pages/integrations/select-integration-auth.tsx +++ b/frontend/src/pages/integrations/select-integration-auth.tsx @@ -13,6 +13,7 @@ import { useGetOrgIntegrationAuths } from "@app/hooks/api"; import { IntegrationAuth } from "@app/hooks/api/types"; +import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils"; export default function SelectIntegrationAuthPage() { const router = useRouter(); @@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() { localStorage.setItem("latestCSRFToken", state); if (integrationSlug === "github") { + if (!currentIntegration?.clientSlug) { + createIntegrationMissingEnvVarsNotification("githubactions", "cicd"); + return; + } + // for now we only handle Github apps window.location.assign( `https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}` diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index b20b15a09..e1a1ff6fb 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -1,5 +1,6 @@ import crypto from "crypto"; +import { createNotification } from "@app/components/notifications"; import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types"; import { @@ -30,6 +31,28 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) = return { encryptedKey: ciphertext, nonce }; }; +export const createIntegrationMissingEnvVarsNotification = ( + slug: string, + type: "cloud" | "cicd" = "cloud", + hashtag?: string +) => + createNotification({ + type: "error", + text: ( + + Click here to view docs + + ), + title: "Missing Environment Variables" + }); + export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => { try { // generate CSRF token for OAuth2 code-token exchange integrations @@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`; break; case "azure-key-vault": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } 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 "azure-app-configuration": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } 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}`; break; case "aws-parameter-store": @@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/aws-secret-manager/authorize`; break; case "heroku": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; break; case "vercel": + if (!integrationOption.clientSlug) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; break; case "netlify": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug); + return; + } 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": @@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `${window.location.origin}/integrations/cloudflare-workers/authorize`; break; case "bitbucket": + if (!integrationOption.clientId) { + createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd"); + return; + } 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}`; break; case "codefresh": diff --git a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx index e44fd2539..3249e1eb5 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { motion } from "framer-motion"; import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { @@ -28,11 +30,17 @@ type Props = { }>; }; +enum IntegrationView { + List = "list", + New = "new" +} + export const IntegrationsPage = withProjectPermission( ({ frameworkIntegrations, infrastructureIntegrations }: Props) => { const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; const environments = currentWorkspace?.environments || []; + const [view, setView] = useState(IntegrationView.New); const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } = useGetCloudIntegrations(); @@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission( const { data: integrations, isLoading: isIntegrationLoading, - isFetching: isIntegrationFetching + isFetching: isIntegrationFetching, + isFetched: isIntegrationsFetched } = useGetWorkspaceIntegrations(workspaceId); const { mutateAsync: deleteIntegration } = useDeleteIntegration(); @@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission( isIntegrationsEmpty ]); + useEffect(() => { + setView(integrations?.length ? IntegrationView.List : IntegrationView.New); + }, [isIntegrationsFetched]); + const handleProviderIntegration = async (provider: string) => { const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug); if (!selectedCloudIntegration) return; @@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission( } }; + if (isIntegrationLoading || isCloudIntegrationsLoading) + return ( +
+ +
+ ); + return ( -
- - - - +
+
+ {view === IntegrationView.List ? ( + + setView(IntegrationView.New)} + isLoading={isIntegrationLoading} + integrations={integrations} + environments={environments} + onIntegrationDelete={handleIntegrationDelete} + workspaceId={workspaceId} + /> + + ) : ( + + setView(IntegrationView.List) : undefined + } + isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading} + cloudIntegrations={cloudIntegrations} + integrationAuths={integrationAuths} + onIntegrationStart={handleProviderIntegrationStart} + onIntegrationRevoke={handleIntegrationAuthRevoke} + /> + + + + )} +
); }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations } + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Integrations + } ); diff --git a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx index 164470289..8c63aa40c 100644 --- a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx @@ -1,11 +1,24 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faCheck, + faChevronLeft, + faMagnifyingGlass, + faSearch, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner"; import { createNotification } from "@app/components/notifications"; -import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2"; +import { + Button, + DeleteActionModal, + EmptyState, + Input, + Skeleton, + Tooltip +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -22,6 +35,7 @@ type Props = { onIntegrationStart: (slug: string) => void; // cb: handle popUpClose child->parent communication pattern onIntegrationRevoke: (slug: string, cb: () => void) => void; + onViewActiveIntegrations?: () => void; }; type TRevokeIntegrationPopUp = { provider: string }; @@ -31,7 +45,8 @@ export const CloudIntegrationSection = ({ cloudIntegrations = [], integrationAuths = {}, onIntegrationStart, - onIntegrationRevoke + onIntegrationRevoke, + onViewActiveIntegrations }: Props) => { const { t } = useTranslation(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -52,6 +67,12 @@ export const CloudIntegrationSection = ({ return sortedIntegrations; }, [cloudIntegrations, currentWorkspace?.environments]); + const [search, setSearch] = useState(""); + + const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) => + cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim()) + ); + return (
@@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({ )}
-
-

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

-

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

+
+ {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 && - sortedCloudIntegrations?.map((cloudIntegration) => ( + + {!isLoading && filteredIntegrations.length ? ( + filteredIntegrations.map((cloudIntegration) => (
null} role="button" @@ -79,7 +120,7 @@ export const CloudIntegrationSection = ({ 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`} + } flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`} onClick={() => { if (!cloudIntegration.isAvailable) return; if ( @@ -100,11 +141,12 @@ export const CloudIntegrationSection = ({ > integration logo -
+
{cloudIntegration.name}
{cloudIntegration.isAvailable && @@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
)}
- ))} + )) + ) : ( + + )}
{isEmpty && (
diff --git a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx index 3b1df9bdd..a4e6bb586 100644 --- a/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/FrameworkIntegrationSection/FrameworkIntegrationSection.tsx @@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {

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

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

-
+
{sortedFrameworks.map((framework) => ( -
1 ? "px-1 text-sm" : "px-2 text-xl" - } w-full max-w-xs text-center`} - > - {framework?.image && ( - integration logo - )} - {framework?.name && framework?.image &&
} - {framework?.name && framework.name} -
+ {framework?.image && ( + integration logo + )} + {framework?.name && ( +
+ {framework.name} +
+ )}
))} { href="https://infisical.com/docs/cli/commands/run" rel="noopener noreferrer" target="_blank" - className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200" + className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700" > -
- -
+ +
CLI
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => { href="https://infisical.com/docs/sdks/overview" rel="noopener noreferrer" target="_blank" - className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200" + className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700" > -
- -
+ +
SDKs
diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx deleted file mode 100644 index 2be3d1f3e..000000000 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import { useRouter } from "next/router"; -import { - faArrowRight, - faCalendarCheck, - faEllipsis, - faRefresh, - faWarning, - faXmark -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; -import { integrationSlugNameMapping } from "public/data/frequentConstants"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; -import { TIntegration } from "@app/hooks/api/types"; - -type IProps = { - integration: TIntegration; - environments: Array<{ name: string; slug: string; id: string }>; - onRemoveIntegration: VoidFunction; - onManualSyncIntegration: VoidFunction; -}; - -export const ConfiguredIntegrationItem = ({ - integration, - environments, - onRemoveIntegration, - onManualSyncIntegration -}: IProps) => { - const router = useRouter(); - - return ( -
router.push(`/integrations/details/${integration.id}`)} - key={`integration-${integration?.id.toString()}`} - > -
-
- -
- {environments.find((e) => e.id === integration.envId)?.name || "-"} -
-
-
- -
- {integration.secretPath} -
-
-
- -
-
- - {/* eslint-disable-next-line no-nested-ternary */} - {integration.metadata?.githubVisibility === "selected" - ? "Syncing to selected repositories in the organization. " - : integration.metadata?.githubVisibility === "private" - ? "Syncing to all private repositories in the organization" - : "Syncing to all public and private repositories in the organization"} -
- ) : undefined - } - label="Integration" - /> -
- {integrationSlugNameMapping[integration.integration]} -
-
- {integration.integration === "octopus-deploy" && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "qovery" && ( -
-
- -
- {integration?.owner || "-"} -
-
-
- -
- {integration?.targetService || "-"} -
-
-
- -
- {integration?.targetEnvironment || "-"} -
-
-
- )} - {!( - integration.integration === "aws-secret-manager" && - integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE - ) && ( -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (["aws-parameter-store", "rundeck"].includes(integration.integration) && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} -
-
- )} - {(integration.integration === "vercel" || - integration.integration === "netlify" || - integration.integration === "railway" || - integration.integration === "gitlab" || - integration.integration === "teamcity" || - (integration.integration === "github" && integration.scope === "github-env")) && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "bitbucket" && ( - <> - {integration.targetServiceId && ( -
- -
- {integration.targetService || integration.targetServiceId} -
-
- )} -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- - )} - {integration.integration === "checkly" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {integration.integration === "circleci" && integration.owner && ( -
- -
- {integration.owner} -
-
- )} - {integration.integration === "terraform-cloud" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {(integration.integration === "checkly" || integration.integration === "github") && ( -
- -
- {integration?.metadata?.secretSuffix || "-"} -
-
- )} -
-
- {integration.isSynced != null && integration.lastUsed != null && ( - - -
- -
Last successful sync
-
-
- {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} -
- {!integration.isSynced && ( - <> -
- -
Fail reason
-
-
{integration.syncMessage}
- - )} -
- } - > -
-
{integration.isSynced ? "Synced" : "Not synced"}
- {!integration.isSynced && } -
- - - )} -
- - { - e.stopPropagation(); - onManualSyncIntegration(); - }} - ariaLabel="sync" - colorSchema="primary" - variant="star" - className="max-w-[2.5rem] border-none bg-mineshaft-500" - > - - - - - {(isAllowed: boolean) => ( - - { - e.stopPropagation(); - onRemoveIntegration(); - }} - ariaLabel="delete" - isDisabled={!isAllowed} - colorSchema="danger" - variant="star" - className="max-w-[2.5rem] border-none bg-mineshaft-500" - > - - - - )} - - - - - - - -
-
-
- ); -}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 2fbcca15d..2708226f6 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -1,13 +1,16 @@ -import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2"; -import { usePopUp, useToggle } from "@app/hooks"; -import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; -import { TIntegration } from "@app/hooks/api/types"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem"; +import { Button, Checkbox, DeleteActionModal } from "@app/components/v2"; +import { usePopUp, useToggle } from "@app/hooks"; +import { TCloudIntegration, TIntegration } from "@app/hooks/api/types"; + +import { IntegrationsTable } from "./components"; type Props = { environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; + cloudIntegrations?: TCloudIntegration[]; isLoading?: boolean; onIntegrationDelete: ( integrationId: string, @@ -15,6 +18,7 @@ type Props = { cb: () => void ) => Promise; workspaceId: string; + onAddIntegration: () => void; }; export const IntegrationsSection = ({ @@ -22,58 +26,47 @@ export const IntegrationsSection = ({ environments = [], isLoading, onIntegrationDelete, - workspaceId + workspaceId, + onAddIntegration, + cloudIntegrations = [] }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteConfirmation", "deleteSecretsConfirmation" ] as const); - const { mutate: syncIntegration } = useSyncIntegration(); const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false); return ( -
-
-

Current Integrations

+
+
+

Integrations

Manage integrations with third-party services.

- {isLoading && ( -
- +
+
+

Active Integrations

+
- )} - - {!isLoading && !integrations.length && ( -
- -
- )} - {!isLoading && ( -
- {integrations?.map((integration) => ( - { - syncIntegration({ - workspaceId, - id: integration.id, - lastUsed: integration.lastUsed as string - }); - }} - onRemoveIntegration={() => { - setShouldDeleteSecrets.off(); - handlePopUpOpen("deleteConfirmation", integration); - }} - integration={integration} - environments={environments} - /> - ))} -
- )} + { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} + /> +
+ (integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || + (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) || + integration.app || + "-"; + +export const IntegrationDetails = ({ integration }: Props) => { + return ( +
+ {integration.integration === "octopus-deploy" && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "qovery" && ( + <> +
+ +
{integration?.owner || "-"}
+
+
+ +
{integration?.targetService || "-"}
+
+
+ +
{integration?.targetEnvironment || "-"}
+
+ + )} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
{getIntegrationDestination(integration)}
+
+ )} + {(integration.integration === "vercel" || + integration.integration === "netlify" || + integration.integration === "railway" || + integration.integration === "gitlab" || + integration.integration === "teamcity" || + (integration.integration === "github" && integration.scope === "github-env")) && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "bitbucket" && ( + <> + {integration.targetServiceId && ( +
+ +
+ {integration.targetService || integration.targetServiceId} +
+
+ )} +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ + )} + {integration.integration === "checkly" && integration.targetService && ( +
+ +
{integration.targetService}
+
+ )} + {integration.integration === "circleci" && integration.owner && ( +
+ +
{integration.owner}
+
+ )} + {integration.integration === "terraform-cloud" && integration.targetService && ( +
+ +
{integration.targetService}
+
+ )} + {(integration.integration === "checkly" || integration.integration === "github") && + integration?.metadata?.secretSuffix && ( +
+ +
{integration.metadata.secretSuffix}
+
+ )} + {integration.integration === "github" && integration.metadata?.githubVisibility ? ( +
+ {/* eslint-disable-next-line no-nested-ternary */} + {integration.metadata?.githubVisibility === "selected" + ? "* Syncing to selected repositories in the organization. " + : integration.metadata?.githubVisibility === "private" + ? "* Syncing to all private repositories in the organization" + : "* Syncing to all public and private repositories in the organization"} +
+ ) : undefined} +
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx new file mode 100644 index 000000000..79c0e26f3 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationRow.tsx @@ -0,0 +1,185 @@ +import { useMemo } from "react"; +import { useRouter } from "next/router"; +import { + faCalendarCheck, + faCheck, + faInfoCircle, + faRefresh, + faTrash, + faWarning, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { TCloudIntegration } from "@app/hooks/api/integrations/types"; +import { TIntegration } from "@app/hooks/api/types"; + +import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails"; + +type IProps = { + integration: TIntegration; + environment?: { name: string; slug: string; id: string }; + onRemoveIntegration: VoidFunction; + onManualSyncIntegration: VoidFunction; + cloudIntegration: TCloudIntegration; +}; + +export const IntegrationRow = ({ + integration, + environment, + onRemoveIntegration, + onManualSyncIntegration, + cloudIntegration +}: IProps) => { + const router = useRouter(); + + const { id, secretPath, syncMessage, isSynced } = integration; + + const failureMessage = useMemo(() => { + if (isSynced === false) { + if (syncMessage) + try { + return JSON.stringify(JSON.parse(syncMessage), null, 2); + } catch (e) { + return syncMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [isSynced, syncMessage]); + + return ( + router.push(`/integrations/details/${integration.id}`)} + className={twMerge( + "group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700", + isSynced === false && "bg-red/5 hover:bg-red/10" + )} + key={`integration-${id}`} + > + +
+ {`${cloudIntegration?.name} + {cloudIntegration?.name} +
+ + + +

{secretPath}

+
{" "} + + {environment?.name ?? "-"} + +
+

{getIntegrationDestination(integration)}

+ } + > + + +
+ + + {" "} + {typeof integration.isSynced !== "boolean" ? ( + + Pending Sync + + ) : ( + + {integration.lastUsed && ( +
+
+ +
Last Synced
+
+
+ {format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")} +
+
+ )} + {failureMessage && ( +
+
+ +
Failure Reason
+
+
{failureMessage}
+
+ )} +
+ } + > +
+ +
+ +
{integration.isSynced ? "Synced" : "Not Synced"}
+
+
+
+ + )} + + +
+ + { + e.stopPropagation(); + onManualSyncIntegration(); + }} + ariaLabel="sync" + colorSchema="secondary" + variant="plain" + > + + + + + {(isAllowed: boolean) => ( + + { + e.stopPropagation(); + onRemoveIntegration(); + }} + ariaLabel="delete" + isDisabled={!isAllowed} + colorSchema="danger" + variant="plain" + > + + + + )} + +
+ + + ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx new file mode 100644 index 000000000..ea2ecd888 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationsTable.tsx @@ -0,0 +1,448 @@ +import { useEffect, useMemo, useState } from "react"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowDown, + faArrowUp, + faCheck, + faClock, + faFilter, + faMagnifyingGlass, + faPlug, + faSearch, + faWarning +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; +import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types"; + +import { getIntegrationDestination } from "./IntegrationDetails"; +import { IntegrationRow } from "./IntegrationRow"; + +type Props = { + integrations?: TIntegration[]; + cloudIntegrations?: TCloudIntegration[]; + workspaceId: string; + isLoading?: boolean; + environments: Array<{ name: string; slug: string; id: string }>; + onDeleteIntegration: (integration: TIntegration) => void; +}; + +enum IntegrationsOrderBy { + App = "app", + Status = "status", + SecretPath = "secretPath", + Environment = "environment", + Destination = "destination" +} + +enum IntegrationStatus { + Synced = "synced", + NotSynced = "not-synced", + PendingSync = "pending-sync" +} + +type IntegrationFilters = { + environmentIds: string[]; + integrations: string[]; + status: IntegrationStatus[]; +}; + +const STATUS_ICON_MAP = { + [IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" }, + [IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" }, + [IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" } +}; + +export const IntegrationsTable = ({ + integrations = [], + cloudIntegrations = [], + workspaceId, + environments, + onDeleteIntegration, + isLoading +}: Props) => { + const { mutate: syncIntegration } = useSyncIntegration(); + + const initialFilters = useMemo( + () => ({ + environmentIds: environments.map((env) => env.id), + integrations: [...new Set(integrations.map(({ integration }) => integration))], + status: Object.values(IntegrationStatus) + }), + [environments, integrations] + ); + + const [filters, setFilters] = useState(initialFilters); + + const cloudIntegrationMap = useMemo(() => { + return new Map( + cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration]) + ); + }, [cloudIntegrations]); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(IntegrationsOrderBy.App, { initPerPage: 20 }); + + useEffect(() => { + if (integrations?.some((integration) => integration.isSynced === false)) + setOrderBy(IntegrationsOrderBy.Status); + }, []); + + const environmentMap = new Map(environments.map((env) => [env.id, env])); + + const filteredIntegrations = useMemo( + () => + integrations + .filter((integration) => { + const { secretPath, envId, isSynced } = integration; + + if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false; + if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false) + return false; + if ( + !filters.status.includes(IntegrationStatus.PendingSync) && + typeof isSynced !== "boolean" + ) + return false; + + if (!filters.integrations.includes(integration.integration)) return false; + + if (!filters.environmentIds.includes(envId)) return false; + + return ( + integration.integration + .replace("-", " ") + .toLowerCase() + .includes(search.trim().toLowerCase()) || + secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) || + getIntegrationDestination(integration) + .toLowerCase() + .includes(search.trim().toLowerCase()) || + environmentMap + .get(envId) + ?.name.replace("-", " ") + .toLowerCase() + .includes(search.trim().toLowerCase()) + ); + }) + .sort((a, b) => { + const [integrationOne, integrationTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case IntegrationsOrderBy.SecretPath: + return integrationOne.secretPath + .toLowerCase() + .localeCompare(integrationTwo.secretPath.toLowerCase()); + case IntegrationsOrderBy.Environment: + return (environmentMap.get(integrationOne.envId)?.name ?? "-") + .toLowerCase() + .localeCompare( + (environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase() + ); + case IntegrationsOrderBy.Destination: + return getIntegrationDestination(integrationOne) + .toLowerCase() + .localeCompare(getIntegrationDestination(integrationTwo).toLowerCase()); + case IntegrationsOrderBy.Status: + if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end + if (typeof integrationTwo.isSynced !== "boolean") return -1; + + return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced); + case IntegrationsOrderBy.App: + default: + return integrationOne.integration + .toLowerCase() + .localeCompare(integrationTwo.integration.toLowerCase()); + } + }), + [integrations, orderDirection, search, orderBy, filters] + ); + + useResetPageHelper({ + totalCount: filteredIntegrations.length, + offset, + setPage + }); + + const handleSort = (column: IntegrationsOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: IntegrationsOrderBy) => + twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: IntegrationsOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + + const isTableFiltered = + filters.integrations.length !== initialFilters.integrations.length || + filters.environmentIds.length !== initialFilters.environmentIds.length || + filters.status.length !== initialFilters.status.length; + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search integrations..." + className="flex-1" + /> + + + + + + + + + + Status + {Object.values(IntegrationStatus).map((status) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + status: prev.status.includes(status) + ? prev.status.filter((s) => s !== status) + : [...prev.status, status] + })); + }} + key={status} + icon={ + filters.status.includes(status) && ( + + ) + } + iconPos="right" + > +
+ + {status.replace("-", " ")} +
+
+ ))} + Integration + {[...new Set(integrations.map(({ integration }) => integration))].map((integration) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + integrations: prev.integrations.includes(integration) + ? prev.integrations.filter((i) => i !== integration) + : [...prev.integrations, integration] + })); + }} + key={integration} + icon={ + filters.integrations.includes(integration) && ( + + ) + } + iconPos="right" + > +
+ {`${cloudIntegrationMap.get(integration)!.name} + {cloudIntegrationMap.get(integration)!.name} +
+
+ ))} + Environment + {environments.map((env) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + environmentIds: prev.environmentIds.includes(env.id) + ? prev.environmentIds.filter((i) => i !== env.id) + : [...prev.environmentIds, env.id] + })); + }} + key={env.id} + icon={ + filters.environmentIds.includes(env.id) && ( + + ) + } + iconPos="right" + > + {env.name} + + ))} +
+
+
+ + + + + + + + + + + + + {filteredIntegrations.slice(offset, perPage * page).map((integration) => ( + { + syncIntegration({ + workspaceId, + id: integration.id, + lastUsed: integration.lastUsed as string + }); + }} + onRemoveIntegration={() => onDeleteIntegration(integration)} + integration={integration} + environment={environmentMap.get(integration.envId)} + /> + ))} + +
+
+ Integration + handleSort(IntegrationsOrderBy.App)} + > + + +
+
+
+ Source Path + handleSort(IntegrationsOrderBy.SecretPath)} + > + + +
+
+
+ Source Environment + handleSort(IntegrationsOrderBy.Environment)} + > + + +
+
+
+ Destination + handleSort(IntegrationsOrderBy.Destination)} + > + + +
+
+
+ Status + handleSort(IntegrationsOrderBy.Status)} + > + + +
+
+
+ {Boolean(filteredIntegrations.length) && ( + + )} + {!isLoading && !filteredIntegrations?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts new file mode 100644 index 000000000..d9567592c --- /dev/null +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/index.ts @@ -0,0 +1 @@ +export * from "./IntegrationsTable";