From 5e5ab29ab99fa0a0025dc90676bc12906dddb938 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 12 Sep 2024 11:02:50 +0400 Subject: [PATCH] Feat: Integration UI improvements --- .../server/routes/v1/integration-router.ts | 44 +++- .../integration/integration-service.ts | 25 +- .../services/integration/integration-types.ts | 4 + frontend/src/hooks/api/integrations/index.tsx | 7 +- .../src/hooks/api/integrations/queries.tsx | 20 +- frontend/src/hooks/api/integrations/types.ts | 8 + .../integrations/details/[integrationId].tsx | 23 ++ .../DetailsPage/DetailsPage.tsx | 115 +++++++++ .../components/IntegrationDetailsSection.tsx | 241 ++++++++++++++++++ .../IntegrationsPage/DetailsPage/index.tsx | 0 .../ConfiguredIntegrationItem.tsx | 8 +- 11 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/integrations/details/[integrationId].tsx create mode 100644 frontend/src/views/IntegrationsPage/DetailsPage/DetailsPage.tsx create mode 100644 frontend/src/views/IntegrationsPage/DetailsPage/components/IntegrationDetailsSection.tsx create mode 100644 frontend/src/views/IntegrationsPage/DetailsPage/index.tsx diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 6526dd940..f08bb7e3b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -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", diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 02e520c6e..33737d68f 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -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 }; }; diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 0df8edc4a..5c76159de 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -39,6 +39,10 @@ export type TCreateIntegrationDTO = { }; } & Omit; +export type TGetIntegrationDTO = { + id: string; +} & Omit; + export type TUpdateIntegrationDTO = { id: string; app?: string; diff --git a/frontend/src/hooks/api/integrations/index.tsx b/frontend/src/hooks/api/integrations/index.tsx index f91d85644..9d43c33ad 100644 --- a/frontend/src/hooks/api/integrations/index.tsx +++ b/frontend/src/hooks/api/integrations/index.tsx @@ -1 +1,6 @@ -export { useCreateIntegration, useDeleteIntegration, useGetCloudIntegrations } from "./queries"; +export { + useCreateIntegration, + useDeleteIntegration, + useGetCloudIntegrations, + useGetIntegration +} from "./queries"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index f07e33e60..e6745b741 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -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`), diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index f8c7ce244..609a00d29 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -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", diff --git a/frontend/src/pages/integrations/details/[integrationId].tsx b/frontend/src/pages/integrations/details/[integrationId].tsx new file mode 100644 index 000000000..d437dabd4 --- /dev/null +++ b/frontend/src/pages/integrations/details/[integrationId].tsx @@ -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 ( + <> + + Integration Details | Infisical + + + + + + + + ); +} + +IntegrationsDetailsPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/DetailsPage/DetailsPage.tsx b/frontend/src/views/IntegrationsPage/DetailsPage/DetailsPage.tsx new file mode 100644 index 000000000..c645f0f39 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/DetailsPage/DetailsPage.tsx @@ -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 ? ( +
+
+ +
+

+ {integrationSlugNameMapping[integration.integration]} Integration +

+ + +
+ + + +
+
+ + { + await syncIntegration({ + id: integration.id, + lastUsed: integration.lastUsed!, + workspaceId: projectId! + }); + }} + > +
+ + Manually Sync +
+
+ + {(isAllowed) => ( + {}} + disabled={!isAllowed} + > +
+ + Delete Integration +
+
+ )} +
+
+
+
+
+
+ +
+
+
+
+ ) : null; +}; diff --git a/frontend/src/views/IntegrationsPage/DetailsPage/components/IntegrationDetailsSection.tsx b/frontend/src/views/IntegrationsPage/DetailsPage/components/IntegrationDetailsSection.tsx new file mode 100644 index 000000000..c7ab28e0c --- /dev/null +++ b/frontend/src/views/IntegrationsPage/DetailsPage/components/IntegrationDetailsSection.tsx @@ -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 ( +
+
+ +
{integration?.owner || "-"}
+
+
+ +
{integration?.targetService || "-"}
+
+
+ +
{integration?.targetEnvironment || "-"}
+
+
+ ); + }; + + 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 ( +
+ +
{contents()}
+
+ ); + }; + + const targetEnvironmentDetails = () => { + if ( + ["vercel", "netlify", "railway", "gitlab", "teamcity", "bitbucket"].includes( + integration.integration + ) || + (integration.integration === "github" && integration.scope === "github-env") + ) { + return ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ ); + } + + return null; + }; + + const generalIntegrationSpecificDetails = () => { + if (integration.integration === "checkly" && integration.targetService) { + return ( +
+ +
{integration.targetService}
+
+ ); + } + + if (integration.integration === "circleci" && integration.owner) { + return ( +
+ +
{integration.owner}
+
+ ); + } + + if (integration.integration === "terraform-cloud" && integration.targetService) { + return ( +
+ +
{integration.targetService}
+
+ ); + } + + if (integration.integration === "checkly" || integration.integration === "github") { + return ( +
+ +
+ {integration?.metadata?.secretSuffix || "-"} +
+
+ ); + } + + return null; + }; + + return ( +
+
+
+

Integration Details

+
+
+
+
+

Name

+

+ {integrationSlugNameMapping[integration.integration]} +

+
+
+

Sync Status

+
+

+ {integration.isSynced ? "Synced" : "Not Synced"} +

+ +
+
+
+ {!integration.isSynced && integration.syncMessage && ( + <> +

Latest sync error

+

{integration.syncMessage}

+ + )} +
+
+
+
+ +
+
+

Connection

+
+ +
+ + +
+
+ +
{integration.environment.name}
+
+
+ +
{integration.secretPath}
+
+
+ + +
+ +
+ {integrationSlugNameMapping[integration.integration]} +
+ + {specifcQoveryDetails()} + {isNotAwsManagerOneToOneDetails()} + {targetEnvironmentDetails()} + {generalIntegrationSpecificDetails()} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/DetailsPage/index.tsx b/frontend/src/views/IntegrationsPage/DetailsPage/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx index 8558eb36a..1d6332022 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx @@ -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 (
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()}`} >