Merge pull request #2239 from Infisical/ca-renewal

CA Renewal (Same Key Pair)
This commit is contained in:
Maidul Islam
2024-08-16 10:37:44 -04:00
committed by GitHub
36 changed files with 1819 additions and 83 deletions
+194
View File
@@ -22,6 +22,7 @@
"@headlessui/react": "^1.7.7",
"@hookform/resolvers": "^2.9.10",
"@octokit/rest": "^19.0.7",
"@peculiar/x509": "^1.11.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
@@ -4520,6 +4521,149 @@
"@octokit/openapi-types": "^18.0.0"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.13.tgz",
"integrity": "sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"@peculiar/asn1-x509-attr": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.13.tgz",
"integrity": "sha512-+JtFsOUWCw4zDpxp1LbeTYBnZLlGVOWmHHEhoFdjM5yn4wCn+JiYQ8mghOi36M2f6TPQ17PmhNL6/JfNh7/jCA==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.13.tgz",
"integrity": "sha512-3dF2pQcrN/WJEMq+9qWLQ0gqtn1G81J4rYqFl6El6QV367b4IuhcRv+yMA84tNNyHOJn9anLXV5radnpPiG3iA==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.13.tgz",
"integrity": "sha512-fypYxjn16BW+5XbFoY11Rm8LhZf6euqX/C7BTYpqVvLem1GvRl7A+Ro1bO/UPwJL0z+1mbvXEnkG0YOwbwz2LA==",
"dependencies": {
"@peculiar/asn1-cms": "^2.3.13",
"@peculiar/asn1-pkcs8": "^2.3.13",
"@peculiar/asn1-rsa": "^2.3.13",
"@peculiar/asn1-schema": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.13.tgz",
"integrity": "sha512-VP3PQzbeSSjPjKET5K37pxyf2qCdM0dz3DJ56ZCsol3FqAXGekb4sDcpoL9uTLGxAh975WcdvUms9UcdZTuGyQ==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.13.tgz",
"integrity": "sha512-rIwQXmHpTo/dgPiWqUgby8Fnq6p1xTJbRMxCiMCk833kQCeZrC5lbSKg6NDnJTnX2kC6IbXBB9yCS2C73U2gJg==",
"dependencies": {
"@peculiar/asn1-cms": "^2.3.13",
"@peculiar/asn1-pfx": "^2.3.13",
"@peculiar/asn1-pkcs8": "^2.3.13",
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"@peculiar/asn1-x509-attr": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.13.tgz",
"integrity": "sha512-wBNQqCyRtmqvXkGkL4DR3WxZhHy8fDiYtOjTeCd7SFE5F6GBeafw3EJ94PX/V0OJJrjQ40SkRY2IZu3ZSyBqcg==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz",
"integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==",
"dependencies": {
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.13.tgz",
"integrity": "sha512-PfeLQl2skXmxX2/AFFCVaWU8U6FKW1Db43mgBhShCOFS1bVxqtvusq1hVjfuEcuSQGedrLdCSvTgabluwN/M9A==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"asn1js": "^3.0.5",
"ipaddr.js": "^2.1.0",
"pvtsutils": "^1.3.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.13.tgz",
"integrity": "sha512-WpEos6CcnUzJ6o2Qb68Z7Dz5rSjRGv/DtXITCNBtjZIRWRV12yFVci76SVfOX8sisL61QWMhpLKQibrG8pi2Pw==",
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/asn1-x509": "^2.3.13",
"asn1js": "^3.0.5",
"tslib": "^2.6.2"
}
},
"node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
"engines": {
"node": ">= 10"
}
},
"node_modules/@peculiar/x509": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.11.0.tgz",
"integrity": "sha512-8rdxE//tsWLb2Yo2TYO2P8gieStbrHK/huFMV5PPfwX8I5HmtOus+Ox6nTKrPA9o+WOPaa5xKenee+QdmHBd5g==",
"dependencies": {
"@peculiar/asn1-cms": "^2.3.8",
"@peculiar/asn1-csr": "^2.3.8",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-pkcs9": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"pvtsutils": "^1.3.5",
"reflect-metadata": "^0.2.2",
"tslib": "^2.6.2",
"tsyringe": "^4.8.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -9870,6 +10014,19 @@
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
"dev": true
},
"node_modules/asn1js": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
"dependencies": {
"pvtsutils": "^1.3.2",
"pvutils": "^1.1.3",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
@@ -20320,6 +20477,22 @@
"async-limiter": "~1.0.0"
}
},
"node_modules/pvtsutils": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz",
"integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==",
"dependencies": {
"tslib": "^2.6.1"
}
},
"node_modules/pvutils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/qs": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
@@ -21186,6 +21359,11 @@
"redux": "^4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
@@ -23573,6 +23751,22 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true
},
"node_modules/tsyringe": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz",
"integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"node_modules/tty-browserify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"@headlessui/react": "^1.7.7",
"@hookform/resolvers": "^2.9.10",
"@octokit/rest": "^19.0.7",
"@peculiar/x509": "^1.11.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
+1
View File
@@ -1,5 +1,6 @@
export * from "./Accordion";
export * from "./Alert";
export * from "./Badge";
export * from "./Button";
export * from "./Card";
export * from "./Checkbox";
+12 -1
View File
@@ -1,4 +1,4 @@
import { CaStatus,CaType } from "./enums";
import { CaStatus, CaType } from "./enums";
export const caTypeToNameMap: { [K in CaType]: string } = {
[CaType.ROOT]: "Root",
@@ -10,3 +10,14 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = {
[CaStatus.DISABLED]: "Disabled",
[CaStatus.PENDING_CERTIFICATE]: "Pending Certificate"
};
export const getStatusBadgeVariant = (status: CaStatus) => {
switch (status) {
case CaStatus.ACTIVE:
return "success";
case CaStatus.DISABLED:
return "danger";
default:
return "primary";
}
};
+4
View File
@@ -8,3 +8,7 @@ export enum CaStatus {
DISABLED = "disabled",
PENDING_CERTIFICATE = "pending-certificate"
}
export enum CaRenewalType {
EXISTING = "existing"
}
+4 -4
View File
@@ -1,10 +1,10 @@
export { CaStatus, CaType } from "./enums";
export { CaRenewalType,CaStatus, CaType } from "./enums";
export {
useCreateCa,
useCreateCertificate,
useDeleteCa,
useImportCaCertificate,
useRenewCa,
useSignIntermediate,
useUpdateCa
} from "./mutations";
export { useGetCaById, useGetCaCert, useGetCaCrl,useGetCaCsr } from "./queries";
useUpdateCa} from "./mutations";
export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrl, useGetCaCsr } from "./queries";
+27 -1
View File
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries";
import { caKeys } from "./queries";
import {
TCertificateAuthority,
TCreateCaDTO,
@@ -11,6 +12,8 @@ import {
TDeleteCaDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
TRenewCaDTO,
TRenewCaResponse,
TSignIntermediateDTO,
TSignIntermediateResponse,
TUpdateCaDTO
@@ -84,8 +87,10 @@ export const useImportCaCertificate = () => {
);
return data;
},
onSuccess: (_, { projectSlug }) => {
onSuccess: (_, { caId, projectSlug }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug }));
queryClient.invalidateQueries(caKeys.getCaCerts(caId));
queryClient.invalidateQueries(caKeys.getCaCert(caId));
}
});
};
@@ -106,3 +111,24 @@ export const useCreateCertificate = () => {
}
});
};
export const useRenewCa = () => {
const queryClient = useQueryClient();
return useMutation<TRenewCaResponse, {}, TRenewCaDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TRenewCaResponse>(
`/api/v1/pki/ca/${body.caId}/renew`,
body
);
return data;
},
onSuccess: (_, { caId, projectSlug }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug }));
queryClient.invalidateQueries(caKeys.getCaById(caId));
queryClient.invalidateQueries(caKeys.getCaCert(caId));
queryClient.invalidateQueries(caKeys.getCaCerts(caId));
queryClient.invalidateQueries(caKeys.getCaCsr(caId));
queryClient.invalidateQueries(caKeys.getCaCrl(caId));
}
});
};
+20 -1
View File
@@ -6,6 +6,7 @@ import { TCertificateAuthority } from "./types";
export const caKeys = {
getCaById: (caId: string) => [{ caId }, "ca"],
getCaCerts: (caId: string) => [{ caId }, "ca-cert"],
getCaCert: (caId: string) => [{ caId }, "ca-cert"],
getCaCsr: (caId: string) => [{ caId }, "ca-csr"],
getCaCrl: (caId: string) => [{ caId }, "ca-crl"]
@@ -24,6 +25,24 @@ export const useGetCaById = (caId: string) => {
});
};
export const useGetCaCerts = (caId: string) => {
return useQuery({
queryKey: caKeys.getCaCerts(caId),
queryFn: async () => {
const { data } = await apiRequest.get<
{
certificate: string;
certificateChain: string;
serialNumber: string;
version: number;
}[]
>(`/api/v1/pki/ca/${caId}/ca-certificates`); // TODO: consider updating endpoint structure
return data;
},
enabled: Boolean(caId)
});
};
export const useGetCaCert = (caId: string) => {
return useQuery({
queryKey: caKeys.getCaCert(caId),
@@ -32,7 +51,7 @@ export const useGetCaCert = (caId: string) => {
certificate: string;
certificateChain: string;
serialNumber: string;
}>(`/api/v1/pki/ca/${caId}/certificate`);
}>(`/api/v1/pki/ca/${caId}/certificate`); // TODO: consider updating endpoint structure
return data;
},
enabled: Boolean(caId)
+15 -1
View File
@@ -1,5 +1,5 @@
import { CertKeyAlgorithm } from "../certificates/enums";
import { CaStatus, CaType } from "./enums";
import { CaRenewalType, CaStatus, CaType } from "./enums";
export type TCertificateAuthority = {
id: string;
@@ -19,6 +19,7 @@ export type TCertificateAuthority = {
notAfter?: string;
notBefore?: string;
keyAlgorithm: CertKeyAlgorithm;
activeCaCertId?: string;
createdAt: string;
updatedAt: string;
};
@@ -94,3 +95,16 @@ export type TCreateCertificateResponse = {
privateKey: string;
serialNumber: string;
};
export type TRenewCaDTO = {
projectSlug: string;
caId: string;
type: CaRenewalType;
notAfter: string;
};
export type TRenewCaResponse = {
certificate: string;
certificateChain: string;
serialNumber: string;
};
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Head from "next/head";
import { CaPage } from "@app/views/Project/CaPage";
export default function Ca() {
return (
<>
<Head>
<title>Certificate Authority</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<CaPage />
</>
);
}
Ca.requireAuth = true;
@@ -0,0 +1,145 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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 { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteCa, useGetCaById } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaModal } from "@app/views/Project/CertificatesPage/components/CaTab/components/CaModal";
import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal";
import { TabSections } from "../Types";
import { CaCertificatesSection, CaDetailsSection, CaRenewalModal } from "./components";
export const CaPage = withProjectPermission(
() => {
const router = useRouter();
const caId = router.query.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 });
await createNotification({
text: "Successfully deleted CA",
type: "success"
});
handlePopUpClose("deleteCa");
router.push(`/project/${projectId}/certificates`);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete CA",
type: "error"
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => router.push(`/project/${projectId}/certificates`)}
className="mb-4"
>
Certificate Authorities
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">{data.friendlyName}</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deleteCa", {
caId: data.id,
dn: data.dn
})
}
disabled={!isAllowed}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<CaDetailsSection caId={caId} handlePopUpOpen={handlePopUpOpen} />
</div>
<CaCertificatesSection caId={caId} />
</div>
</div>
)}
<CaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaInstallCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}
title={`Are you sure want to remove the CA ${
(popUp?.deleteCa?.data as { dn: string })?.dn || ""
} from the project?`}
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)
}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.CertificateAuthorities }
);
@@ -0,0 +1,31 @@
// import { faPlus } from "@fortawesome/free-solid-svg-icons";
// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// import { IconButton } from "@app/components/v2";
import { CaCertificatesTable } from "./CaCertificatesTable";
type Props = {
caId: string;
};
export const CaCertificatesSection = ({ caId }: Props) => {
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">CA Certificates</h3>
{/* <IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
// handlePopUpOpen("addIdentityToProject");
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton> */}
</div>
<div className="py-4">
<CaCertificatesTable caId={caId} />
</div>
</div>
);
};
@@ -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, isLoading } = useGetCaCerts(caId);
const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename);
};
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>CA Certificate #</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="ca-certificates" />}
{!isLoading &&
caCerts?.map((caCert, index) => {
const isLastItem = index === caCerts.length - 1;
const caCertObj = new x509.X509Certificate(caCert.certificate);
return (
<Tr key={`ca-cert=${caCert.serialNumber}`}>
<Td>
<div className="flex items-center">
CA Certificate {caCert.version}
{isLastItem && (
<Badge variant="success" className="ml-4">
Current
</Badge>
)}
</div>
</Td>
<Td>{format(new Date(caCertObj.notBefore), "yyyy-MM-dd")}</Td>
<Td>{format(new Date(caCertObj.notAfter), "yyyy-MM-dd")}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
downloadTxtFile("cert.pem", caCert.certificate);
}}
disabled={!isAllowed}
>
Download CA Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
downloadTxtFile("chain.pem", caCert.certificateChain);
}}
disabled={!isAllowed}
>
Download CA Certificate Chain
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && !caCerts?.length && (
<EmptyState
title="This CA does not have any CA certificates installed"
icon={faCertificate}
/>
)}
</TableContainer>
);
};
@@ -0,0 +1 @@
export { CaCertificatesSection } from "./CaCertificatesSection";
@@ -0,0 +1,167 @@
import { faCheck, faCopy } 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, 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?: {}
) => void;
};
export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const [copyTextParentId, isCopyingParentId, setCopyTextParentId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { data: ca } = useGetCaById(caId);
return ca ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">CA Details</h3>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">CA ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{ca.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(ca.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
{ca.parentCaId && (
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Parent CA ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{ca.parentCaId}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextParentId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(ca.parentCaId as string);
setCopyTextParentId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingParentId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
)}
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Friendly Name</p>
<p className="text-sm text-mineshaft-300">{ca.friendlyName}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">CA Type</p>
<p className="text-sm text-mineshaft-300">{caTypeToNameMap[ca.type]}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">{caStatusToNameMap[ca.status]}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Key Algorithm</p>
<p className="text-sm text-mineshaft-300">{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Max Path Length</p>
<p className="text-sm text-mineshaft-300">{ca.maxPathLength ?? "-"}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Not Before</p>
<p className="text-sm text-mineshaft-300">
{ca.notBefore ? format(new Date(ca.notBefore), "yyyy-MM-dd") : "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Not After</p>
<p className="text-sm text-mineshaft-300">
{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}
</p>
</div>
{ca.status === CaStatus.ACTIVE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
onClick={() => {
handlePopUpOpen("renewCa", {
caId
});
}}
>
Renew CA
</Button>
);
}}
</ProjectPermissionCan>
)}
{ca.status === CaStatus.PENDING_CERTIFICATE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
return (
<Button
isDisabled={!isAllowed}
className="mt-4 w-full"
colorSchema="primary"
type="submit"
onClick={() => {
handlePopUpOpen("installCaCert", {
caId
});
}}
>
Install CA Certificate
</Button>
);
}}
</ProjectPermissionCan>
)}
</div>
</div>
) : (
<div />
);
};
@@ -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<typeof schema>;
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<FormData>({
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 (
<Modal
isOpen={popUp?.renewCa?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("renewCa", isOpen);
reset();
}}
>
<ModalContent title="Renew CA">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="type"
defaultValue={CaRenewalType.EXISTING}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="CA Renewal Method"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{caRenewalTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="YYYY-MM-DD" />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Renew
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("renewCa", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,3 @@
export { CaCertificatesSection } from "./CaCertificatesSection/CaCertificatesSection";
export { CaDetailsSection } from "./CaDetailsSection";
export { CaRenewalModal } from "./CaRenewalModal";
@@ -0,0 +1 @@
export { CaPage } from "./CaPage";
@@ -1,3 +1,4 @@
import { useRouter } from "next/router";
import {
faBan,
faCertificate,
@@ -12,6 +13,7 @@ import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -31,9 +33,14 @@ import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace} from "@app/context";
useWorkspace
} from "@app/context";
import { CaStatus, useListWorkspaceCas } from "@app/hooks/api";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import {
caStatusToNameMap,
caTypeToNameMap,
getStatusBadgeVariant
} from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -51,11 +58,13 @@ type Props = {
};
export const CaTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? ""
});
return (
<div>
<TableContainer>
@@ -76,11 +85,26 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
data.length > 0 &&
data.map((ca) => {
return (
<Tr className="h-10" key={`ca-${ca.id}`}>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ca-${ca.id}`}
onClick={() => router.push(`/project/${currentWorkspace?.id}/ca/${ca.id}`)}
>
<Td>{ca.friendlyName}</Td>
<Td>{caStatusToNameMap[ca.status]}</Td>
<Td>
<Badge variant={getStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td>{caTypeToNameMap[ca.type]}</Td>
<Td>{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}</Td>
<Td>
<div className="flex items-center ">
<p>{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}</p>
{/* <Badge variant="danger" className="ml-4">
Expires Soon
</Badge> */}
</div>
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
@@ -102,7 +126,8 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("installCaCert", {
caId: ca.id
});
@@ -110,7 +135,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCertificate} />}
>
Install Certificate
Install CA Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
@@ -126,7 +151,8 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caCert", {
caId: ca.id
});
@@ -150,7 +176,8 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
onClick={(e) => {
e.stopPropagation();
if (!subscription?.caCrl) {
handlePopUpOpen("upgradePlan", {
description:
@@ -179,11 +206,12 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("ca", {
caId: ca.id
})
}
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
@@ -202,15 +230,16 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caStatus", {
caId: ca.id,
status:
ca.status === CaStatus.ACTIVE
? CaStatus.DISABLED
: CaStatus.ACTIVE
})
}
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
@@ -228,12 +257,13 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteCa", {
caId: ca.id,
dn: ca.dn
})
}
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>