diff --git a/frontend-v2/src/const/routes.ts b/frontend-v2/src/const/routes.ts
index 913b3f60d..7d987d72c 100644
--- a/frontend-v2/src/const/routes.ts
+++ b/frontend-v2/src/const/routes.ts
@@ -39,5 +39,8 @@ export const ROUTE_PATHS = Object.freeze({
SecretDashboardPage: setRoute(
"/secret-manager/$projectId/secrets/$envSlug",
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/"
- )
+ ),
+ CertAuthDetailsByIDPage: setRoute("cert-auth", "cert-auth"),
+ CertCertificatesPage: setRoute("cert-list", "cert-list"),
+ CertPkiCollectionDetailsByIDPage: setRoute("cert-pki-collection", "cert-pki-collection")
});
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx
new file mode 100644
index 000000000..2552a57c6
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx
@@ -0,0 +1,168 @@
+import { Helmet } from "react-helmet";
+import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useNavigate, useParams } from "@tanstack/react-router";
+import { twMerge } from "tailwind-merge";
+
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Button,
+ DeleteActionModal,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Tooltip
+} from "@app/components/v2";
+import { ROUTE_PATHS } from "@app/const/routes";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { useDeleteCa, useGetCaById } from "@app/hooks/api";
+import { ProjectType } from "@app/hooks/api/workspace/types";
+import { usePopUp } from "@app/hooks/usePopUp";
+
+import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal";
+import { CaModal } from "../CertificatesPage/components/CaTab/components/CaModal";
+import { CertificateTemplatesSection } from "../CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection";
+import {
+ CaCertificatesSection,
+ CaCrlsSection,
+ CaDetailsSection,
+ CaRenewalModal
+} from "./components";
+
+const Page = () => {
+ const navigate = useNavigate();
+ const params = useParams({
+ from: ROUTE_PATHS.CertAuthDetailsByIDPage.id
+ });
+ const caId = params.caId as string;
+ const { data } = useGetCaById(caId);
+
+ const { currentWorkspace } = useWorkspace();
+ const projectId = currentWorkspace?.id || "";
+
+ const { mutateAsync: deleteCa } = useDeleteCa();
+
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "ca",
+ "deleteCa",
+ "installCaCert",
+ "renewCa"
+ ] as const);
+
+ const onRemoveCaSubmit = async (caIdToDelete: string) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ await deleteCa({ caId: caIdToDelete, projectSlug: currentWorkspace.slug });
+
+ createNotification({
+ text: "Successfully deleted CA",
+ type: "success"
+ });
+
+ handlePopUpClose("deleteCa");
+ navigate({ to: `/${ProjectType.CertificateManager}/${projectId}/certificates` as const });
+ } catch {
+ createNotification({
+ text: "Failed to delete CA",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+ {data && (
+
+
}
+ onClick={() =>
+ navigate({
+ to: `/${ProjectType.CertificateManager}/${projectId}/certificates` as const
+ })
+ }
+ className="mb-4"
+ >
+ Certificate Authorities
+
+
+
{data.friendlyName}
+
+
+
+
+
+
+
+
+
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("deleteCa", {
+ caId: data.id,
+ dn: data.dn
+ })
+ }
+ disabled={!isAllowed}
+ >
+ Delete CA
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+
+
handlePopUpToggle("deleteCa", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
+ />
+
+ );
+};
+
+export const CertAuthDetailsByIDPage = () => {
+ return (
+ <>
+
+ Certificate Authority
+
+
+
+ >
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx
new file mode 100644
index 000000000..23bfd269b
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx
@@ -0,0 +1,28 @@
+import { CaCertificatesTable } from "./CaCertificatesTable";
+
+type Props = {
+ caId: string;
+};
+
+export const CaCertificatesSection = ({ caId }: Props) => {
+ return (
+
+
+
CA Certificates
+ {/* {
+ // handlePopUpOpen("addIdentityToProject");
+ }}
+ >
+
+ */}
+
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx
new file mode 100644
index 000000000..e0d7c1740
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx
@@ -0,0 +1,133 @@
+import { faCertificate, faEllipsis } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import * as x509 from "@peculiar/x509";
+import { format } from "date-fns";
+import FileSaver from "file-saver";
+import { twMerge } from "tailwind-merge";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Badge,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { useGetCaCerts } from "@app/hooks/api";
+
+type Props = {
+ caId: string;
+};
+
+export const CaCertificatesTable = ({ caId }: Props) => {
+ const { data: caCerts, isPending } = useGetCaCerts(caId);
+
+ const downloadTxtFile = (filename: string, content: string) => {
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ FileSaver.saveAs(blob, filename);
+ };
+
+ return (
+
+
+
+
+ | CA Certificate # |
+ Not Before |
+ Not After |
+ |
+
+
+
+ {isPending && }
+ {!isPending &&
+ caCerts?.map((caCert, index) => {
+ const isLastItem = index === caCerts.length - 1;
+ const caCertObj = new x509.X509Certificate(caCert.certificate);
+ return (
+
+ |
+
+ CA Certificate {caCert.version}
+ {isLastItem && (
+
+ Current
+
+ )}
+
+ |
+ {format(new Date(caCertObj.notBefore), "yyyy-MM-dd")} |
+ {format(new Date(caCertObj.notAfter), "yyyy-MM-dd")} |
+
+
+
+
+
+
+
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ downloadTxtFile("cert.pem", caCert.certificate);
+ }}
+ disabled={!isAllowed}
+ >
+ Download CA Certificate
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ downloadTxtFile("chain.pem", caCert.certificateChain);
+ }}
+ disabled={!isAllowed}
+ >
+ Download CA Certificate Chain
+
+ )}
+
+
+
+ |
+
+ );
+ })}
+
+
+ {!isPending && !caCerts?.length && (
+
+ )}
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/index.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/index.tsx
new file mode 100644
index 000000000..14f5dbbb9
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/index.tsx
@@ -0,0 +1 @@
+export { CaCertificatesSection } from "./CaCertificatesSection";
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/CaCrlsSection.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/CaCrlsSection.tsx
new file mode 100644
index 000000000..6778a77a3
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/CaCrlsSection.tsx
@@ -0,0 +1,20 @@
+import { CaCrlsTable } from "./CaCrlsTable";
+
+type Props = {
+ caId: string;
+};
+
+export const CaCrlsSection = ({ caId }: Props) => {
+ return (
+
+
+
+ CA Certificate Revocation Lists (CRLs)
+
+
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/CaCrlsTable.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/CaCrlsTable.tsx
new file mode 100644
index 000000000..dfe87d027
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/CaCrlsTable.tsx
@@ -0,0 +1,88 @@
+import { faCertificate, faFileDownload } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+// import * as x509 from "@peculiar/x509";
+// import { format } from "date-fns";
+import FileSaver from "file-saver";
+
+import {
+ EmptyState,
+ IconButton,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { useGetCaCrls } from "@app/hooks/api";
+
+type Props = {
+ caId: string;
+};
+
+export const CaCrlsTable = ({ caId }: Props) => {
+ const { data: caCrls, isPending } = useGetCaCrls(caId);
+
+ const downloadTxtFile = (filename: string, content: string) => {
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ FileSaver.saveAs(blob, filename);
+ };
+
+ return (
+
+
+
+
+ | Distribution Point URL |
+ {/* This Update | */}
+ {/* Next Update | */}
+ |
+
+
+
+ {isPending && }
+ {!isPending &&
+ caCrls?.map(({ id, crl }) => {
+ // const caCrlObj = new x509.X509Crl(crl);
+ return (
+
+ |
+
+ {`${window.origin}/api/v1/pki/crl/${id}`}
+
+ |
+ {/* {format(new Date(caCrlObj.thisUpdate), "yyyy-MM-dd")} | */}
+ {/*
+ {caCrlObj.nextUpdate
+ ? format(new Date(caCrlObj.nextUpdate), "yyyy-MM-dd")
+ : "-"}
+ | */}
+
+
+ {
+ e.stopPropagation();
+ downloadTxtFile("crl.pem", crl);
+ }}
+ >
+
+
+
+ |
+
+ );
+ })}
+
+
+ {!isPending && !caCrls?.length && (
+
+ )}
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/index.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/index.tsx
new file mode 100644
index 000000000..a46d94d7b
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCrlsSection/index.tsx
@@ -0,0 +1 @@
+export { CaCrlsSection } from "./CaCrlsSection";
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx
new file mode 100644
index 000000000..06fa5d1d9
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaDetailsSection.tsx
@@ -0,0 +1,208 @@
+import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { format } from "date-fns";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { Button, IconButton, Tooltip } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { useTimedReset } from "@app/hooks";
+import { CaStatus, CaType, useGetCaById } from "@app/hooks/api";
+import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
+import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ caId: string;
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<["ca", "renewCa", "installCaCert"]>,
+ data?: object
+ ) => void;
+};
+
+export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
+ const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({
+ initialState: "Copy ID to clipboard"
+ });
+ const [copyTextParentId, isCopyingParentId, setCopyTextParentId] = useTimedReset({
+ initialState: "Copy ID to clipboard"
+ });
+
+ const { data: ca } = useGetCaById(caId);
+
+ return ca ? (
+
+
+
CA Details
+
+ {(isAllowed) => {
+ return (
+
+ {
+ e.stopPropagation();
+ handlePopUpOpen("ca", {
+ caId: ca.id
+ });
+ }}
+ >
+
+
+
+ );
+ }}
+
+
+
+
+
CA Type
+
{caTypeToNameMap[ca.type]}
+
+
+
CA ID
+
+
{ca.id}
+
+
+ {
+ navigator.clipboard.writeText(ca.id);
+ setCopyTextId("Copied");
+ }}
+ >
+
+
+
+
+
+
+ {ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (
+
+
Parent CA ID
+
+
+ {ca.parentCaId ? ca.parentCaId : "N/A - External Parent CA"}
+
+ {ca.parentCaId && (
+
+
+ {
+ navigator.clipboard.writeText(ca.parentCaId as string);
+ setCopyTextParentId("Copied");
+ }}
+ >
+
+
+
+
+ )}
+
+
+ )}
+
+
Friendly Name
+
{ca.friendlyName}
+
+
+
Status
+
{caStatusToNameMap[ca.status]}
+
+
+
Key Algorithm
+
{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}
+
+
+
Max Path Length
+
{ca.maxPathLength ?? "-"}
+
+
+
Not Before
+
+ {ca.notBefore ? format(new Date(ca.notBefore), "yyyy-MM-dd") : "-"}
+
+
+
+
Not After
+
+ {ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}
+
+
+
+
Template Issuance Required
+
+ {ca.requireTemplateForIssuance ? "True" : "False"}
+
+
+ {ca.status === CaStatus.ACTIVE && (
+
+ {(isAllowed) => {
+ return (
+
+ );
+ }}
+
+ )}
+ {ca.status === CaStatus.PENDING_CERTIFICATE && (
+
+ {(isAllowed) => {
+ return (
+
+ );
+ }}
+
+ )}
+
+
+ ) : (
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaRenewalModal.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaRenewalModal.tsx
new file mode 100644
index 000000000..6f9cf3a1c
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaRenewalModal.tsx
@@ -0,0 +1,182 @@
+// 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 {
+ CaRenewalType,
+ useRenewCa
+ // useGetCaById,
+ // CaType,
+ // CaStatus
+} from "@app/hooks/api/ca";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+const caRenewalTypes = [{ label: "Renew with same key pair", value: CaRenewalType.EXISTING }];
+
+const isValidDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return !Number.isNaN(date.getTime());
+};
+
+const schema = z
+ .object({
+ type: z.enum([CaRenewalType.EXISTING]),
+ notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" })
+ })
+ .required();
+
+export type FormData = z.infer;
+
+type Props = {
+ popUp: UsePopUpState<["renewCa"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["renewCa"]>, state?: boolean) => void;
+};
+
+export const CaRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const projectSlug = currentWorkspace?.slug || "";
+
+ const popUpData = popUp?.renewCa?.data as {
+ caId: string;
+ };
+
+ // const { data: ca } = useGetCaById(popUpData?.caId || "");
+ // const { data: parentCa } = useGetCaById(ca?.parentCaId || "");
+ const { mutateAsync: renewCa } = useRenewCa();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ // setValue
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ type: CaRenewalType.EXISTING,
+ notAfter: "" // TODO: consider setting a default value
+ }
+ });
+
+ // useEffect(() => {
+ // if (ca && ca.status === CaStatus.ACTIVE) {
+ // const notBeforeDate = new Date(ca.notBefore as string);
+ // const notAfterDate = new Date(ca.notAfter as string);
+
+ // const newNotAfterDate = new Date(
+ // notAfterDate.getTime() + notAfterDate.getTime() - notBeforeDate.getTime()
+ // );
+
+ // setValue("notAfter", newNotAfterDate.toISOString().split("T")[0]);
+ // }
+ // }, [ca, parentCa]);
+
+ const onFormSubmit = async ({ type, notAfter }: FormData) => {
+ try {
+ if (!projectSlug || !popUpData.caId) return;
+
+ await renewCa({
+ projectSlug,
+ caId: popUpData.caId,
+ notAfter,
+ type
+ });
+
+ handlePopUpToggle("renewCa", false);
+
+ createNotification({
+ text: "Successfully renewed CA",
+ type: "success"
+ });
+
+ reset();
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+ {
+ handlePopUpToggle("renewCa", isOpen);
+ reset();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/index.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/index.tsx
new file mode 100644
index 000000000..95a4868f2
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/components/index.tsx
@@ -0,0 +1,4 @@
+export { CaCertificatesSection } from "./CaCertificatesSection/CaCertificatesSection";
+export { CaCrlsSection } from "./CaCrlsSection";
+export { CaDetailsSection } from "./CaDetailsSection";
+export { CaRenewalModal } from "./CaRenewalModal";
diff --git a/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx
new file mode 100644
index 000000000..17f0f27e2
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { CertAuthDetailsByIDPage } from "./CertAuthDetailsByIDPage";
+
+export const Route = createFileRoute("")({
+ component: CertAuthDetailsByIDPage
+});
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx
new file mode 100644
index 000000000..e18782cdc
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx
@@ -0,0 +1,67 @@
+import { Helmet } from "react-helmet";
+import { useTranslation } from "react-i18next";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+
+import { CaTab, CertificatesTab, PkiAlertsTab } from "./components";
+
+enum TabSections {
+ Ca = "certificate-authorities",
+ Certificates = "certificates",
+ Alerting = "alerting"
+}
+
+export const CertificatesPage = () => {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("common.head-title", { title: "Certificates" })}
+
+
+
+
+
Internal PKI
+
+
+ Certificates
+ Certificate Authorities
+ Alerting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/GroupsTab.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/CaTab.tsx
similarity index 67%
rename from frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/GroupsTab.tsx
rename to frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/CaTab.tsx
index 19fb35773..aadca4dfe 100644
--- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/GroupsTab.tsx
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/CaTab.tsx
@@ -1,18 +1,17 @@
import { motion } from "framer-motion";
-import { GroupsSection } from "./components";
+import { CaSection } from "./components";
-export const GroupsTab = () => {
+export const CaTab = () => {
return (
-
+
);
};
-
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaCertModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaCertModal.tsx
new file mode 100644
index 000000000..97970d94f
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaCertModal.tsx
@@ -0,0 +1,34 @@
+import { Modal, ModalContent } from "@app/components/v2";
+import { useGetCaCert } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { CertificateContent } from "../../CertificatesTab/components/CertificateContent";
+
+type Props = {
+ popUp: UsePopUpState<["caCert"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCert"]>, state?: boolean) => void;
+};
+
+export const CaCertModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { data } = useGetCaCert((popUp?.caCert?.data as { caId: string })?.caId || "");
+ return (
+ {
+ handlePopUpToggle("caCert", isOpen);
+ }}
+ >
+
+ {data ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx
new file mode 100644
index 000000000..1095e0d3e
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react";
+
+import { FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { ExternalCaInstallForm } from "./ExternalCaInstallForm";
+import { InternalCaInstallForm } from "./InternalCaInstallForm";
+
+type Props = {
+ popUp: UsePopUpState<["installCaCert"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void;
+};
+
+enum ParentCaType {
+ Internal = "internal",
+ External = "external"
+}
+
+export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const popupData = popUp?.installCaCert?.data;
+ const caId = popupData?.caId || "";
+ const isParentCaExternal = popupData?.isParentCaExternal || false;
+ const [parentCaType, setParentCaType] = useState(ParentCaType.Internal);
+
+ useEffect(() => {
+ if (popupData?.isParentCaExternal) {
+ setParentCaType(ParentCaType.External);
+ }
+ }, [popupData]);
+
+ const renderForm = (parentCaTypeInput: ParentCaType) => {
+ switch (parentCaTypeInput) {
+ case ParentCaType.Internal:
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+ {
+ handlePopUpToggle("installCaCert", isOpen);
+ }}
+ >
+
+
+
+
+ {renderForm(parentCaType)}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx
new file mode 100644
index 000000000..8b3ddd0cf
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx
@@ -0,0 +1,172 @@
+import { useEffect } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import FileSaver from "file-saver";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import { Button, FormControl, IconButton, TextArea, Tooltip } from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import { useTimedReset } from "@app/hooks";
+import { useGetCaCsr, useImportCaCertificate } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+const schema = z.object({
+ certificate: z.string().min(1),
+ certificateChain: z.string().min(1)
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ caId: string;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void;
+};
+
+export const ExternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const [copyTextCaCsr, isCopyingCaCsr, setCopyTextCaCsr] = useTimedReset({
+ initialState: "Copy to clipboard"
+ });
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema)
+ });
+
+ const { data: csr } = useGetCaCsr(caId);
+ const { mutateAsync: importCaCertificate } = useImportCaCertificate();
+
+ useEffect(() => {
+ reset();
+ }, []);
+
+ const onFormSubmit = async ({ certificate, certificateChain }: FormData) => {
+ try {
+ if (!csr || !caId || !currentWorkspace?.slug) return;
+
+ await importCaCertificate({
+ caId,
+ projectSlug: currentWorkspace?.slug,
+ certificate,
+ certificateChain
+ });
+
+ reset();
+
+ createNotification({
+ text: "Successfully installed certificate for CA",
+ type: "success"
+ });
+ handlePopUpToggle("installCaCert", false);
+ } catch (err) {
+ createNotification({
+ text: "Failed to install certificate for CA",
+ type: "error"
+ });
+ }
+ };
+
+ const downloadTxtFile = (filename: string, content: string) => {
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ FileSaver.saveAs(blob, filename);
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/InternalCaInstallForm.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/InternalCaInstallForm.tsx
new file mode 100644
index 000000000..ddde187cb
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/InternalCaInstallForm.tsx
@@ -0,0 +1,236 @@
+import { useEffect } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { format } from "date-fns";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import {
+ CaStatus,
+ useGetCaById,
+ useGetCaCsr,
+ useImportCaCertificate,
+ useListWorkspaceCas,
+ useSignIntermediate
+} from "@app/hooks/api";
+import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+const isValidDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return !Number.isNaN(date.getTime());
+};
+
+const getMiddleDate = (date1: Date, date2: Date) => {
+ const timestamp1 = date1.getTime();
+ const timestamp2 = date2.getTime();
+
+ const middleTimestamp = (timestamp1 + timestamp2) / 2;
+
+ return new Date(middleTimestamp);
+};
+
+const schema = z.object({
+ parentCaId: z.string(),
+ notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
+ maxPathLength: z.string()
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ caId: string;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void;
+};
+
+export const InternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const { data: cas } = useListWorkspaceCas({
+ projectSlug: currentWorkspace?.slug ?? "",
+ status: CaStatus.ACTIVE
+ });
+ const { data: ca } = useGetCaById(caId);
+ const { data: csr } = useGetCaCsr(caId);
+
+ const { mutateAsync: signIntermediate } = useSignIntermediate();
+ const { mutateAsync: importCaCertificate } = useImportCaCertificate();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting },
+ setValue,
+ watch
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ maxPathLength: "0"
+ }
+ });
+
+ useEffect(() => {
+ reset();
+ }, []);
+
+ useEffect(() => {
+ if (cas?.length) {
+ setValue("parentCaId", cas[0].id);
+ }
+ }, [cas, setValue]);
+
+ const parentCaId = watch("parentCaId");
+
+ const { data: parentCa } = useGetCaById(parentCaId);
+
+ useEffect(() => {
+ if (parentCa?.maxPathLength) {
+ setValue(
+ "maxPathLength",
+ (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString()
+ );
+ }
+
+ if (parentCa?.notAfter) {
+ const parentCaNotAfter = new Date(parentCa.notAfter);
+ const middleDate = getMiddleDate(new Date(), parentCaNotAfter);
+ setValue("notAfter", format(middleDate, "yyyy-MM-dd"));
+ }
+ }, [parentCa]);
+
+ const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => {
+ try {
+ if (!csr || !caId || !currentWorkspace?.slug) return;
+
+ const { certificate, certificateChain } = await signIntermediate({
+ caId: parentCaId,
+ csr,
+ maxPathLength: Number(maxPathLength),
+ notAfter,
+ notBefore: new Date().toISOString()
+ });
+
+ await importCaCertificate({
+ caId,
+ projectSlug: currentWorkspace?.slug,
+ certificate,
+ certificateChain
+ });
+
+ reset();
+
+ createNotification({
+ text: "Successfully installed certificate for CA",
+ type: "success"
+ });
+ handlePopUpToggle("installCaCert", false);
+ } catch (err) {
+ createNotification({
+ text: "Failed to install certificate for CA",
+ type: "error"
+ });
+ }
+ };
+
+ function generatePathLengthOpts(parentCaMaxPathLength: number): number[] {
+ if (parentCaMaxPathLength === -1) {
+ return [-1, 0, 1, 2, 3];
+ }
+
+ return Array.from({ length: parentCaMaxPathLength }, (_, index) => index);
+ }
+
+ return (
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx
new file mode 100644
index 000000000..9ad602c06
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx
@@ -0,0 +1 @@
+export { CaInstallCertModal } from "./CaInstallCertModal";
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaModal.tsx
new file mode 100644
index 000000000..c27d4dc39
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaModal.tsx
@@ -0,0 +1,471 @@
+import { useEffect } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { format } from "date-fns";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ FormControl,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ Switch
+ // DatePicker
+} from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
+import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
+import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+const isValidDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return !Number.isNaN(date.getTime());
+};
+
+const getDateTenYearsFromToday = () => {
+ const date = new Date();
+ date.setFullYear(date.getFullYear() + 10);
+ return format(date, "yyyy-MM-dd");
+};
+
+const schema = z
+ .object({
+ type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]),
+ friendlyName: z.string(),
+ organization: z.string(),
+ ou: z.string(),
+ country: z.string(),
+ province: z.string(),
+ locality: z.string(),
+ commonName: z.string(),
+ notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
+ maxPathLength: z.string(),
+ keyAlgorithm: z.enum([
+ CertKeyAlgorithm.RSA_2048,
+ CertKeyAlgorithm.RSA_4096,
+ CertKeyAlgorithm.ECDSA_P256,
+ CertKeyAlgorithm.ECDSA_P384
+ ]),
+ requireTemplateForIssuance: z.boolean()
+ })
+ .required();
+
+export type FormData = z.infer;
+
+type Props = {
+ popUp: UsePopUpState<["ca"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void;
+};
+
+const caTypes = [
+ { label: "Root", value: CaType.ROOT },
+ { label: "Intermediate", value: CaType.INTERMEDIATE }
+];
+
+export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
+
+ const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || "");
+
+ const { mutateAsync: createMutateAsync } = useCreateCa();
+ const { mutateAsync: updateMutateAsync } = useUpdateCa();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting },
+ watch
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ type: CaType.ROOT,
+ friendlyName: "",
+ organization: "",
+ ou: "",
+ country: "",
+ province: "",
+ locality: "",
+ commonName: "",
+ notAfter: getDateTenYearsFromToday(),
+ maxPathLength: "-1",
+ keyAlgorithm: CertKeyAlgorithm.RSA_2048
+ }
+ });
+
+ const caType = watch("type");
+
+ useEffect(() => {
+ if (ca) {
+ reset({
+ type: ca.type,
+ friendlyName: ca.friendlyName,
+ organization: ca.organization,
+ ou: ca.ou,
+ country: ca.country,
+ province: ca.province,
+ locality: ca.locality,
+ commonName: ca.commonName,
+ notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "",
+ maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "",
+ keyAlgorithm: ca.keyAlgorithm,
+ requireTemplateForIssuance: ca.requireTemplateForIssuance
+ });
+ } else {
+ reset({
+ type: CaType.ROOT,
+ friendlyName: "",
+ organization: "",
+ ou: "",
+ country: "",
+ province: "",
+ locality: "",
+ commonName: "",
+ notAfter: getDateTenYearsFromToday(),
+ maxPathLength: "-1",
+ keyAlgorithm: CertKeyAlgorithm.RSA_2048,
+ requireTemplateForIssuance: true
+ });
+ }
+ }, [ca]);
+
+ const onFormSubmit = async ({
+ type,
+ friendlyName,
+ commonName,
+ organization,
+ ou,
+ country,
+ locality,
+ province,
+ notAfter,
+ maxPathLength,
+ keyAlgorithm,
+ requireTemplateForIssuance
+ }: FormData) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ if (ca) {
+ // update
+ await updateMutateAsync({
+ projectSlug: currentWorkspace.slug,
+ caId: ca.id,
+ requireTemplateForIssuance
+ });
+ } else {
+ // create
+ await createMutateAsync({
+ projectSlug: currentWorkspace.slug,
+ type,
+ friendlyName,
+ commonName,
+ organization,
+ ou,
+ country,
+ province,
+ locality,
+ notAfter,
+ maxPathLength: Number(maxPathLength),
+ keyAlgorithm,
+ requireTemplateForIssuance
+ });
+ }
+
+ reset();
+ handlePopUpToggle("ca", false);
+
+ createNotification({
+ text: `Successfully ${ca ? "updated" : "created"} CA`,
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to create CA",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+ {
+ reset();
+ handlePopUpToggle("ca", isOpen);
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaSection.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaSection.tsx
new file mode 100644
index 000000000..697e5d3c1
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaSection.tsx
@@ -0,0 +1,132 @@
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
+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 { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api";
+import { usePopUp } from "@app/hooks/usePopUp";
+
+import { CaCertModal } from "./CaCertModal";
+import { CaInstallCertModal } from "./CaInstallCertModal";
+import { CaModal } from "./CaModal";
+import { CaTable } from "./CaTable";
+
+export const CaSection = () => {
+ const { currentWorkspace } = useWorkspace();
+ const { mutateAsync: deleteCa } = useDeleteCa();
+ const { mutateAsync: updateCa } = useUpdateCa();
+
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "ca",
+ "caCert",
+ "installCaCert",
+ "deleteCa",
+ "caStatus", // enable / disable
+ "upgradePlan"
+ ] as const);
+
+ const onRemoveCaSubmit = async (caId: string) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ await deleteCa({ caId, projectSlug: currentWorkspace.slug });
+
+ createNotification({
+ text: "Successfully deleted CA",
+ type: "success"
+ });
+
+ handlePopUpClose("deleteCa");
+ } catch {
+ createNotification({
+ text: "Failed to delete CA",
+ type: "error"
+ });
+ }
+ };
+
+ const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ await updateCa({ caId, projectSlug: currentWorkspace.slug, status });
+
+ createNotification({
+ text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
+ type: "success"
+ });
+
+ handlePopUpClose("caStatus");
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+
Certificate Authorities
+
+ {(isAllowed) => (
+ }
+ onClick={() => handlePopUpOpen("ca")}
+ isDisabled={!isAllowed}
+ >
+ Create CA
+
+ )}
+
+
+
+
+
+
+
handlePopUpToggle("deleteCa", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
+ />
+ handlePopUpToggle("caStatus", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus })
+ }
+ />
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text={(popUp.upgradePlan?.data as { description: string })?.description}
+ />
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaTable.tsx
new file mode 100644
index 000000000..8acf45e81
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/CaTable.tsx
@@ -0,0 +1,223 @@
+import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useNavigate } from "@tanstack/react-router";
+import { format } from "date-fns";
+import { twMerge } from "tailwind-merge";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Badge,
+ 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 { CaStatus, useListWorkspaceCas } from "@app/hooks/api";
+import {
+ caStatusToNameMap,
+ caTypeToNameMap,
+ getCaStatusBadgeVariant
+} from "@app/hooks/api/ca/constants";
+import { ProjectType } from "@app/hooks/api/workspace/types";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<
+ ["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "upgradePlan"]
+ >,
+ data?: {
+ caId?: string;
+ dn?: string;
+ status?: CaStatus;
+ description?: string;
+ }
+ ) => void;
+};
+
+export const CaTable = ({ handlePopUpOpen }: Props) => {
+ const navigate = useNavigate();
+ const { currentWorkspace } = useWorkspace();
+ const { data, isPending } = useListWorkspaceCas({
+ projectSlug: currentWorkspace?.slug ?? ""
+ });
+
+ return (
+
+
+
+
+
+ | Friendly Name |
+ Status |
+ Type |
+ Valid Until |
+ |
+
+
+
+ {isPending && }
+ {!isPending &&
+ data &&
+ data.length > 0 &&
+ data.map((ca) => {
+ return (
+
+ navigate({
+ to: `/${ProjectType.CertificateManager}/${currentWorkspace?.id}/ca/${ca.id}` as const
+ })
+ }
+ >
+ | {ca.friendlyName} |
+
+
+ {caStatusToNameMap[ca.status]}
+
+ |
+ {caTypeToNameMap[ca.type]} |
+
+
+ {ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}
+
+ |
+
+
+
+
+
+
+
+
+
+
+ {ca.status === CaStatus.PENDING_CERTIFICATE && (
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("installCaCert", {
+ caId: ca.id
+ });
+ }}
+ disabled={!isAllowed}
+ icon={}
+ >
+ Install CA Certificate
+
+ )}
+
+ )}
+ {ca.status !== CaStatus.PENDING_CERTIFICATE && (
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("caCert", {
+ caId: ca.id
+ });
+ }}
+ disabled={!isAllowed}
+ icon={}
+ >
+ View Certificate
+
+ )}
+
+ )}
+ {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && (
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("caStatus", {
+ caId: ca.id,
+ status:
+ ca.status === CaStatus.ACTIVE
+ ? CaStatus.DISABLED
+ : CaStatus.ACTIVE
+ });
+ }}
+ disabled={!isAllowed}
+ icon={}
+ >
+ {`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`}
+
+ )}
+
+ )}
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("deleteCa", {
+ caId: ca.id,
+ dn: ca.dn
+ });
+ }}
+ disabled={!isAllowed}
+ icon={}
+ >
+ Delete CA
+
+ )}
+
+
+
+ |
+
+ );
+ })}
+
+
+ {!isPending && data?.length === 0 && (
+
+ )}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/index.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/index.tsx
new file mode 100644
index 000000000..1f23813bc
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/components/index.tsx
@@ -0,0 +1 @@
+export { CaSection } from "./CaSection";
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/index.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/index.tsx
new file mode 100644
index 000000000..9e52be028
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CaTab/index.tsx
@@ -0,0 +1 @@
+export { CaTab } from "./CaTab";
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx
new file mode 100644
index 000000000..0f74920ec
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx
@@ -0,0 +1,21 @@
+import { motion } from "framer-motion";
+
+import { PkiCollectionSection } from "../PkiAlertsTab/components";
+// import { CertificateTemplatesSection } from "./components/CertificateTemplatesSection";
+import { CertificatesSection } from "./components";
+
+export const CertificatesTab = () => {
+ return (
+
+
+ {/* */}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx
new file mode 100644
index 000000000..01c79589c
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx
@@ -0,0 +1,37 @@
+import { Modal, ModalContent } from "@app/components/v2";
+import { useGetCertBody } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { CertificateContent } from "./CertificateContent";
+
+type Props = {
+ popUp: UsePopUpState<["certificateCert"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void;
+};
+
+export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { data } = useGetCertBody(
+ (popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || ""
+ );
+
+ return (
+ {
+ handlePopUpToggle("certificateCert", isOpen);
+ }}
+ >
+
+ {data ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx
new file mode 100644
index 000000000..8c40af74f
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx
@@ -0,0 +1,173 @@
+import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import FileSaver from "file-saver";
+
+import { IconButton, Tooltip } from "@app/components/v2";
+import { useTimedReset } from "@app/hooks";
+
+type Props = {
+ serialNumber: string;
+ certificate: string;
+ certificateChain: string;
+ privateKey?: string;
+};
+
+export const CertificateContent = ({
+ serialNumber,
+ certificate,
+ certificateChain,
+ privateKey
+}: Props) => {
+ const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] =
+ useTimedReset({
+ initialState: "Copy to clipboard"
+ });
+ const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset(
+ {
+ initialState: "Copy to clipboard"
+ }
+ );
+ const [copyTextCertificateChain, isCopyingCertificateChain, setCopyTextCertificateChain] =
+ useTimedReset({
+ initialState: "Copy to clipboard"
+ });
+
+ const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] =
+ useTimedReset({
+ initialState: "Copy to clipboard"
+ });
+
+ const downloadTxtFile = (filename: string, content: string) => {
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
+ FileSaver.saveAs(blob, filename);
+ };
+
+ return (
+
+
Serial Number
+
+
{serialNumber}
+
+ {
+ navigator.clipboard.writeText(serialNumber);
+ setCopyTextSerialNumber("Copied");
+ }}
+ >
+
+
+
+
+
+
Certificate Body
+
+
+ {
+ navigator.clipboard.writeText(certificate);
+ setCopyTextCertificate("Copied");
+ }}
+ >
+
+
+
+
+ {
+ downloadTxtFile("cert.pem", certificate);
+ }}
+ >
+
+
+
+
+
+
+ {certificateChain && (
+ <>
+
+
Certificate Chain
+
+
+ {
+ navigator.clipboard.writeText(certificateChain);
+ setCopyTextCertificateChain("Copied");
+ }}
+ >
+
+
+
+
+ {
+ downloadTxtFile("chain.pem", certificateChain);
+ }}
+ >
+
+
+
+
+
+
+ >
+ )}
+ {privateKey && (
+ <>
+
+
Certificate Private Key
+
+
+ {
+ navigator.clipboard.writeText(privateKey);
+ setCopyTextCertificateSk("Copied");
+ }}
+ >
+
+
+
+
+ {
+ downloadTxtFile("private_key.txt", privateKey);
+ }}
+ >
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx
new file mode 100644
index 000000000..10c1fa4cd
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx
@@ -0,0 +1,538 @@
+import { useEffect, useState } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Button,
+ Checkbox,
+ FormControl,
+ FormLabel,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ Tooltip
+} from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import {
+ CaStatus,
+ useCreateCertificate,
+ useGetCert,
+ useGetCertTemplate,
+ useListWorkspaceCas,
+ useListWorkspaceCertificateTemplates,
+ useListWorkspacePkiCollections
+} from "@app/hooks/api";
+import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
+import {
+ EXTENDED_KEY_USAGES_OPTIONS,
+ KEY_USAGES_OPTIONS
+} from "@app/hooks/api/certificates/constants";
+import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { CertificateContent } from "./CertificateContent";
+
+const schema = z.object({
+ certificateTemplateId: z.string().optional(),
+ caId: z.string(),
+ collectionId: z.string().optional(),
+ friendlyName: z.string(),
+ commonName: z.string().trim().min(1),
+ altNames: z.string(),
+ ttl: z.string().trim(),
+ keyUsages: z.object({
+ [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
+ [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
+ [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
+ [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
+ [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
+ [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
+ [CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
+ [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
+ [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
+ }),
+ extendedKeyUsages: z.object({
+ [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
+ [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
+ [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
+ [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
+ [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
+ [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
+ })
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ popUp: UsePopUpState<["certificate"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void;
+};
+
+type TCertificateDetails = {
+ serialNumber: string;
+ certificate: string;
+ certificateChain: string;
+ privateKey: string;
+};
+
+const CERT_TEMPLATE_NONE_VALUE = "none";
+
+export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const [certificateDetails, setCertificateDetails] = useState(null);
+ const { currentWorkspace } = useWorkspace();
+ const { data: cert } = useGetCert(
+ (popUp?.certificate?.data as { serialNumber: string })?.serialNumber || ""
+ );
+
+ const { data: cas } = useListWorkspaceCas({
+ projectSlug: currentWorkspace?.slug ?? "",
+ status: CaStatus.ACTIVE
+ });
+
+ const { data } = useListWorkspacePkiCollections({
+ workspaceId: currentWorkspace?.id || ""
+ });
+
+ const { data: templatesData } = useListWorkspaceCertificateTemplates({
+ workspaceId: currentWorkspace?.id || ""
+ });
+
+ const { mutateAsync: createCertificate } = useCreateCertificate();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting },
+ setValue,
+ watch
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ keyUsages: {
+ [CertKeyUsage.DIGITAL_SIGNATURE]: true,
+ [CertKeyUsage.KEY_ENCIPHERMENT]: true
+ },
+ extendedKeyUsages: {}
+ }
+ });
+
+ const selectedCertTemplateId = watch("certificateTemplateId");
+ const hasCertTemplateSelected =
+ selectedCertTemplateId !== "" && selectedCertTemplateId !== CERT_TEMPLATE_NONE_VALUE;
+
+ const { data: selectedCertTemplate } = useGetCertTemplate(
+ hasCertTemplateSelected ? (selectedCertTemplateId as string) : ""
+ );
+
+ useEffect(() => {
+ if (cert) {
+ reset({
+ caId: cert.caId,
+ friendlyName: cert.friendlyName,
+ commonName: cert.commonName,
+ altNames: cert.altNames,
+ certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
+ ttl: "",
+ keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
+ extendedKeyUsages: Object.fromEntries(
+ (cert.extendedKeyUsages || []).map((name) => [name, true])
+ )
+ });
+ } else {
+ reset({
+ caId: "",
+ friendlyName: "",
+ commonName: "",
+ altNames: "",
+ ttl: "",
+ certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
+ keyUsages: {
+ [CertKeyUsage.DIGITAL_SIGNATURE]: true,
+ [CertKeyUsage.KEY_ENCIPHERMENT]: true
+ },
+ extendedKeyUsages: {}
+ });
+ }
+ }, [cert]);
+
+ useEffect(() => {
+ if (!cert && selectedCertTemplate) {
+ setValue("ttl", selectedCertTemplate.ttl);
+ setValue(
+ "keyUsages",
+ Object.fromEntries(selectedCertTemplate.keyUsages.map((name) => [name, true]))
+ );
+ setValue(
+ "extendedKeyUsages",
+ Object.fromEntries(selectedCertTemplate.extendedKeyUsages.map((name) => [name, true]))
+ );
+ }
+ }, [selectedCertTemplate, cert]);
+
+ const onFormSubmit = async ({
+ caId,
+ friendlyName,
+ collectionId,
+ commonName,
+ altNames,
+ ttl,
+ keyUsages,
+ extendedKeyUsages
+ }: FormData) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
+ caId: !selectedCertTemplate ? caId : undefined,
+ certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
+ projectSlug: currentWorkspace.slug,
+ pkiCollectionId: collectionId,
+ friendlyName,
+ commonName,
+ altNames,
+ ttl,
+ keyUsages: Object.entries(keyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertKeyUsage),
+ extendedKeyUsages: Object.entries(extendedKeyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertExtendedKeyUsage)
+ });
+
+ reset();
+
+ setCertificateDetails({
+ serialNumber,
+ certificate,
+ certificateChain,
+ privateKey
+ });
+
+ createNotification({
+ text: "Successfully created certificate",
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to create certificate",
+ type: "error"
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (cas?.length) {
+ setValue("caId", cas[0].id);
+ }
+ }, [cas]);
+
+ return (
+ {
+ handlePopUpToggle("certificate", isOpen);
+ reset();
+ setCertificateDetails(null);
+ }}
+ >
+
+ {!certificateDetails ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx
new file mode 100644
index 000000000..2f14fda3f
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx
@@ -0,0 +1,131 @@
+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, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import { useRevokeCert } from "@app/hooks/api";
+import { crlReasons } from "@app/hooks/api/certificates/constants";
+import { CrlReason } from "@app/hooks/api/certificates/enums";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+const schema = z.object({
+ revocationReason: z.enum([
+ CrlReason.UNSPECIFIED,
+ CrlReason.KEY_COMPROMISE,
+ CrlReason.CA_COMPROMISE,
+ CrlReason.AFFILIATION_CHANGED,
+ CrlReason.SUPERSEDED,
+ CrlReason.CESSATION_OF_OPERATION,
+ CrlReason.CERTIFICATE_HOLD,
+ CrlReason.PRIVILEGE_WITHDRAWN,
+ CrlReason.A_A_COMPROMISE
+ ])
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ popUp: UsePopUpState<["revokeCertificate"]>;
+ handlePopUpToggle: (
+ popUpName: keyof UsePopUpState<["revokeCertificate"]>,
+ state?: boolean
+ ) => void;
+};
+
+export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const { mutateAsync: revokeCertificate } = useRevokeCert();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema)
+ });
+
+ const onFormSubmit = async ({ revocationReason }: FormData) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ const { serialNumber } = popUp.revokeCertificate.data as { serialNumber: string };
+
+ await revokeCertificate({
+ projectSlug: currentWorkspace.slug,
+ serialNumber,
+ revocationReason
+ });
+
+ reset();
+ handlePopUpToggle("revokeCertificate", false);
+
+ createNotification({
+ text: "Successfully revoked certificate",
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to revoke certificate",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+ {
+ handlePopUpToggle("revokeCertificate", isOpen);
+ reset();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx
new file mode 100644
index 000000000..72be07e2a
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx
@@ -0,0 +1,261 @@
+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,
+ Switch,
+ TextArea
+} from "@app/components/v2";
+import { useToggle } from "@app/hooks";
+import { useCreateEstConfig, useGetEstConfig, useUpdateEstConfig } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+enum EnrollmentMethod {
+ EST = "est"
+}
+
+type Props = {
+ popUp: UsePopUpState<["enrollmentOptions"]>;
+ handlePopUpToggle: (
+ popUpName: keyof UsePopUpState<["enrollmentOptions"]>,
+ state?: boolean
+ ) => void;
+};
+
+const schema = z.object({
+ method: z.nativeEnum(EnrollmentMethod),
+ caChain: z.string().optional(),
+ passphrase: z.string().optional(),
+ isEnabled: z.boolean(),
+ disableBootstrapCertValidation: z.boolean().optional().default(false)
+});
+
+export type FormData = z.infer;
+
+export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const popUpData = popUp?.enrollmentOptions?.data as {
+ id: string;
+ };
+ const certificateTemplateId = popUpData?.id;
+
+ const { data } = useGetEstConfig(certificateTemplateId);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ setError,
+ watch,
+ setValue,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema)
+ });
+
+ const { mutateAsync: createEstConfig } = useCreateEstConfig();
+ const { mutateAsync: updateEstConfig } = useUpdateEstConfig();
+ const [isPassphraseFocused, setIsPassphraseFocused] = useToggle(false);
+
+ const disableBootstrapCertValidation = watch("disableBootstrapCertValidation");
+
+ useEffect(() => {
+ if (disableBootstrapCertValidation) {
+ setValue("caChain", "");
+ }
+ }, [disableBootstrapCertValidation]);
+
+ useEffect(() => {
+ if (data) {
+ reset({
+ caChain: data.caChain,
+ isEnabled: data.isEnabled,
+ disableBootstrapCertValidation: data.disableBootstrapCertValidation
+ });
+ } else {
+ reset({
+ caChain: "",
+ isEnabled: false,
+ disableBootstrapCertValidation: false
+ });
+ }
+ }, [data]);
+
+ const onFormSubmit = async ({ caChain, passphrase, isEnabled }: FormData) => {
+ try {
+ if (data) {
+ await updateEstConfig({
+ certificateTemplateId,
+ caChain,
+ passphrase,
+ isEnabled,
+ disableBootstrapCertValidation
+ });
+ } else {
+ if (!passphrase) {
+ setError("passphrase", { message: "Passphrase is required to setup EST" });
+ return;
+ }
+
+ await createEstConfig({
+ certificateTemplateId,
+ caChain,
+ passphrase,
+ isEnabled,
+ disableBootstrapCertValidation
+ });
+ }
+
+ handlePopUpToggle("enrollmentOptions", false);
+
+ createNotification({
+ text: "Successfully saved changes",
+ type: "success"
+ });
+
+ reset();
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+ {
+ handlePopUpToggle("enrollmentOptions", isOpen);
+ reset();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx
new file mode 100644
index 000000000..c7fba88b7
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx
@@ -0,0 +1,493 @@
+import { useEffect } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Button,
+ Checkbox,
+ FormControl,
+ FormLabel,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ Tooltip
+} from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import {
+ CaStatus,
+ useCreateCertTemplate,
+ useGetCaById,
+ useGetCertTemplate,
+ useListWorkspaceCas,
+ useListWorkspacePkiCollections,
+ useUpdateCertTemplate
+} from "@app/hooks/api";
+import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
+import {
+ EXTENDED_KEY_USAGES_OPTIONS,
+ KEY_USAGES_OPTIONS
+} from "@app/hooks/api/certificates/constants";
+import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+const validateTemplateRegexField = z
+ .string()
+ .trim()
+ .min(1)
+ .max(100)
+ .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, {
+ message:
+ "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
+ });
+
+const schema = z.object({
+ caId: z.string(),
+ collectionId: z.string().optional(),
+ name: z.string().min(1),
+ commonName: validateTemplateRegexField,
+ subjectAlternativeName: validateTemplateRegexField,
+ ttl: z.string().trim().min(1),
+ keyUsages: z.object({
+ [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
+ [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
+ [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
+ [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
+ [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
+ [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
+ [CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
+ [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
+ [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
+ }),
+ extendedKeyUsages: z.object({
+ [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
+ [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
+ [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
+ [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
+ [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
+ [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
+ })
+});
+
+export type FormData = z.infer;
+
+type Props = {
+ caId: string;
+ popUp: UsePopUpState<["certificateTemplate"]>;
+ handlePopUpToggle: (
+ popUpName: keyof UsePopUpState<["certificateTemplate"]>,
+ state?: boolean
+ ) => void;
+};
+
+export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+
+ const { data: ca } = useGetCaById(caId);
+
+ const { data: certTemplate } = useGetCertTemplate(
+ (popUp?.certificateTemplate?.data as { id: string })?.id || ""
+ );
+
+ const { data: cas } = useListWorkspaceCas({
+ projectSlug: currentWorkspace?.slug ?? "",
+ status: CaStatus.ACTIVE
+ });
+
+ const { data: collectionsData } = useListWorkspacePkiCollections({
+ workspaceId: currentWorkspace?.id || ""
+ });
+
+ const { mutateAsync: createCertTemplate } = useCreateCertTemplate();
+ const { mutateAsync: updateCertTemplate } = useUpdateCertTemplate();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ keyUsages: {
+ [CertKeyUsage.DIGITAL_SIGNATURE]: true,
+ [CertKeyUsage.KEY_ENCIPHERMENT]: true
+ }
+ }
+ });
+
+ useEffect(() => {
+ if (certTemplate) {
+ reset({
+ caId: certTemplate.caId,
+ name: certTemplate.name,
+ commonName: certTemplate.commonName,
+ subjectAlternativeName: certTemplate.subjectAlternativeName,
+ collectionId: certTemplate.pkiCollectionId ?? undefined,
+ ttl: certTemplate.ttl,
+ keyUsages: Object.fromEntries(certTemplate.keyUsages.map((name) => [name, true]) ?? []),
+ extendedKeyUsages: Object.fromEntries(
+ certTemplate.extendedKeyUsages.map((name) => [name, true]) ?? []
+ )
+ });
+ } else {
+ reset({
+ caId,
+ name: "",
+ commonName: "",
+ ttl: "",
+ keyUsages: {
+ [CertKeyUsage.DIGITAL_SIGNATURE]: true,
+ [CertKeyUsage.KEY_ENCIPHERMENT]: true
+ },
+ extendedKeyUsages: {}
+ });
+ }
+ }, [certTemplate, ca]);
+
+ const onFormSubmit = async ({
+ collectionId,
+ name,
+ commonName,
+ subjectAlternativeName,
+ ttl,
+ keyUsages,
+ extendedKeyUsages
+ }: FormData) => {
+ if (!currentWorkspace?.id) {
+ return;
+ }
+
+ try {
+ if (certTemplate) {
+ await updateCertTemplate({
+ id: certTemplate.id,
+ projectId: currentWorkspace.id,
+ pkiCollectionId: collectionId,
+ caId,
+ name,
+ commonName,
+ subjectAlternativeName,
+ ttl,
+ keyUsages: Object.entries(keyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertKeyUsage),
+ extendedKeyUsages: Object.entries(extendedKeyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertExtendedKeyUsage)
+ });
+
+ createNotification({
+ text: "Successfully updated certificate template",
+ type: "success"
+ });
+ } else {
+ await createCertTemplate({
+ projectId: currentWorkspace.id,
+ pkiCollectionId: collectionId,
+ caId,
+ name,
+ commonName,
+ subjectAlternativeName,
+ ttl,
+ keyUsages: Object.entries(keyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertKeyUsage),
+ extendedKeyUsages: Object.entries(extendedKeyUsages)
+ .filter(([, value]) => value)
+ .map(([key]) => key as CertExtendedKeyUsage)
+ });
+
+ 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-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx
new file mode 100644
index 000000000..1afe60010
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx
@@ -0,0 +1,107 @@
+/**
+ * TODO (dangtony98): Reevaluate if this component should be in main
+ * CertificateTab or under CA page in the future.
+ */
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { DeleteActionModal, IconButton } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { usePopUp } from "@app/hooks";
+import { useDeleteCertTemplate } from "@app/hooks/api";
+
+import { CertificateTemplateEnrollmentModal } from "./CertificateTemplateEnrollmentModal";
+import { CertificateTemplateModal } from "./CertificateTemplateModal";
+import { CertificateTemplatesTable } from "./CertificateTemplatesTable";
+
+type Props = {
+ caId: string;
+};
+
+export const CertificateTemplatesSection = ({ caId }: Props) => {
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "certificateTemplate",
+ "deleteCertificateTemplate",
+ "enrollmentOptions",
+ "upgradePlan"
+ ] 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
+ });
+
+ 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) => (
+ handlePopUpOpen("certificateTemplate")}
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
handlePopUpToggle("deleteCertificateTemplate", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onRemoveCertificateTemplateSubmit(
+ (popUp?.deleteCertificateTemplate?.data as { id: string })?.id
+ )
+ }
+ />
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan."
+ />
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx
new file mode 100644
index 000000000..64b829844
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx
@@ -0,0 +1,146 @@
+import { faEllipsis, faFileAlt, faTrash, faUserPlus } 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, useSubscription } from "@app/context";
+import { useGetCaCertTemplates } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ caId: string;
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<
+ ["certificateTemplate", "deleteCertificateTemplate", "enrollmentOptions", "upgradePlan"]
+ >,
+ data?: {
+ id?: string;
+ name?: string;
+ }
+ ) => void;
+};
+
+export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
+ const { subscription } = useSubscription();
+
+ const { data, isPending } = useGetCaCertTemplates(caId);
+
+ return (
+
+
+
+
+
+ | Name |
+ |
+
+
+
+ {isPending && }
+ {!isPending &&
+ data?.certificateTemplates.map((certificateTemplate) => {
+ return (
+
+ | {certificateTemplate.name} |
+
+
+
+
+
+
+
+
+
+
+
+ handlePopUpOpen("certificateTemplate", {
+ id: certificateTemplate.id
+ })
+ }
+ icon={}
+ >
+ Manage Policies
+
+
+ {(isAllowed) => (
+ {
+ if (!subscription?.pkiEst) {
+ handlePopUpOpen("upgradePlan");
+ return;
+ }
+
+ handlePopUpOpen("enrollmentOptions", {
+ id: certificateTemplate.id
+ });
+ }}
+ className={twMerge(
+ !isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
+ )}
+ disabled={!isAllowed}
+ icon={}
+ >
+ Manage Enrollment
+
+ )}
+
+
+ {(isAllowed) => (
+ }
+ onClick={() =>
+ handlePopUpOpen("deleteCertificateTemplate", {
+ id: certificateTemplate.id,
+ name: certificateTemplate.name
+ })
+ }
+ >
+ Delete Template
+
+ )}
+
+
+
+ |
+
+ );
+ })}
+
+
+ {!isPending && !data?.certificateTemplates?.length && (
+
+ )}
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx
new file mode 100644
index 000000000..d5f94e7b7
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx
@@ -0,0 +1,88 @@
+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 { useDeleteCert } from "@app/hooks/api";
+import { usePopUp } from "@app/hooks/usePopUp";
+
+import { CertificateCertModal } from "./CertificateCertModal";
+import { CertificateModal } from "./CertificateModal";
+import { CertificateRevocationModal } from "./CertificateRevocationModal";
+import { CertificatesTable } from "./CertificatesTable";
+
+export const CertificatesSection = () => {
+ const { currentWorkspace } = useWorkspace();
+ const { mutateAsync: deleteCert } = useDeleteCert();
+
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "certificate",
+ "certificateCert",
+ "deleteCertificate",
+ "revokeCertificate"
+ ] as const);
+
+ const onRemoveCertificateSubmit = async (serialNumber: string) => {
+ try {
+ if (!currentWorkspace?.slug) return;
+
+ await deleteCert({ serialNumber, projectSlug: currentWorkspace.slug });
+
+ createNotification({
+ text: "Successfully deleted certificate",
+ type: "success"
+ });
+
+ handlePopUpClose("deleteCertificate");
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to delete certificate",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+
Certificates
+
+ {(isAllowed) => (
+ }
+ onClick={() => handlePopUpOpen("certificate")}
+ isDisabled={!isAllowed}
+ >
+ Issue
+
+ )}
+
+
+
+
+
+
+
handlePopUpToggle("deleteCertificate", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onRemoveCertificateSubmit(
+ (popUp?.deleteCertificate?.data as { serialNumber: string })?.serialNumber
+ )
+ }
+ />
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx
new file mode 100644
index 000000000..dcc832bfb
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx
@@ -0,0 +1,219 @@
+import { useState } from "react";
+import {
+ faBan,
+ faCertificate,
+ faEllipsis,
+ faEye,
+ faFileExport,
+ faTrash
+} 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,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ EmptyState,
+ Pagination,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { useListWorkspaceCertificates } from "@app/hooks/api";
+import { CertStatus } from "@app/hooks/api/certificates/enums";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
+
+type Props = {
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<
+ ["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"]
+ >,
+ data?: {
+ serialNumber?: string;
+ commonName?: string;
+ }
+ ) => void;
+};
+
+const PER_PAGE_INIT = 25;
+
+export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
+ const [page, setPage] = useState(1);
+ const [perPage, setPerPage] = useState(PER_PAGE_INIT);
+
+ const { currentWorkspace } = useWorkspace();
+ const { data, isPending } = useListWorkspaceCertificates({
+ projectSlug: currentWorkspace?.slug ?? "",
+ offset: (page - 1) * perPage,
+ limit: perPage
+ });
+
+ return (
+
+
+
+
+ | Friendly Name |
+ Status |
+ Not Before |
+ Not After |
+ |
+
+
+
+ {isPending && }
+ {!isPending &&
+ data?.certificates.map((certificate) => {
+ const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
+ return (
+
+ | {certificate.friendlyName} |
+
+ {certificate.status === CertStatus.REVOKED ? (
+ Revoked
+ ) : (
+ {label}
+ )}
+ |
+
+ {certificate.notBefore
+ ? format(new Date(certificate.notBefore), "yyyy-MM-dd")
+ : "-"}
+ |
+
+ {certificate.notAfter
+ ? format(new Date(certificate.notAfter), "yyyy-MM-dd")
+ : "-"}
+ |
+
+
+
+
+
+
+
+
+
+
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("certificateCert", {
+ serialNumber: certificate.serialNumber
+ })
+ }
+ disabled={!isAllowed}
+ icon={}
+ >
+ Export Certificate
+
+ )}
+
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("certificate", {
+ serialNumber: certificate.serialNumber
+ })
+ }
+ disabled={!isAllowed}
+ icon={}
+ >
+ View Details
+
+ )}
+
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("revokeCertificate", {
+ serialNumber: certificate.serialNumber
+ })
+ }
+ disabled={!isAllowed}
+ icon={}
+ >
+ Revoke Certificate
+
+ )}
+
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("deleteCertificate", {
+ serialNumber: certificate.serialNumber,
+ commonName: certificate.commonName
+ })
+ }
+ disabled={!isAllowed}
+ icon={}
+ >
+ Delete Certificate
+
+ )}
+
+
+
+ |
+
+ );
+ })}
+
+
+ {!isPending && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && (
+ setPage(newPage)}
+ onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
+ />
+ )}
+ {!isPending && !data?.certificates?.length && (
+
+ )}
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesTable.utils.ts b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesTable.utils.ts
new file mode 100644
index 000000000..93b408bbe
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificatesTable.utils.ts
@@ -0,0 +1,28 @@
+import ms from "ms";
+
+export const getCertValidUntilBadgeDetails = (notAfter: string) => {
+ const currentDate = new Date().getTime();
+ const notAfterDate = new Date(notAfter).getTime();
+ const diffInMs = notAfterDate - currentDate;
+
+ let variant: "success" | "primary" | "danger" = "success";
+ let label = "Healthy";
+
+ if (diffInMs > ms("60d")) {
+ variant = "success";
+ } else if (diffInMs > ms("30d")) {
+ variant = "primary";
+ } else {
+ variant = "danger";
+ }
+
+ if (diffInMs > ms("60d")) {
+ label = "Healthy";
+ } else if (diffInMs > ms("0d")) {
+ label = `Expires in ${ms(diffInMs)}`;
+ } else {
+ label = "Expired";
+ }
+
+ return { variant, label };
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/index.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/index.tsx
new file mode 100644
index 000000000..7854a6f8b
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/index.tsx
@@ -0,0 +1 @@
+export { CertificatesSection } from "./CertificatesSection";
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/index.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/index.tsx
new file mode 100644
index 000000000..277134d56
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/index.tsx
@@ -0,0 +1 @@
+export { CertificatesTab } from "./CertificatesTab";
diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/MembersTab.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx
similarity index 66%
rename from frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/MembersTab.tsx
rename to frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx
index 6b5660c83..3a5a04345 100644
--- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/MembersTab.tsx
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/PkiAlertsTab.tsx
@@ -1,17 +1,17 @@
import { motion } from "framer-motion";
-import { MembersSection } from "./components";
+import { PkiAlertsSection } from "./components";
-export const MembersTab = () => {
+export const PkiAlertsTab = () => {
return (
-
+
);
};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertModal.tsx
new file mode 100644
index 000000000..048c951ea
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertModal.tsx
@@ -0,0 +1,295 @@
+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 {
+ useCreatePkiAlert,
+ useGetPkiAlertById,
+ useListWorkspacePkiCollections,
+ useUpdatePkiAlert
+} from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+enum TimeUnit {
+ DAY = "days",
+ WEEK = "weeks",
+ MONTH = "months",
+ YEAR = "years"
+}
+
+const schema = z.object({
+ name: z.string().trim().min(1),
+ pkiCollectionId: z.string(),
+ alertBefore: z.string(),
+ alertUnit: z.nativeEnum(TimeUnit),
+ emails: z.string().trim()
+});
+
+const convertToDays = (unit: TimeUnit, value: number) => {
+ switch (unit) {
+ case TimeUnit.DAY:
+ return value;
+ case TimeUnit.WEEK:
+ return value * 7;
+ case TimeUnit.MONTH:
+ return value * 30;
+ case TimeUnit.YEAR:
+ return value * 365;
+ default:
+ throw new Error(`Unknown time unit: ${unit}`);
+ }
+};
+
+export type FormData = z.infer;
+
+type Props = {
+ popUp: UsePopUpState<["pkiAlert"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiAlert"]>, state?: boolean) => void;
+};
+
+export const PkiAlertModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const projectId = currentWorkspace?.id || "";
+
+ const { data: alert } = useGetPkiAlertById(
+ (popUp?.pkiAlert?.data as { alertId: string })?.alertId || ""
+ );
+
+ const { data: pkiCollections } = useListWorkspacePkiCollections({
+ workspaceId: projectId
+ });
+
+ const { mutateAsync: createPkiAlert } = useCreatePkiAlert();
+ const { mutateAsync: updatePkiAlert } = useUpdatePkiAlert();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ alertUnit: TimeUnit.DAY
+ }
+ });
+
+ useEffect(() => {
+ if (alert) {
+ reset({
+ name: alert.name,
+ pkiCollectionId: alert.pkiCollectionId,
+ alertBefore: alert.alertBeforeDays.toString(),
+ alertUnit: TimeUnit.DAY,
+ emails: alert.recipientEmails
+ });
+ } else {
+ // TODO: add default collection?
+ reset({
+ name: "",
+ ...(pkiCollections?.collections?.[0] && {
+ pkiCollectionId: pkiCollections.collections[0].id
+ })
+ });
+ }
+ }, [alert, pkiCollections]);
+
+ const onFormSubmit = async ({
+ name,
+ pkiCollectionId,
+ alertBefore,
+ alertUnit,
+ emails
+ }: FormData) => {
+ try {
+ if (!projectId) return;
+
+ const emailArray = emails
+ .split(",")
+ .map((email) => email.trim())
+ .filter((email) => email.length > 0);
+
+ const alertBeforeDays = convertToDays(alertUnit, Number(alertBefore));
+
+ if (alert) {
+ // update
+ await updatePkiAlert({
+ alertId: alert.id,
+ pkiCollectionId,
+ name,
+ projectId,
+ alertBeforeDays,
+ emails: emailArray
+ });
+ } else {
+ // create
+ await createPkiAlert({
+ name,
+ projectId,
+ pkiCollectionId,
+ alertBeforeDays,
+ emails: emailArray
+ });
+ }
+
+ handlePopUpToggle("pkiAlert", false);
+
+ reset();
+
+ createNotification({
+ text: `Successfully ${alert ? "updated" : "created"} alert`,
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: `Failed to ${alert ? "updated" : "created"} alert`,
+ type: "error"
+ });
+ }
+ };
+
+ return (
+ {
+ handlePopUpToggle("pkiAlert", isOpen);
+ reset();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertRow.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertRow.tsx
new file mode 100644
index 000000000..5416fede3
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertRow.tsx
@@ -0,0 +1,92 @@
+import { faEllipsis } 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,
+ Td,
+ Tr
+} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { useGetPkiCollectionById } from "@app/hooks/api";
+import { TPkiAlert } from "@app/hooks/api/pkiAlerts/types";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ alert: TPkiAlert;
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<["pkiAlert", "deletePkiAlert"]>,
+ data?: object
+ ) => void;
+};
+
+export const PkiAlertRow = ({ alert, handlePopUpOpen }: Props) => {
+ const { data: pkiCollection } = useGetPkiCollectionById(alert.pkiCollectionId || "");
+ return (
+
+ | {alert.name} |
+ {alert.alertBeforeDays} |
+ {pkiCollection ? pkiCollection.name : "-"} |
+
+
+
+
+
+
+
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("pkiAlert", {
+ alertId: alert.id
+ });
+ }}
+ disabled={!isAllowed}
+ >
+ Edit Alert
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("deletePkiAlert", {
+ alertId: alert.id,
+ name: alert.name
+ });
+ }}
+ disabled={!isAllowed}
+ >
+ Delete Alert
+
+ )}
+
+
+
+ |
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertsSection.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertsSection.tsx
new file mode 100644
index 000000000..af27c4b7a
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertsSection.tsx
@@ -0,0 +1,84 @@
+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 { useDeletePkiAlert } from "@app/hooks/api";
+import { usePopUp } from "@app/hooks/usePopUp";
+
+import { PkiAlertModal } from "./PkiAlertModal";
+import { PkiAlertsTable } from "./PkiAlertsTable";
+
+export const PkiAlertsSection = () => {
+ const { currentWorkspace } = useWorkspace();
+ const projectId = currentWorkspace?.id || "";
+ const { mutateAsync: deletePkiAlert } = useDeletePkiAlert();
+
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "pkiAlert",
+ "deletePkiAlert"
+ ] as const);
+
+ const onRemoveAlertSubmit = async (alertId: string) => {
+ try {
+ if (!projectId) return;
+
+ await deletePkiAlert({
+ alertId,
+ projectId
+ });
+
+ createNotification({
+ text: "Successfully deleted alert",
+ type: "success"
+ });
+
+ handlePopUpClose("deletePkiAlert");
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to delete alert",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+
Alerts
+
+ {(isAllowed) => (
+ }
+ onClick={() => handlePopUpOpen("pkiAlert")}
+ isDisabled={!isAllowed}
+ >
+ Create
+
+ )}
+
+
+
+
+
handlePopUpToggle("deletePkiAlert", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onRemoveAlertSubmit((popUp?.deletePkiAlert?.data as { alertId: string })?.alertId)
+ }
+ />
+
+ );
+};
diff --git a/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertsTable.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertsTable.tsx
new file mode 100644
index 000000000..b67c5a2c6
--- /dev/null
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiAlertsTable.tsx
@@ -0,0 +1,67 @@
+import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
+
+import {
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import { useListWorkspacePkiAlerts } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+import { PkiAlertRow } from "./PkiAlertRow";
+
+type Props = {
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<["pkiAlert", "deletePkiAlert"]>,
+ data?: object
+ ) => void;
+};
+
+export const PkiAlertsTable = ({ handlePopUpOpen }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const projectId = currentWorkspace?.id || "";
+
+ const { data, isPending } = useListWorkspacePkiAlerts({
+ workspaceId: projectId
+ });
+
+ return (
+
+
+
+
+ | Alert Name |
+ Alert Before Days |
+ Certificate Collection |
+ |
+
+
+
+ {isPending && }
+ {!isPending &&
+ data?.alerts.map((alert) => {
+ return (
+
+ );
+ })}
+
+
+ {!isPending && !data?.alerts?.length && (
+
+ )}
+
+ );
+};
diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleModal.tsx b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx
similarity index 50%
rename from frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleModal.tsx
rename to frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx
index 25e33aed4..95adbd430 100644
--- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleModal.tsx
+++ b/frontend-v2/src/pages/cert-manager/CertificatesPage/components/PkiAlertsTab/components/PkiCollectionModal.tsx
@@ -8,42 +8,36 @@ import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
- useCreateProjectRole,
- useGetProjectRoleBySlug,
- useUpdateProjectRole
+ useCreatePkiCollection,
+ useGetPkiCollectionById,
+ useUpdatePkiCollection
} from "@app/hooks/api";
+import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
-import { slugSchema } from "@app/lib/schemas";
-const schema = z
- .object({
- name: z.string(),
- description: z.string(),
- slug: slugSchema({ min: 1 })
- })
- .required();
+const schema = z.object({
+ name: z.string().trim().min(1),
+ description: z.string()
+});
export type FormData = z.infer;
type Props = {
- popUp: UsePopUpState<["role"]>;
- handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void;
+ popUp: UsePopUpState<["pkiCollection"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiCollection"]>, state?: boolean) => void;
};
-export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
+export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => {
const navigate = useNavigate();
-
- const popupData = popUp?.role?.data as {
- roleSlug: string;
- };
-
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
- const { data: role } = useGetProjectRoleBySlug(projectId, popupData?.roleSlug ?? "");
+ const { data: pkiCollection } = useGetPkiCollectionById(
+ (popUp?.pkiCollection?.data as { collectionId: string })?.collectionId || ""
+ );
- const { mutateAsync: createProjectRole } = useCreateProjectRole();
- const { mutateAsync: updateProjectRole } = useUpdateProjectRole();
+ const { mutateAsync: createPkiCollection } = useCreatePkiCollection();
+ const { mutateAsync: updatePkiCollection } = useUpdatePkiCollection();
const {
control,
@@ -51,79 +45,60 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
reset,
formState: { isSubmitting }
} = useForm({
- resolver: zodResolver(schema),
- defaultValues: {
- name: "",
- description: ""
- }
+ resolver: zodResolver(schema)
});
useEffect(() => {
- if (role) {
+ if (pkiCollection) {
reset({
- name: role.name,
- description: role.description,
- slug: role.slug
+ name: pkiCollection.name,
+ description: pkiCollection.description
});
} else {
reset({
name: "",
- description: "",
- slug: ""
+ description: ""
});
}
- }, [role]);
+ }, [pkiCollection]);
- const onFormSubmit = async ({ name, description, slug }: FormData) => {
+ const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!projectId) return;
- if (role) {
+ if (pkiCollection) {
// update
- await updateProjectRole({
- id: role.id,
- projectId,
+ await updatePkiCollection({
+ collectionId: pkiCollection.id,
name,
description,
- slug
+ projectId
});
-
- handlePopUpToggle("role", false);
} else {
// create
- const newRole = await createProjectRole({
- projectId,
+ const { id: createdId } = await createPkiCollection({
name,
description,
- slug,
- permissions: []
+ projectId
});
navigate({
- to: `/${currentWorkspace.type}/$projectId/roles/$roleSlug` as const,
- params: {
- roleSlug: newRole.slug,
- projectId
- }
+ to: `/${ProjectType.CertificateManager}/${projectId}/pki-collections/${createdId}` as const
});
- handlePopUpToggle("role", false);
}
- createNotification({
- text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`,
- type: "success"
- });
+ handlePopUpToggle("pkiCollection", false);
reset();
- } catch (err) {
- console.error(err);
- const error = err as any;
- const text =
- error?.response?.data?.message ??
- `Failed to ${popUp?.role?.data ? "update" : "create"} role`;
createNotification({
- text,
+ text: `Successfully ${pkiCollection ? "updated" : "created"} PKI collection`,
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: `Failed to ${pkiCollection ? "updated" : "created"} PKI collection`,
type: "error"
});
}
@@ -131,13 +106,13 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
{
- handlePopUpToggle("role", isOpen);
+ handlePopUpToggle("pkiCollection", isOpen);
reset();
}}
>
-
+