From 28a2a6c41ae9defb86e2adb884971c8efba7d484 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 15 Aug 2024 21:06:37 +0800 Subject: [PATCH] feat: initial integration of cert template management --- .../src/server/routes/v2/project-router.ts | 6 +- .../certificate-template-dal.ts | 3 +- .../context/ProjectPermissionContext/types.ts | 2 + .../hooks/api/certificateTemplates/index.tsx | 2 + .../api/certificateTemplates/mutations.tsx | 63 +++++ .../api/certificateTemplates/queries.tsx | 24 ++ .../hooks/api/certificateTemplates/types.ts | 35 +++ frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/workspace/index.tsx | 1 + frontend/src/hooks/api/workspace/queries.tsx | 21 +- .../CertificatesTab/CertificatesTab.tsx | 2 + .../components/CertificateTemplateModal.tsx | 234 ++++++++++++++++++ .../CertificateTemplatesSection.tsx | 87 +++++++ .../components/CertificateTemplatesTable.tsx | 117 +++++++++ 14 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 frontend/src/hooks/api/certificateTemplates/index.tsx create mode 100644 frontend/src/hooks/api/certificateTemplates/mutations.tsx create mode 100644 frontend/src/hooks/api/certificateTemplates/queries.tsx create mode 100644 frontend/src/hooks/api/certificateTemplates/types.ts create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index ed80a9e59..d3a49fb69 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -476,7 +476,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { CertificateTemplatesSchema.pick({ id: true, name: true - }) + }).merge( + z.object({ + caName: z.string() + }) + ) ) }) } diff --git a/backend/src/services/certificate-template/certificate-template-dal.ts b/backend/src/services/certificate-template/certificate-template-dal.ts index 062e64f35..478a1a3dd 100644 --- a/backend/src/services/certificate-template/certificate-template-dal.ts +++ b/backend/src/services/certificate-template/certificate-template-dal.ts @@ -16,7 +16,8 @@ export const certificateTemplateDALFactory = (db: TDbClient) => { `${TableName.CertificateTemplate}.caId` ) .where(`${TableName.CertificateAuthority}.projectId`, "=", projectId) - .select(selectAllTableCols(TableName.CertificateTemplate)); + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority)); return certTemplates; }; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index c307688d6..6a4f84d02 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -28,6 +28,7 @@ export enum ProjectPermissionSub { Identity = "identity", CertificateAuthorities = "certificate-authorities", Certificates = "certificates", + CertificateTemplates = "certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms" @@ -59,6 +60,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] diff --git a/frontend/src/hooks/api/certificateTemplates/index.tsx b/frontend/src/hooks/api/certificateTemplates/index.tsx new file mode 100644 index 000000000..b8145fbcc --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/index.tsx @@ -0,0 +1,2 @@ +export { useCreateCertTemplate, useDeleteCertTemplate, useUpdateCertTemplate } from "./mutations"; +export { useGetCertTemplate } from "./queries"; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx new file mode 100644 index 000000000..89ea225f2 --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { certTemplateKeys } from "./queries"; +import { + TCertificateTemplate, + TCreateCertificateTemplateDTO, + TDeleteCertificateTemplateDTO, + TUpdateCertificateTemplateDTO +} from "./types"; + +export const useCreateCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { + data: { certificateTemplate } + } = await apiRequest.post<{ certificateTemplate: TCertificateTemplate }>( + "/api/v1/pki/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + } + }); +}; + +export const useUpdateCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { + data: { certificateTemplate } + } = await apiRequest.patch<{ certificateTemplate: TCertificateTemplate }>( + `/api/v1/pki/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: (_, { projectId, id }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id)); + } + }); +}; + +export const useDeleteCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + return apiRequest.delete(`/api/v1/pki/certificate-templates/${data.id}`); + }, + onSuccess: (_, { projectId, id }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx new file mode 100644 index 000000000..56e3e5d54 --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getCertTemplateById: (id: string) => [{ id }, "cert-template"] +}; + +export const useGetCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getCertTemplateById(id), + queryFn: async () => { + const { + data: { certificateTemplate } + } = await apiRequest.get<{ certificateTemplate: TCertificateTemplate }>( + `/api/v1/pki/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts new file mode 100644 index 000000000..2938edbca --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -0,0 +1,35 @@ +export type TCertificateTemplateListEntry = { + id: string; + name: string; + caName: string; +}; + +export type TCertificateTemplate = { + id: string; + caId: string; + name: string; + commonName: string; + ttl: string; +}; + +export type TCreateCertificateTemplateDTO = { + caId: string; + name: string; + commonName: string; + ttl: string; + projectId: string; +}; + +export type TUpdateCertificateTemplateDTO = { + id: string; + caId?: string; + name?: string; + commonName?: string; + ttl?: string; + projectId: string; +}; + +export type TDeleteCertificateTemplateDTO = { + id: string; + projectId: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 94037cb62..a9d24a5c7 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -7,6 +7,7 @@ export * from "./auth"; export * from "./bots"; export * from "./ca"; export * from "./certificates"; +export * from "./certificateTemplates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 3da83e1b8..3f320d0d9 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -25,6 +25,7 @@ export { useGetWorkspaceUsers, useListWorkspaceCas, useListWorkspaceCertificates, + useListWorkspaceCertificateTemplates, useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index d971f06e2..711641be8 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { CaStatus } from "../ca/enums"; import { TCertificateAuthority } from "../ca/types"; import { TCertificate } from "../certificates/types"; +import { TCertificateTemplateListEntry } from "../certificateTemplates/types"; import { TGroupMembership } from "../groups/types"; import { identitiesKeys } from "../identities/queries"; import { IdentityMembership } from "../identities/types"; @@ -68,7 +69,9 @@ export const workspaceKeys = { getWorkspacePkiAlerts: (workspaceId: string) => [{ workspaceId }, "workspace-pki-alerts"] as const, getWorkspacePkiCollections: (workspaceId: string) => - [{ workspaceId }, "workspace-pki-collections"] as const + [{ workspaceId }, "workspace-pki-collections"] as const, + getWorkspaceCertificateTemplates: (workspaceId: string) => + [{ workspaceId }, "workspace-certificate-templates"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -639,3 +642,19 @@ export const useListWorkspacePkiCollections = ({ workspaceId }: { workspaceId: s enabled: Boolean(workspaceId) }); }; + +export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspaceId: string }) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceCertificateTemplates(workspaceId), + queryFn: async () => { + const { + data: { certificateTemplates } + } = await apiRequest.get<{ certificateTemplates: TCertificateTemplateListEntry[] }>( + `/api/v2/workspace/${workspaceId}/certificate-templates` + ); + + return { certificateTemplates }; + }, + enabled: Boolean(workspaceId) + }); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx index f054e2546..f29a0bdb7 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -1,5 +1,6 @@ import { motion } from "framer-motion"; +import { CertificateTemplatesSection } from "./components/CertificateTemplatesSection"; import { CertificatesSection } from "./components"; export const CertificatesTab = () => { @@ -11,6 +12,7 @@ export const CertificatesTab = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > + ); diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx new file mode 100644 index 000000000..d5b3921c5 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx @@ -0,0 +1,234 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useCreateCertTemplate, + useGetCertTemplate, + useListWorkspaceCas, + useUpdateCertTemplate +} from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + caId: z.string(), + name: z.string().min(1), + commonName: z.string().trim().min(1), + ttl: z.string().trim().min(1) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificateTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateTemplate"]>, + state?: boolean + ) => void; +}; + +export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: certTemplate } = useGetCertTemplate( + (popUp?.certificateTemplate?.data as { id: string })?.id || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { mutateAsync: createCertTemplate } = useCreateCertTemplate(); + const { mutateAsync: updateCertTemplate } = useUpdateCertTemplate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (certTemplate) { + reset({ + caId: certTemplate.caId, + name: certTemplate.name, + commonName: certTemplate.commonName, + ttl: certTemplate.ttl + }); + } else { + reset({ + caId: "", + name: "", + commonName: "", + ttl: "" + }); + } + }, [certTemplate]); + + const onFormSubmit = async ({ caId, name, commonName, ttl }: FormData) => { + if (!currentWorkspace?.id) { + return; + } + + try { + if (certTemplate) { + await updateCertTemplate({ + id: certTemplate.id, + projectId: currentWorkspace.id, + caId, + name, + commonName, + ttl + }); + + createNotification({ + text: "Successfully updated certificate template", + type: "success" + }); + } else { + await createCertTemplate({ + projectId: currentWorkspace.id, + caId, + name, + commonName, + ttl + }); + + createNotification({ + text: "Successfully created certificate template", + type: "success" + }); + } + + reset(); + handlePopUpToggle("certificateTemplate", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateTemplate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx new file mode 100644 index 000000000..82c4c8d3a --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx @@ -0,0 +1,87 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteCertTemplate } from "@app/hooks/api"; + +import { CertificateTemplateModal } from "./CertificateTemplateModal"; +import { CertificateTemplatesTable } from "./CertificateTemplatesTable"; + +export const CertificateTemplatesSection = () => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateTemplate", + "deleteCertificateTemplate" + ] as const); + + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCertTemplate } = useDeleteCertTemplate(); + + const onRemoveCertificateTemplateSubmit = async (id: string) => { + if (!currentWorkspace?.id) { + return; + } + + try { + await deleteCertTemplate({ + id, + projectId: currentWorkspace.id + }); + + await createNotification({ + text: "Successfully deleted certificate template", + type: "success" + }); + + handlePopUpClose("deleteCertificateTemplate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate template", + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Templates

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteCertificateTemplate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateTemplateSubmit( + (popUp?.deleteCertificateTemplate?.data as { id: string })?.id + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx new file mode 100644 index 000000000..a8042337e --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx @@ -0,0 +1,117 @@ +import { faEllipsis, faGear, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificateTemplates } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["certificateTemplate", "deleteCertificateTemplate"]>, + data?: { + id?: string; + name?: string; + } + ) => void; +}; + +export const CertificateTemplatesTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificateTemplates({ + workspaceId: currentWorkspace?.id ?? "" + }); + + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificateTemplates.map((certificateTemplate) => { + return ( + + + + + + ); + })} + +
NameCertificate Authority +
{certificateTemplate.name}{certificateTemplate.caName} + + +
+ + + +
+
+ + + handlePopUpOpen("certificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Manage + + + {(isAllowed) => ( + } + onClick={() => + handlePopUpOpen("deleteCertificateTemplate", { + id: certificateTemplate.id, + name: certificateTemplate.name + }) + } + > + Delete Template + + )} + + +
+
+ {!isLoading && !data?.certificateTemplates?.length && ( + + )} +
+
+ ); +};