Feat: Integration UI improvements

This commit is contained in:
Daniel Hougaard
2024-09-12 11:02:50 +04:00
parent 5150c102e6
commit 5e5ab29ab9
11 changed files with 490 additions and 5 deletions

View File

@@ -4,7 +4,7 @@ import { IntegrationsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { INTEGRATION } from "@app/lib/api-docs";
import { removeTrailingSlash, shake } from "@app/lib/fn";
import { writeLimit } from "@app/server/config/rateLimiter";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -154,6 +154,48 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:integrationId",
config: {
rateLimit: readLimit
},
schema: {
description: "Get an integration by integration id",
security: [
{
bearerAuth: []
}
],
params: z.object({
integrationId: z.string().trim().describe(INTEGRATION.UPDATE.integrationId)
}),
response: {
200: z.object({
integration: IntegrationsSchema.extend({
environment: z.object({
slug: z.string().trim(),
name: z.string().trim(),
id: z.string().trim()
})
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const integration = await server.services.integration.getIntegration({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.integrationId
});
return { integration };
}
});
server.route({
method: "DELETE",
url: "/:integrationId",

View File

@@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types";
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
@@ -19,6 +19,7 @@ import { TIntegrationDALFactory } from "./integration-dal";
import {
TCreateIntegrationDTO,
TDeleteIntegrationDTO,
TGetIntegrationDTO,
TSyncIntegrationDTO,
TUpdateIntegrationDTO
} from "./integration-types";
@@ -180,6 +181,27 @@ export const integrationServiceFactory = ({
return updatedIntegration;
};
const getIntegration = async ({ id, actor, actorAuthMethod, actorId, actorOrgId }: TGetIntegrationDTO) => {
const integration = await integrationDAL.findById(id);
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
integration?.projectId || "",
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
if (!integration) {
throw new NotFoundError({
message: "Integration not found"
});
}
return { ...integration, envId: integration.environment.id };
};
const deleteIntegration = async ({
actorId,
id,
@@ -289,6 +311,7 @@ export const integrationServiceFactory = ({
updateIntegration,
deleteIntegration,
listIntegrationByProject,
getIntegration,
syncIntegration
};
};

View File

@@ -39,6 +39,10 @@ export type TCreateIntegrationDTO = {
};
} & Omit<TProjectPermission, "projectId">;
export type TGetIntegrationDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateIntegrationDTO = {
id: string;
app?: string;

View File

@@ -1 +1,6 @@
export { useCreateIntegration, useDeleteIntegration, useGetCloudIntegrations } from "./queries";
export {
useCreateIntegration,
useDeleteIntegration,
useGetCloudIntegrations,
useGetIntegration
} from "./queries";

View File

@@ -5,9 +5,11 @@ import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace";
import { TCloudIntegration } from "./types";
import { TCloudIntegration, TIntegrationWithEnv } from "./types";
export const integrationQueryKeys = {
getIntegrations: () => ["integrations"] as const
getIntegrations: () => ["integrations"] as const,
getIntegration: (id: string) => ["integration", id] as const
};
const fetchIntegrations = async () => {
@@ -18,6 +20,14 @@ const fetchIntegrations = async () => {
return data.integrationOptions;
};
const fetchIntegration = async (id: string) => {
const { data } = await apiRequest.get<{ integration: TIntegrationWithEnv }>(
`/api/v1/integration/${id}`
);
return data.integration;
};
export const useGetCloudIntegrations = () =>
useQuery({
queryKey: integrationQueryKeys.getIntegrations(),
@@ -128,6 +138,14 @@ export const useDeleteIntegration = () => {
});
};
export const useGetIntegration = (integrationId: string) => {
return useQuery({
enabled: Boolean(integrationId),
queryKey: integrationQueryKeys.getIntegration(integrationId),
queryFn: () => fetchIntegration(integrationId)
});
};
export const useSyncIntegration = () => {
return useMutation<{}, {}, { id: string; workspaceId: string; lastUsed: string }>({
mutationFn: ({ id }) => apiRequest.post(`/api/v1/integration/${id}/sync`),

View File

@@ -47,6 +47,14 @@ export type TIntegration = {
};
};
export type TIntegrationWithEnv = TIntegration & {
environment: {
id: string;
name: string;
slug: string;
};
};
export enum IntegrationSyncBehavior {
OVERWRITE_TARGET = "overwrite-target",
PREFER_TARGET = "prefer-target",

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { IntegrationDetails } from "@app/views/IntegrationsPage/DetailsPage/DetailsPage";
export default function IntegrationsDetailsPage() {
const { t } = useTranslation();
return (
<>
<Head>
<title>Integration Details | Infisical</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="Manage your .env files in seconds" />
<meta name="og:description" content={t("integrations.description") as string} />
</Head>
<IntegrationDetails />
</>
);
}
IntegrationsDetailsPage.requireAuth = true;

View File

@@ -0,0 +1,115 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useUser, useWorkspace } from "@app/context";
import { useGetIntegration } from "@app/hooks/api";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { usePopUp } from "@app/hooks/usePopUp";
import { IntegrationDetailsSection } from "./components/IntegrationDetailsSection";
// import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
export const IntegrationDetails = () => {
const router = useRouter();
const integrationId = router.query.integrationId as string;
const { data: integration } = useGetIntegration(integrationId);
const projectId = useWorkspace().currentWorkspace?.id;
const { mutateAsync: syncIntegration } = useSyncIntegration();
const { user } = useUser();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"removeMember",
"orgMembership",
"deactivateMember",
"upgradePlan"
] as const);
return integration ? (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
router.push(`/integrations/${projectId}`);
}}
className="mb-4"
>
Integrations
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{integrationSlugNameMapping[integration.integration]} Integration
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={async () => {
await syncIntegration({
id: integration.id,
lastUsed: integration.lastUsed!,
workspaceId: projectId!
});
}}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRefresh} />
Manually Sync
</div>
</DropdownMenuItem>
<OrgPermissionCan I={OrgPermissionActions.Delete} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => {}}
disabled={!isAllowed}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTrash} />
Delete Integration
</div>
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<IntegrationDetailsSection integration={integration} />
</div>
</div>
</div>
</div>
) : null;
};

View File

@@ -0,0 +1,241 @@
import { faCheckCircle, faCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { twMerge } from "tailwind-merge";
import { FormLabel } from "@app/components/v2";
import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegrationWithEnv;
};
export const IntegrationDetailsSection = ({ integration }: Props) => {
const specifcQoveryDetails = () => {
if (integration.integration !== "qovery") return null;
return (
<div className="flex flex-row">
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Organization" />
<div className="text-sm text-mineshaft-300">{integration?.owner || "-"}</div>
</div>
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Project" />
<div className="text-sm text-mineshaft-300">{integration?.targetService || "-"}</div>
</div>
<div className="flex flex-col">
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Target Environment"
/>
<div className="text-sm text-mineshaft-300">{integration?.targetEnvironment || "-"}</div>
</div>
</div>
);
};
const isNotAwsManagerOneToOneDetails = () => {
const isAwsSecretManagerOneToOne =
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE;
if (isAwsSecretManagerOneToOne) {
return null;
}
const formLabel = () => {
switch (integration.integration) {
case "qovery":
return integration.scope;
case "circleci":
case "terraform-cloud":
return "Project";
case "aws-secret-manager":
return "Secret";
case "aws-parameter-store":
case "rundeck":
return "Path";
case "github":
if (["github-env", "github-repo"].includes(integration.scope!)) {
return "Repository";
}
return "Organization";
default:
return "App";
}
};
const contents = () => {
switch (integration.integration) {
case "hashicorp-vault":
return `${integration.app} - path: ${integration.path}`;
case "github":
if (integration.scope === "github-org") {
return `${integration.owner}`;
}
return `${integration.owner}/${integration.app}`;
case "aws-parameter-store":
case "rundeck":
return `${integration.path}aaaaa`;
default:
return `${integration.app}`;
}
};
return (
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label={formLabel()} />
<div className="text-sm text-mineshaft-300">{contents()}</div>
</div>
);
};
const targetEnvironmentDetails = () => {
if (
["vercel", "netlify", "railway", "gitlab", "teamcity", "bitbucket"].includes(
integration.integration
) ||
(integration.integration === "github" && integration.scope === "github-env")
) {
return (
<div className="flex flex-col">
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Target Environment"
/>
<div className="text-sm text-mineshaft-300">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
);
}
return null;
};
const generalIntegrationSpecificDetails = () => {
if (integration.integration === "checkly" && integration.targetService) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Group" />
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
if (integration.integration === "circleci" && integration.owner) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Organization" />
<div className="text-sm text-mineshaft-300">{integration.owner}</div>
</div>
);
}
if (integration.integration === "terraform-cloud" && integration.targetService) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Category" />
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
if (integration.integration === "checkly" || integration.integration === "github") {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Secret Suffix" />
<div className="text-sm text-mineshaft-300">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
);
}
return null;
};
return (
<div>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Integration Details</h3>
</div>
<div className="mt-4">
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{integrationSlugNameMapping[integration.integration]}
</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Sync Status</p>
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium",
integration.isSynced ? "text-green-500" : "text-red-500"
)}
>
{integration.isSynced ? "Synced" : "Not Synced"}
</p>
<FontAwesomeIcon
size="sm"
className={twMerge(integration.isSynced ? "text-green-500" : "text-red-500")}
icon={integration.isSynced ? faCheckCircle : faCircleXmark}
/>
</div>
</div>
<div>
{!integration.isSynced && integration.syncMessage && (
<>
<p className="text-sm font-semibold text-mineshaft-300">Latest sync error</p>
<p className="text-sm text-mineshaft-300">{integration.syncMessage}</p>
</>
)}
</div>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Connection</h3>
</div>
<div className="mt-4">
<FormLabel className="my-2" label="Source" />
<div className="space-y-2 rounded-lg border border-mineshaft-700 bg-mineshaft-800 p-2">
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Environment" />
<div className="text-sm text-mineshaft-300">{integration.environment.name}</div>
</div>
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Secret Path" />
<div className="text-sm text-mineshaft-300">{integration.secretPath}</div>
</div>
</div>
<FormLabel className="my-2" label="Destination" />
<div className="space-y-2 rounded-lg border border-mineshaft-700 bg-mineshaft-800 p-2">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Platform" />
<div className="text-sm text-mineshaft-300">
{integrationSlugNameMapping[integration.integration]}
</div>
{specifcQoveryDetails()}
{isNotAwsManagerOneToOneDetails()}
{targetEnvironmentDetails()}
{generalIntegrationSpecificDetails()}
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,3 +1,6 @@
/* 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,
@@ -28,9 +31,12 @@ export const ConfiguredIntegrationItem = ({
onRemoveIntegration,
onManualSyncIntegration
}: IProps) => {
const router = useRouter();
return (
<div
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3"
onClick={() => router.push(`/integrations/details/${integration.id}`)}
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"
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">