Compare commits

..

10 Commits

19 changed files with 1050 additions and 415 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -36,12 +36,10 @@ export const MultiValueRemove = (props: MultiValueRemoveProps) => {
export const Option = <T,>({ isSelected, children, ...props }: OptionProps<T>) => {
return (
<components.Option isSelected={isSelected} {...props}>
<div className="flex items-center">
<p className="truncate">{children}</p>
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</div>
{children}
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</components.Option>
);
};

View File

@ -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);
};

View File

@ -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);

View File

@ -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();

View File

@ -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}`

View File

@ -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: (
<a
href={`https://infisical.com/docs/integrations/${type}/${slug}${
hashtag ? `#${hashtag}` : ""
}`}
target="_blank"
rel="noreferrer"
className="underline"
>
Click here to view docs
</a>
),
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":

View File

@ -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>(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 (
<div className="flex flex-col items-center gap-2">
<ContentLoader text={["Loading integrations..."]} />
</div>
);
return (
<div className="container mx-auto max-w-7xl pb-12 text-white">
<IntegrationsSection
isLoading={isIntegrationLoading}
integrations={integrations}
environments={environments}
onIntegrationDelete={handleIntegrationDelete}
workspaceId={workspaceId}
/>
<CloudIntegrationSection
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
cloudIntegrations={cloudIntegrations}
integrationAuths={integrationAuths}
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
/>
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
<div className="relative">
{view === IntegrationView.List ? (
<motion.div
key="view-integrations"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<IntegrationsSection
cloudIntegrations={cloudIntegrations}
onAddIntegration={() => setView(IntegrationView.New)}
isLoading={isIntegrationLoading}
integrations={integrations}
environments={environments}
onIntegrationDelete={handleIntegrationDelete}
workspaceId={workspaceId}
/>
</motion.div>
) : (
<motion.div
key="add-integration"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<CloudIntegrationSection
onViewActiveIntegrations={
integrations?.length ? () => setView(IntegrationView.List) : undefined
}
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
cloudIntegrations={cloudIntegrations}
integrationAuths={integrationAuths}
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
/>
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
</motion.div>
)}
</div>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations }
{
action: ProjectPermissionActions.Read,
subject: ProjectPermissionSub.Integrations
}
);

View File

@ -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 (
<div>
<div className="px-5">
@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
)}
</div>
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
{onViewActiveIntegrations && (
<Button
variant="link"
onClick={onViewActiveIntegrations}
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
>
Back to Integrations
</Button>
)}
<div className="flex w-full flex-col justify-between gap-4 whitespace-nowrap lg:flex-row lg:items-end lg:gap-8">
<div className="flex-1">
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search cloud integrations..."
containerClassName="flex-1 h-min text-base"
/>
</div>
</div>
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
<div className="mx-6 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
))}
{!isLoading &&
sortedCloudIntegrations?.map((cloudIntegration) => (
{!isLoading && filteredIntegrations.length ? (
filteredIntegrations.map((cloudIntegration) => (
<div
onKeyDown={() => 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 = ({
>
<img
src={`/images/integrations/${cloudIntegration.image}`}
height={70}
width={70}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{cloudIntegration.name}
</div>
{cloudIntegration.isAvailable &&
@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
</div>
)}
</div>
))}
))
) : (
<EmptyState
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
title="No cloud integrations match search..."
icon={faSearch}
/>
)}
</div>
{isEmpty && (
<div className="mx-6 grid max-w-5xl grid-cols-4 grid-rows-2 gap-4">

View File

@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
</div>
<div
className="mx-6 mt-4 grid grid-flow-dense gap-3"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
>
<div className="mx-6 mt-4 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{sortedFrameworks.map((framework) => (
<a
key={`framework-integration-${framework.slug}`}
href={framework.docsLink}
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"
>
<div
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 ${
framework?.name?.split(" ").length > 1 ? "px-1 text-sm" : "px-2 text-xl"
} w-full max-w-xs text-center`}
>
{framework?.image && (
<img
src={`/images/integrations/${framework.image}.png`}
height={framework?.name ? 60 : 90}
width={framework?.name ? 60 : 90}
alt="integration logo"
/>
)}
{framework?.name && framework?.image && <div className="h-2" />}
{framework?.name && framework.name}
</div>
{framework?.image && (
<img
src={`/images/integrations/${framework.image}.png`}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
)}
{framework?.name && (
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{framework.name}
</div>
)}
</a>
))}
<a
@ -58,13 +53,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
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"
>
<div
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
>
<FontAwesomeIcon className="text-5xl mb-2 text-white/90" icon={faKeyboard} />
<div className="h-2" />
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faKeyboard} />
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
CLI
</div>
</a>
@ -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"
>
<div
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
>
<FontAwesomeIcon className="text-5xl mb-1 text-white/90" icon={faComputer} />
<div className="h-2" />
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faComputer} />
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
SDKs
</div>
</a>

View File

@ -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 (
<div
className="max-w-8xl flex cursor-pointer justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 transition-all hover:bg-mineshaft-700"
onClick={() => router.push(`/integrations/details/${integration.id}`)}
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">
<div className="ml-2 flex flex-col">
<FormLabel label="Environment" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{environments.find((e) => e.id === integration.envId)?.name || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Secret Path" />
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.secretPath}
</div>
</div>
<div className="mt-3 flex h-full items-center">
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
</div>
<div className="ml-4 flex flex-col">
<FormLabel
tooltipText={
integration.integration === "github" ? (
<div className="text-xs">
{/* 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"}
</div>
) : undefined
}
label="Integration"
/>
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integrationSlugNameMapping[integration.integration]}
</div>
</div>
{integration.integration === "octopus-deploy" && (
<div className="ml-2 flex flex-col">
<FormLabel label="Space" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "qovery" && (
<div className="flex flex-row">
<div className="ml-2 flex flex-col">
<FormLabel label="Org" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.owner || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Project" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetService || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Env" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetEnvironment || "-"}
</div>
</div>
</div>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div className="ml-2 flex flex-col">
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") ||
(integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
"App"
}
/>
<div className="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
{(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}
</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div className="ml-4 flex flex-col">
<FormLabel label="Target Environment" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "bitbucket" && (
<>
{integration.targetServiceId && (
<div className="ml-2 flex flex-col">
<FormLabel label="Environment" />
<div className="min-w-[8rem] overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService || integration.targetServiceId}
</div>
</div>
)}
<div className="ml-2 flex flex-col">
<FormLabel label="Workspace" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
</>
)}
{integration.integration === "checkly" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Group" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{integration.integration === "circleci" && integration.owner && (
<div className="ml-2">
<FormLabel label="Organization" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.owner}
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Category" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{(integration.integration === "checkly" || integration.integration === "github") && (
<div className="ml-2">
<FormLabel label="Secret Suffix" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
)}
</div>
<div className="mt-[1.5rem] flex cursor-default space-x-3">
{integration.isSynced != null && integration.lastUsed != null && (
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
<Tooltip
center
className="max-w-xs whitespace-normal break-words"
content={
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
<div className="text-sm">Last successful sync</div>
</div>
<div className="pl-5 text-left text-xs">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
</div>
{!integration.isSynced && (
<>
<div className="mt-2 flex self-start">
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
<div className="text-sm">Fail reason</div>
</div>
<div className="pl-5 text-left text-xs">{integration.syncMessage}</div>
</>
)}
</div>
}
>
<div className="flex h-full items-center space-x-2">
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
</div>
</Tooltip>
</Badge>
)}
<div className="space-x-1.5">
<Tooltip className="text-center" content="Manually sync integration secrets">
<IconButton
onClick={(e) => {
e.stopPropagation();
onManualSyncIntegration();
}}
ariaLabel="sync"
colorSchema="primary"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faRefresh} className="px-1" />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<Tooltip content="Remove Integration">
<IconButton
onClick={(e) => {
e.stopPropagation();
onRemoveIntegration();
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faXmark} className="px-1" />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
<Tooltip content="View details">
<IconButton
ariaLabel="delete"
colorSchema="primary"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faEllipsis} className="px-1" />
</IconButton>
</Tooltip>
</div>
</div>
</div>
);
};

View File

@ -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<void>;
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 (
<div className="mb-8">
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Current Integrations</h1>
<div className="mx-6 mb-8">
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Integrations</h1>
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
</div>
{isLoading && (
<div className="p-6 pt-0">
<Skeleton className="h-28" />
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={onAddIntegration}
>
Add Integration
</Button>
</div>
)}
{!isLoading && !integrations.length && (
<div className="mx-6">
<EmptyState
className="rounded-md border border-mineshaft-700 pt-8 pb-4"
title="No integrations found. Click on one of the below providers to sync secrets."
/>
</div>
)}
{!isLoading && (
<div className="flex min-w-max flex-col space-y-4 p-6 pt-0">
{integrations?.map((integration) => (
<ConfiguredIntegrationItem
key={`integration-${integration.id}`}
onManualSyncIntegration={() => {
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
});
}}
onRemoveIntegration={() => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
integration={integration}
environments={environments}
/>
))}
</div>
)}
<IntegrationsTable
cloudIntegrations={cloudIntegrations}
integrations={integrations}
isLoading={isLoading}
workspaceId={workspaceId}
environments={environments}
onDeleteIntegration={(integration) => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
/>
</div>
<DeleteActionModal
isOpen={popUp.deleteConfirmation.isOpen}
title={`Are you sure want to remove ${

View File

@ -0,0 +1,138 @@
import { FormLabel } from "@app/components/v2";
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegration;
};
const FIELD_CLASSNAME =
"truncate rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200";
export const getIntegrationDestination = (integration: TIntegration) =>
(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 (
<div className="flex flex-col gap-2 p-2">
{integration.integration === "octopus-deploy" && (
<div>
<FormLabel label="Space" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "qovery" && (
<>
<div>
<FormLabel label="Org" />
<div className={FIELD_CLASSNAME}>{integration?.owner || "-"}</div>
</div>
<div>
<FormLabel label="Project" />
<div className={FIELD_CLASSNAME}>{integration?.targetService || "-"}</div>
</div>
<div>
<FormLabel label="Env" />
<div className={FIELD_CLASSNAME}>{integration?.targetEnvironment || "-"}</div>
</div>
</>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div>
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") ||
(integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
"App"
}
/>
<div className={FIELD_CLASSNAME}>{getIntegrationDestination(integration)}</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div>
<FormLabel label="Target Environment" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "bitbucket" && (
<>
{integration.targetServiceId && (
<div>
<FormLabel label="Environment" />
<div className={FIELD_CLASSNAME}>
{integration.targetService || integration.targetServiceId}
</div>
</div>
)}
<div>
<FormLabel label="Workspace" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
</>
)}
{integration.integration === "checkly" && integration.targetService && (
<div>
<FormLabel label="Group" />
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
</div>
)}
{integration.integration === "circleci" && integration.owner && (
<div>
<FormLabel label="Organization" />
<div className={FIELD_CLASSNAME}>{integration.owner}</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div>
<FormLabel label="Category" />
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
</div>
)}
{(integration.integration === "checkly" || integration.integration === "github") &&
integration?.metadata?.secretSuffix && (
<div>
<FormLabel label="Secret Suffix" />
<div className={FIELD_CLASSNAME}>{integration.metadata.secretSuffix}</div>
</div>
)}
{integration.integration === "github" && integration.metadata?.githubVisibility ? (
<div className="mt-2 text-xs text-mineshaft-200">
{/* 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"}
</div>
) : undefined}
</div>
);
};

View File

@ -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 (
<Tr
onClick={() => 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}`}
>
<Td>
<div className="flex items-center gap-2">
<img
alt={`${cloudIntegration?.name} integration`}
src={`/images/integrations/${cloudIntegration?.image}`}
className="h-5 w-5"
/>
<span className="hidden lg:inline">{cloudIntegration?.name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<Tooltip side="top" className="max-w-2xl break-words" content={secretPath}>
<p className="truncate">{secretPath}</p>
</Tooltip>{" "}
</Td>
<Td>{environment?.name ?? "-"}</Td>
<Td className="!min-w-[5rem] max-w-0">
<div className="flex items-center gap-2">
<p className="truncate">{getIntegrationDestination(integration)}</p>
<Tooltip
position="left"
className="min-w-[20rem] max-w-lg"
content={<IntegrationDetails integration={integration} />}
>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td>
{" "}
{typeof integration.isSynced !== "boolean" ? (
<Badge variant="primary" key={integration.id}>
Pending Sync
</Badge>
) : (
<Tooltip
position="left"
className="max-w-sm"
content={
<div className="flex flex-col gap-2 py-1">
{integration.lastUsed && (
<div>
<div
className={`mb-2 flex self-start ${!isSynced ? "text-yellow" : "text-green"}`}
>
<FontAwesomeIcon
icon={faCalendarCheck}
className="ml-1 pt-0.5 pr-1.5 text-sm"
/>
<div className="text-xs">Last Synced</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">
{format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")}
</div>
</div>
)}
{failureMessage && (
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pt-0.5 pr-1.5 text-sm" />
<div className="text-xs">Failure Reason</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
}
>
<div className="w-min whitespace-nowrap">
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
<div className="flex items-center space-x-1">
<FontAwesomeIcon icon={integration.isSynced ? faCheck : faWarning} />
<div>{integration.isSynced ? "Synced" : "Not Synced"}</div>
</div>
</Badge>
</div>
</Tooltip>
)}
</Td>
<Td>
<div className="flex gap-2 whitespace-nowrap">
<Tooltip className="max-w-sm text-center" content="Manually Sync">
<IconButton
onClick={(e) => {
e.stopPropagation();
onManualSyncIntegration();
}}
ariaLabel="sync"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faRefresh} />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<Tooltip content="Remove Integration">
<IconButton
onClick={(e) => {
e.stopPropagation();
onRemoveIntegration();
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="plain"
>
<FontAwesomeIcon icon={faTrash} className="px-1" />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
};

View File

@ -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<IntegrationFilters>(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>(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 (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search integrations..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Environments"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<Tooltip content="Filter Integrations" className="mb-2">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Status</DropdownMenuLabel>
{Object.values(IntegrationStatus).map((status) => (
<DropdownMenuItem
onClick={(e) => {
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) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={STATUS_ICON_MAP[status].icon}
className={STATUS_ICON_MAP[status].className}
/>
<span className="capitalize">{status.replace("-", " ")}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Integration</DropdownMenuLabel>
{[...new Set(integrations.map(({ integration }) => integration))].map((integration) => (
<DropdownMenuItem
onClick={(e) => {
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) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${cloudIntegrationMap.get(integration)!.name} integration`}
src={`/images/integrations/${cloudIntegrationMap.get(integration)!.image}`}
className="h-4 w-4"
/>
<span className="capitalize">{cloudIntegrationMap.get(integration)!.name}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
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) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-[25%]">
<div className="flex items-center">
Integration
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.App)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.App)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.App)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Source Path
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Source Environment
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Destination
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Destination)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Destination)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Destination)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Status
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Status)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Status)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Status)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredIntegrations.slice(offset, perPage * page).map((integration) => (
<IntegrationRow
cloudIntegration={cloudIntegrationMap.get(integration.integration)!}
key={`integration-${integration.id}`}
onManualSyncIntegration={() => {
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
});
}}
onRemoveIntegration={() => onDeleteIntegration(integration)}
integration={integration}
environment={environmentMap.get(integration.envId)}
/>
))}
</TBody>
</Table>
{Boolean(filteredIntegrations.length) && (
<Pagination
count={filteredIntegrations.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredIntegrations?.length && (
<EmptyState
title={
integrations.length
? "No integrations match search..."
: "This project has no integrations configured"
}
icon={integrations.length ? faSearch : faPlug}
/>
)}
</TableContainer>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./IntegrationsTable";

View File

@ -296,8 +296,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
status,
isActive
}) => {
const name =
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (

View File

@ -123,7 +123,7 @@ export const UserPage = withPermission(
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
? `${membership.user.firstName} ${membership.user.lastName}`
: "-"}
</p>
{userId !== membership.user.id && (

View File

@ -118,7 +118,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
? `${membership.user.firstName} ${membership.user.lastName}`
: "-"}
</p>
</div>