checkpoint

This commit is contained in:
x
2025-04-29 19:24:00 -04:00
parent ddb5098eda
commit 5641d334cd
17 changed files with 653 additions and 26 deletions
@@ -1,2 +1,2 @@
export { useDeleteCert, useRevokeCert } from "./mutations";
export { useDeleteCert, useRevokeCert, useImportCertificate } from "./mutations";
export { useGetCert, useGetCertBody } from "./queries";
@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace";
import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types";
import {
TCertificate,
TDeleteCertDTO,
TImportCertificateDTO,
TImportCertificateResponse,
TRevokeCertDTO
} from "./types";
export const useDeleteCert = () => {
const queryClient = useQueryClient();
@@ -45,3 +51,21 @@ export const useRevokeCert = () => {
}
});
};
export const useImportCertificate = () => {
const queryClient = useQueryClient();
return useMutation<TImportCertificateResponse, object, TImportCertificateDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TImportCertificateResponse>(
"/api/v1/pki/certificates/import-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.forWorkspaceCertificates(projectSlug)
});
}
});
};
@@ -25,3 +25,23 @@ export type TRevokeCertDTO = {
serialNumber: string;
revocationReason: string;
};
export type TImportCertificateDTO = {
projectSlug: string;
certificatePem: string;
privateKeyPem?: string;
chainPem?: string;
pkiCollectionId?: string;
friendlyName?: string;
};
// TODO(andrey): Change this
export type TImportCertificateResponse = {
certificate: string;
issuingCertificate: string;
certificateChain: string;
privateKey: string;
serialNumber: string;
};
@@ -0,0 +1,223 @@
import { useState } 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 { useImportCertificate, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent";
const schema = z.object({
certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
privateKeyPem: z.string().trim().optional(),
chainPem: z.string().trim().optional(),
friendlyName: z.string(),
collectionId: z.string().optional()
// Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts
// commonName: z.string().trim().min(1),
// altNames: z.string(),
// Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts
// 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<typeof schema>;
type Props = {
popUp: UsePopUpState<["certificateImport"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["certificateImport"]>,
state?: boolean
) => void;
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
};
export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const { currentWorkspace } = useWorkspace();
const { data: cert } = useGetCert(
(popUp?.certificateImport?.data as { serialNumber: string })?.serialNumber || ""
);
const { data } = useListWorkspacePkiCollections({
workspaceId: currentWorkspace?.id || ""
});
const { mutateAsync: importCertificate } = useImportCertificate();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
collectionId
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({
projectSlug: currentWorkspace.slug,
projectSlug: currentWorkspace.slug,
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
pkiCollectionId: collectionId
});
reset();
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully imported certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to import certificate",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.certificateImport?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateImport", isOpen);
reset();
setCertificateDetails(null);
}}
>
<ModalContent title={`${cert ? "View" : "Issue"} Certificate`}>
{!certificateDetails ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="collectionId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Collection (Optional)"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
{(data?.collections || []).map(({ id, name }) => (
<SelectItem value={id} key={`pki-collection-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
{!cert && (
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("certificateImport", false)}
>
Cancel
</Button>
</div>
)}
</form>
) : (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -1,4 +1,4 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
@@ -12,6 +12,7 @@ import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
import { CertificateImportModal } from "./CertificateImportModal";
export const CertificatesSection = () => {
const { currentWorkspace } = useWorkspace();
@@ -19,6 +20,7 @@ export const CertificatesSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificate",
"certificateImport",
"certificateCert",
"deleteCertificate",
"revokeCertificate"
@@ -54,20 +56,31 @@ export const CertificatesSection = () => {
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
isDisabled={!isAllowed}
>
Issue
</Button>
<div className="flex gap-2">
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowRight} />}
onClick={() => handlePopUpOpen("certificateImport")}
isDisabled={!isAllowed}
>
Import
</Button>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
isDisabled={!isAllowed}
>
Issue
</Button>
</div>
)}
</ProjectPermissionCan>
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal