Continue cert mgmt mvp

This commit is contained in:
Tuan Dang
2024-05-28 17:00:48 -07:00
parent 26ea949a4e
commit b8516da90f
57 changed files with 3339 additions and 467 deletions
@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { CertificatesSection } from "./components";
export const CertificatesTab = () => {
return (
<motion.div
key="panel-certificates"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<CertificatesSection />
</motion.div>
);
};
@@ -0,0 +1,135 @@
import { useEffect } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton,Modal, ModalContent } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetCertCert } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["certificateCert"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void;
};
export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data } = useGetCertCert(
(popUp?.certificateCert?.data as { certId: string })?.certId || ""
);
const [isSerialNumberCopied, setIsSerialNumberCopied] = useToggle(false);
const [isCertificateCopied, setIsCertificateCopied] = useToggle(false);
const [isCertificateChainCopied, setIsCertificateChainCopied] = useToggle(false);
const [isCertificateSkCopied, setIsCertificateSkCopied] = useToggle(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSerialNumberCopied) {
timer = setTimeout(() => setIsSerialNumberCopied.off(), 2000);
}
if (isCertificateCopied) {
timer = setTimeout(() => setIsCertificateCopied.off(), 2000);
}
if (isCertificateChainCopied) {
timer = setTimeout(() => setIsCertificateChainCopied.off(), 2000);
}
if (isCertificateSkCopied) {
timer = setTimeout(() => setIsCertificateSkCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isSerialNumberCopied, isCertificateCopied, isCertificateChainCopied, isCertificateSkCopied]);
return (
<Modal
isOpen={popUp?.certificateCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateCert", isOpen);
}}
>
<ModalContent title="Export Certificate">
{data ? (
<div>
<h2 className="mb-4">Serial Number</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{data.serialNumber}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.serialNumber);
setIsSerialNumberCopied.on();
}}
>
<FontAwesomeIcon icon={isSerialNumberCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Body</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.certificate}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificate);
setIsCertificateCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Chain</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.certificateChain}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificateChain);
setIsCertificateChainCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateChainCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Private Key</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificateChain);
setIsCertificateSkCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateSkCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
</div>
) : (
<div />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,223 @@
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
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
useCreateCertificate,
useGetCertById,
useListWorkspaceCas
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
const isValidDate = (dateString: string) => {
if (dateString === "") return true;
const date = new Date(dateString);
return !Number.isNaN(date.getTime());
};
const schema = z.object({
caId: z.string(),
commonName: z.string().trim().min(1),
ttl: z.string().trim().optional(),
notAfter: z
.string()
.trim()
.refine(isValidDate, { message: "Invalid date format" })
.transform((val) => (val === "" ? undefined : val))
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["certificate"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void;
};
export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: cert } = useGetCertById(
(popUp?.certificate?.data as { certId: string })?.certId || ""
);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { mutateAsync: createCertificate } = useCreateCertificate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
setValue
} = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
if (cert) {
reset({
caId: cert.caId,
commonName: cert.commonName,
ttl: "",
notAfter: format(new Date(cert.notAfter), "yyyy-MM-dd")
});
} else {
reset({
caId: "",
commonName: "",
ttl: "",
notAfter: ""
});
}
}, [cert]);
const onFormSubmit = async ({ caId, commonName, ttl, notAfter }: FormData) => {
try {
if (!currentWorkspace?.slug) return;
await createCertificate({
projectSlug: currentWorkspace.slug,
caId,
commonName,
ttl: ttl ? Number(ttl) : undefined,
notBefore: new Date().toISOString(),
notAfter
});
reset();
handlePopUpToggle("certificate", false);
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, setValue]);
return (
<Modal
isOpen={popUp?.certificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificate", isOpen);
reset();
}}
>
<ModalContent title={`${cert ? "View" : "Issue"} Certificate`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="caId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name (CN)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="Acme Corp" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="86400" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl label="Valid Until" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="YYYY-MM-DD" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
{!cert && (
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
)}
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,79 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { 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 { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCert } = useDeleteCert();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificate",
"certificateCert",
"deleteCertificate"
] as const);
const onRemoveCertificateSubmit = async (certId: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCert({ certId, projectSlug: currentWorkspace.slug });
await createNotification({
text: "Successfully deleted certificate",
type: "success"
});
handlePopUpClose("deleteCertificate");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete certificate",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificates</p>
{/* <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => ( */}
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
// isDisabled={!isAllowed}
>
Issue Certificate
</Button>
{/* )} */}
{/* </OrgPermissionCan> */}
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure want to remove the certificate ${
(popUp?.deleteCertificate?.data as { commonName: string })?.commonName || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteCertificate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCertificateSubmit((popUp?.deleteCertificate?.data as { certId: string })?.certId)
}
/>
</div>
);
};
@@ -0,0 +1,160 @@
import {
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 {
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 { useListWorkspaceCertificates } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["certificate", "deleteCertificate", "certificateCert"]>,
data?: {
certId?: string;
commonName?: string;
}
) => void;
};
export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useListWorkspaceCertificates(currentWorkspace?.slug ?? "");
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Certificate ID</Th>
<Th>Common Name</Th>
<Th>Valid Until</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map((certificate) => {
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.id}</Td>
<Td>{certificate.commonName}</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificateCert", {
certId: certificate.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faFileExport} />}
>
Export Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificate", {
certId: certificate.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
View Details
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("deleteCertificate", {
certId: certificate.id,
commonName: certificate.commonName
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.length === 0 && (
<EmptyState title="No certificates have been created" icon={faCertificate} />
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { CertificatesSection } from "./CertificatesSection";
@@ -0,0 +1 @@
export { CertificatesTab } from "./CertificatesTab";