mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 01:16:49 +00:00
PKI revamp: frontend implementation
This commit is contained in:
@@ -145,7 +145,13 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
maxPathLength: ca.configuration.maxPathLength
|
||||
? String(ca.configuration.maxPathLength)
|
||||
: "",
|
||||
keyAlgorithm: ca.configuration.keyAlgorithm
|
||||
keyAlgorithm:
|
||||
ca.configuration.keyAlgorithm === CertKeyAlgorithm.RSA_2048 ||
|
||||
ca.configuration.keyAlgorithm === CertKeyAlgorithm.RSA_4096 ||
|
||||
ca.configuration.keyAlgorithm === CertKeyAlgorithm.ECDSA_P256 ||
|
||||
ca.configuration.keyAlgorithm === CertKeyAlgorithm.ECDSA_P384
|
||||
? ca.configuration.keyAlgorithm
|
||||
: CertKeyAlgorithm.RSA_2048
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
+1018
File diff suppressed because it is too large
Load Diff
+15
-3
@@ -7,22 +7,28 @@ import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub,
|
||||
useProject
|
||||
useProject,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useDeleteCert } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { CertificateCertModal } from "./CertificateCertModal";
|
||||
import { CertificateImportModal } from "./CertificateImportModal";
|
||||
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
|
||||
import { CertificateModal } from "./CertificateModal";
|
||||
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
||||
import { CertificatesTable } from "./CertificatesTable";
|
||||
|
||||
export const CertificatesSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
const { subscription } = useSubscription();
|
||||
const { mutateAsync: deleteCert } = useDeleteCert();
|
||||
|
||||
const useOldCertificateFlow = subscription.pkiLegacyTemplates;
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"certificateIssuance",
|
||||
"certificate",
|
||||
"certificateImport",
|
||||
"certificateCert",
|
||||
@@ -73,7 +79,9 @@ export const CertificatesSection = () => {
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("certificate")}
|
||||
onClick={() =>
|
||||
handlePopUpOpen(useOldCertificateFlow ? "certificate" : "certificateIssuance")
|
||||
}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Issue
|
||||
@@ -83,7 +91,11 @@ export const CertificatesSection = () => {
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
{useOldCertificateFlow ? (
|
||||
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
) : (
|
||||
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
)}
|
||||
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
|
||||
+21
-17
@@ -19,6 +19,8 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
|
||||
export const PkiSubscriberSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject.id;
|
||||
|
||||
const allowNewSubscriberCreation = false;
|
||||
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
|
||||
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
|
||||
|
||||
@@ -100,23 +102,25 @@ export const PkiSubscriberSection = () => {
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPkiSubscriberActions.Create}
|
||||
a={ProjectPermissionSub.PkiSubscribers}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("pkiSubscriber")}
|
||||
isDisabled={!isAllowed}
|
||||
className="ml-4"
|
||||
>
|
||||
Add Subscriber
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{allowNewSubscriberCreation && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPkiSubscriberActions.Create}
|
||||
a={ProjectPermissionSub.PkiSubscribers}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("pkiSubscriber")}
|
||||
isDisabled={!isAllowed}
|
||||
className="ml-4"
|
||||
>
|
||||
Add Subscriber
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PkiSubscribersTable handlePopUpOpen={handlePopUpOpen} />
|
||||
|
||||
@@ -58,6 +58,7 @@ export const PkiTemplateListPage = () => {
|
||||
const { currentProject } = useProject();
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"certificateTemplate",
|
||||
"deleteTemplate",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
|
||||
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
|
||||
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
|
||||
|
||||
enum TabSections {
|
||||
CertificateTemplatesV2 = "templates-v2",
|
||||
CertificateProfiles = "profiles"
|
||||
}
|
||||
|
||||
export const PoliciesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentProject } = useProject();
|
||||
const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles);
|
||||
|
||||
if (!currentProject) {
|
||||
return <ContentLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Certificate Policies" })}</title>
|
||||
</Helmet>
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Certificate Policies"
|
||||
description="Manage certificate templates and profiles for unified certificate issuance"
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabSections)}>
|
||||
<TabList className="mb-6 w-full">
|
||||
<div className="flex w-full border-b border-mineshaft-600">
|
||||
<Tab value={TabSections.CertificateProfiles}>Certificate Profiles</Tab>
|
||||
<Tab value={TabSections.CertificateTemplatesV2}>Certificate Templates</Tab>
|
||||
</div>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value={TabSections.CertificateProfiles}>
|
||||
<CertificateProfilesTab />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={TabSections.CertificateTemplatesV2}>
|
||||
<CertificateTemplatesV2Tab />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { useProjectPermission } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
TCertificateProfile,
|
||||
useDeleteCertificateProfile
|
||||
} from "@app/hooks/api/certificateProfiles";
|
||||
|
||||
import { CreateProfileModal } from "./CreateProfileModal";
|
||||
import { EditProfileModal } from "./EditProfileModal";
|
||||
import { ProfileList } from "./ProfileList";
|
||||
|
||||
export const CertificateProfilesTab = () => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<TCertificateProfile | null>(null);
|
||||
|
||||
const deleteProfile = useDeleteCertificateProfile();
|
||||
|
||||
const canCreateProfile = permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const handleCreateProfile = () => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditProfile = (profile: TCertificateProfile) => {
|
||||
setSelectedProfile(profile);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProfile = (profile: TCertificateProfile) => {
|
||||
setSelectedProfile(profile);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!selectedProfile) return;
|
||||
|
||||
try {
|
||||
await deleteProfile.mutateAsync({
|
||||
profileId: selectedProfile.id
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedProfile(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete profile:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-mineshaft-100">Certificate Profiles</h2>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Unified certificate issuance configurations combining CA, template, and enrollment
|
||||
method
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreateProfile && (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleCreateProfile}
|
||||
>
|
||||
Create Profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProfileList onEditProfile={handleEditProfile} onDeleteProfile={handleDeleteProfile} />
|
||||
|
||||
<CreateProfileModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
|
||||
|
||||
{selectedProfile && (
|
||||
<>
|
||||
<EditProfileModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedProfile(null);
|
||||
}}
|
||||
profile={selectedProfile}
|
||||
/>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title={`Delete Certificate Profile ${selectedProfile.name}?`}
|
||||
onChange={(isOpen) => {
|
||||
setIsDeleteModalOpen(isOpen);
|
||||
if (!isOpen) setSelectedProfile(null);
|
||||
}}
|
||||
deleteKey={selectedProfile.name}
|
||||
onDeleteApproved={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
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,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import { useCreateCertificateProfile } from "@app/hooks/api/certificateProfiles";
|
||||
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Profile name is required"),
|
||||
slug: z.string().trim().min(1, "Profile slug is required"),
|
||||
description: z.string().optional(),
|
||||
enrollmentType: z.enum(["api", "est"]),
|
||||
certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
|
||||
certificateTemplateId: z.string().min(1, "Certificate Template is required"),
|
||||
estConfig: z
|
||||
.object({
|
||||
disableBootstrapCaValidation: z.boolean().optional(),
|
||||
passphrase: z.string().min(1, "EST passphrase is required"),
|
||||
caChain: z.string().min(1, "EST CA chain is required")
|
||||
})
|
||||
.optional(),
|
||||
apiConfig: z
|
||||
.object({
|
||||
autoRenew: z.boolean().optional(),
|
||||
autoRenewDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.enrollmentType === "est" && !data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.enrollmentType === "api" && !data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Configuration is required for selected enrollment type"
|
||||
}
|
||||
);
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
|
||||
const { data: templateData } = useListCertificateTemplatesV2({
|
||||
projectId: currentProject?.id || "",
|
||||
limit: 100,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
const createProfile = useCreateCertificateProfile();
|
||||
|
||||
const certificateAuthorities = caData || [];
|
||||
const certificateTemplates = templateData?.certificateTemplates || [];
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
enrollmentType: "api",
|
||||
certificateAuthorityId: "",
|
||||
certificateTemplateId: "",
|
||||
apiConfig: {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const watchedName = watch("name");
|
||||
const watchedEnrollmentType = watch("enrollmentType");
|
||||
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
|
||||
const watchedAutoRenew = watch("apiConfig.autoRenew");
|
||||
|
||||
useEffect(() => {
|
||||
if (watchedName && !watch("slug")) {
|
||||
const slug = watchedName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
setValue("slug", slug);
|
||||
}
|
||||
}, [watchedName, setValue, watch]);
|
||||
|
||||
const onFormSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (!currentProject?.id) return;
|
||||
|
||||
const payload: any = {
|
||||
projectId: currentProject.id,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description,
|
||||
enrollmentType: data.enrollmentType,
|
||||
caId: data.certificateAuthorityId,
|
||||
certificateTemplateId: data.certificateTemplateId
|
||||
};
|
||||
|
||||
if (data.enrollmentType === "est" && data.estConfig) {
|
||||
payload.estConfig = data.estConfig;
|
||||
} else if (data.enrollmentType === "api" && data.apiConfig) {
|
||||
payload.apiConfig = data.apiConfig;
|
||||
}
|
||||
await createProfile.mutateAsync(payload);
|
||||
|
||||
createNotification({
|
||||
text: "Certificate profile created successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error creating profile:", error);
|
||||
createNotification({
|
||||
text: "Failed to create certificate profile",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Create Certificate Profile"
|
||||
subTitle="Configure a new certificate profile for unified certificate issuance"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Profile Name"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Enter profile name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Profile Slug"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="auto-generated-from-name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
|
||||
<TextArea {...field} placeholder="Enter profile description" rows={3} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="certificateAuthorityId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Certificate Authority"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select a certificate authority"
|
||||
className="w-full"
|
||||
position="popper"
|
||||
>
|
||||
{certificateAuthorities.map((ca: any) => (
|
||||
<SelectItem key={ca.id} value={ca.id}>
|
||||
{ca.friendlyName || ca.name || ca.commonName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="certificateTemplateId"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Certificate Template"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
onChange(value);
|
||||
if (watchedEnrollmentType === "est") {
|
||||
setValue("estConfig", {
|
||||
disableBootstrapCaValidation: false,
|
||||
passphrase: "",
|
||||
caChain: ""
|
||||
});
|
||||
setValue("apiConfig", undefined);
|
||||
} else {
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
});
|
||||
setValue("estConfig", undefined);
|
||||
}
|
||||
}}
|
||||
placeholder="Select a certificate template"
|
||||
className="w-full"
|
||||
position="popper"
|
||||
>
|
||||
{certificateTemplates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="enrollmentType"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Enrollment Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select {...field} onValueChange={onChange} className="w-full" position="popper">
|
||||
<SelectItem value="api">API</SelectItem>
|
||||
<SelectItem value="est">EST</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EST Configuration */}
|
||||
{watchedEnrollmentType === "est" && (
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="estConfig.disableBootstrapCaValidation"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<Checkbox
|
||||
id="disableBootstrapCaValidation"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="disableBootstrapCaValidation"
|
||||
className="text-sm font-medium text-mineshaft-100"
|
||||
>
|
||||
Disable Bootstrap CA Validation
|
||||
</label>
|
||||
<p className="text-xs text-bunker-300">
|
||||
Skip CA certificate validation during EST bootstrap phase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="estConfig.passphrase"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="EST Passphrase"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Enter secure passphrase for EST authentication"
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!watchedDisableBootstrapValidation && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estConfig.caChain"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA Chain Certificate"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder="-----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX ... -----END CERTIFICATE-----"
|
||||
rows={6}
|
||||
className="w-full font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-bunker-400">
|
||||
Paste the complete CA certificate chain in PEM format
|
||||
</p>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Configuration */}
|
||||
{watchedEnrollmentType === "api" && (
|
||||
<div className="mb-4 space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="apiConfig.autoRenew"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
|
||||
Enable Auto-Renewal
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{watchedAutoRenew && (
|
||||
<div className="mb-4 space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="apiConfig.autoRenewDays"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Auto-Renewal Days"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder="30"
|
||||
min="1"
|
||||
max="365"
|
||||
className="w-full"
|
||||
isDisabled={!watchedAutoRenew}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 30)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" colorSchema="primary" isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faSave } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import {
|
||||
TCertificateProfileWithDetails,
|
||||
useUpdateCertificateProfile
|
||||
} from "@app/hooks/api/certificateProfiles";
|
||||
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: TCertificateProfileWithDetails;
|
||||
}
|
||||
|
||||
export const EditProfileModal = ({ isOpen, onClose, profile }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const updateProfile = useUpdateCertificateProfile();
|
||||
|
||||
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
|
||||
const { data: templateData } = useListCertificateTemplatesV2({
|
||||
projectId: currentProject?.id || "",
|
||||
limit: 100,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
const certificateAuthorities = caData || [];
|
||||
const certificateTemplates = templateData?.certificateTemplates || [];
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
enrollmentType: "api" as "api" | "est",
|
||||
certificateAuthorityId: "",
|
||||
certificateTemplateId: "",
|
||||
estConfig: {
|
||||
disableBootstrapCaValidation: false,
|
||||
passphrase: "",
|
||||
caChain: ""
|
||||
},
|
||||
apiConfig: {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setFormData({
|
||||
name: profile.name,
|
||||
slug: profile.slug,
|
||||
description: profile.description || "",
|
||||
enrollmentType: profile.enrollmentType,
|
||||
certificateAuthorityId: profile.caId,
|
||||
certificateTemplateId: profile.certificateTemplateId,
|
||||
estConfig: {
|
||||
disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
|
||||
passphrase: "",
|
||||
caChain: ""
|
||||
},
|
||||
apiConfig: {
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean | number) => {
|
||||
if (field.includes(".")) {
|
||||
const [parent, child] = field.split(".");
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[parent]: {
|
||||
...(prev as any)[parent],
|
||||
[child]: value
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
profileId: profile.id,
|
||||
name: formData.name,
|
||||
description: formData.description
|
||||
};
|
||||
|
||||
if (formData.enrollmentType === "est") {
|
||||
payload.estConfig = {
|
||||
disableBootstrapCaValidation: formData.estConfig.disableBootstrapCaValidation,
|
||||
passphrase: formData.estConfig.passphrase,
|
||||
caChain: formData.estConfig.caChain
|
||||
};
|
||||
} else if (formData.enrollmentType === "api") {
|
||||
payload.apiConfig = {
|
||||
autoRenew: formData.apiConfig.autoRenew,
|
||||
autoRenewDays: formData.apiConfig.autoRenewDays
|
||||
};
|
||||
}
|
||||
|
||||
await updateProfile.mutateAsync(payload);
|
||||
|
||||
createNotification({
|
||||
text: "Certificate profile updated successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error updating profile:", error);
|
||||
createNotification({
|
||||
text: "Failed to update certificate profile",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent
|
||||
title="Edit Certificate Profile"
|
||||
subTitle={`Update configuration for ${profile?.name}`}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FormControl label="Profile Name" isRequired>
|
||||
<Input
|
||||
placeholder="Enter profile name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Profile Slug" isRequired>
|
||||
<Input
|
||||
placeholder="profile-slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => handleInputChange("slug", e.target.value)}
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Description">
|
||||
<TextArea
|
||||
placeholder="Enter profile description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Enrollment Type">
|
||||
<Select
|
||||
value={formData.enrollmentType}
|
||||
onValueChange={(value) => handleInputChange("enrollmentType", value)}
|
||||
isDisabled
|
||||
>
|
||||
<SelectItem value="api">API - Programmatic certificate enrollment</SelectItem>
|
||||
<SelectItem value="est">EST - RFC 7030 certificate enrollment</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Certificate Authority">
|
||||
<Select
|
||||
value={formData.certificateAuthorityId}
|
||||
onValueChange={(value) => handleInputChange("certificateAuthorityId", value)}
|
||||
placeholder="Select a certificate authority"
|
||||
isDisabled
|
||||
>
|
||||
{certificateAuthorities.map((ca: any) => (
|
||||
<SelectItem key={ca.id} value={ca.id}>
|
||||
{ca.friendlyName || ca.name || ca.commonName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Certificate Template">
|
||||
<Select
|
||||
value={formData.certificateTemplateId}
|
||||
onValueChange={(value) => handleInputChange("certificateTemplateId", value)}
|
||||
placeholder="Select a certificate template"
|
||||
isDisabled
|
||||
>
|
||||
{certificateTemplates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* EST Configuration */}
|
||||
{formData.enrollmentType === "est" && (
|
||||
<div className="space-y-4 rounded border border-mineshaft-600 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="disableBootstrapCaValidation"
|
||||
isChecked={formData.estConfig.disableBootstrapCaValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
handleInputChange("estConfig.disableBootstrapCaValidation", checked)
|
||||
}
|
||||
>
|
||||
Disable Bootstrap CA Validation
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="EST Passphrase" isRequired>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter EST passphrase"
|
||||
value={formData.estConfig.passphrase}
|
||||
onChange={(e) => handleInputChange("estConfig.passphrase", e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="CA Chain" isRequired>
|
||||
<TextArea
|
||||
placeholder="Enter CA chain (PEM format)"
|
||||
value={formData.estConfig.caChain}
|
||||
onChange={(e) => handleInputChange("estConfig.caChain", e.target.value)}
|
||||
rows={6}
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Configuration */}
|
||||
{formData.enrollmentType === "api" && (
|
||||
<div className="space-y-4 rounded border border-mineshaft-600 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="autoRenew"
|
||||
isChecked={formData.apiConfig.autoRenew}
|
||||
onCheckedChange={(checked) => handleInputChange("apiConfig.autoRenew", checked)}
|
||||
>
|
||||
Enable Auto-Renewal
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Auto-Renewal Days">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="30"
|
||||
min="1"
|
||||
max="365"
|
||||
value={formData.apiConfig.autoRenewDays}
|
||||
onChange={(e) =>
|
||||
handleInputChange("apiConfig.autoRenewDays", parseInt(e.target.value, 10) || 30)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
isLoading={updateProfile.isPending}
|
||||
disabled={!formData.name}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose} disabled={updateProfile.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import {
|
||||
TCertificateProfile,
|
||||
useListCertificateProfiles
|
||||
} from "@app/hooks/api/certificateProfiles";
|
||||
|
||||
import { ProfileRow } from "./ProfileRow";
|
||||
|
||||
interface Props {
|
||||
onEditProfile: (profile: TCertificateProfile) => void;
|
||||
onDeleteProfile: (profile: TCertificateProfile) => void;
|
||||
}
|
||||
|
||||
export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data, isLoading } = useListCertificateProfiles({
|
||||
projectId: currentProject?.id || "",
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
includeMetrics: true
|
||||
});
|
||||
|
||||
const profiles = data?.certificateProfiles || [];
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton columns={7} innerKey="certificate-profiles" />;
|
||||
}
|
||||
|
||||
if (!profiles || profiles.length === 0) {
|
||||
return <EmptyState title="No Certificate Profiles" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Enrollment Type</Th>
|
||||
<Th>Certificate Authority</Th>
|
||||
<Th>Template</Th>
|
||||
<Th>Certificates</Th>
|
||||
<Th>Created</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{profiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEditProfile={onEditProfile}
|
||||
onDeleteProfile={onDeleteProfile}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useProjectPermission } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { useGetCaById } from "@app/hooks/api/ca/queries";
|
||||
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
|
||||
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
|
||||
|
||||
interface Props {
|
||||
profile: TCertificateProfile;
|
||||
onEditProfile: (profile: TCertificateProfile) => void;
|
||||
onDeleteProfile: (profile: TCertificateProfile) => void;
|
||||
}
|
||||
|
||||
export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { data: caData } = useGetCaById(profile.caId);
|
||||
|
||||
const { data: templateData } = useGetCertificateTemplateV2ById({
|
||||
templateId: profile.certificateTemplateId
|
||||
});
|
||||
|
||||
const canEditProfile = permission.can(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const canDeleteProfile = permission.can(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const getEnrollmentTypeBadge = (enrollmentType: string) => {
|
||||
const config = {
|
||||
api: { variant: "success" as const, label: "API" },
|
||||
est: { variant: "primary" as const, label: "EST" }
|
||||
};
|
||||
|
||||
const { variant, label } = config[enrollmentType as keyof typeof config] || config.api;
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700">
|
||||
<Td>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-mineshaft-100">{profile.name}</div>
|
||||
{profile.description && (
|
||||
<Tooltip content={profile.description}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-bunker-300">{profile.slug}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="text-center">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
|
||||
<Td className="text-center">
|
||||
<span className="text-sm text-mineshaft-300">
|
||||
{caData?.friendlyName || caData?.commonName || profile.caId}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-300">
|
||||
{templateData?.name || profile.certificateTemplateId}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex gap-2 text-xs">
|
||||
{profile.metrics ? (
|
||||
profile.metrics.totalCertificates === 0 ? (
|
||||
<span className="text-bunker-300">No certificates attached</span>
|
||||
) : (
|
||||
<>
|
||||
{profile.metrics.activeCertificates > 0 && (
|
||||
<span className="text-green-400">
|
||||
{profile.metrics.activeCertificates} active
|
||||
</span>
|
||||
)}
|
||||
{profile.metrics.expiringCertificates > 0 && (
|
||||
<>
|
||||
{profile.metrics.activeCertificates > 0 && (
|
||||
<span className="text-gray-400">•</span>
|
||||
)}
|
||||
<span className="text-yellow-400">
|
||||
{profile.metrics.expiringCertificates} expiring
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{profile.metrics.expiredCertificates > 0 && (
|
||||
<>
|
||||
{(profile.metrics.activeCertificates > 0 ||
|
||||
profile.metrics.expiringCertificates > 0) && (
|
||||
<span className="text-gray-400">•</span>
|
||||
)}
|
||||
<span className="text-red-300">
|
||||
{profile.metrics.expiredCertificates} expired
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{profile.metrics.revokedCertificates > 0 && (
|
||||
<>
|
||||
{(profile.metrics.activeCertificates > 0 ||
|
||||
profile.metrics.expiringCertificates > 0 ||
|
||||
profile.metrics.expiredCertificates > 0) && (
|
||||
<span className="text-gray-400">•</span>
|
||||
)}
|
||||
<span className="text-red-400">
|
||||
{profile.metrics.revokedCertificates} revoked
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<span className="text-bunker-300">No metrics available</span>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-bunker-300">{formatDate(profile.createdAt)}</span>
|
||||
</Td>
|
||||
<Td className="text-right">
|
||||
<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">
|
||||
{canEditProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditProfile(profile);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
>
|
||||
Edit Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeleteProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteProfile(profile);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export { CertificateProfilesTab } from "./CertificateProfilesTab";
|
||||
export { CreateProfileModal } from "./CreateProfileModal";
|
||||
export { EditProfileModal } from "./EditProfileModal";
|
||||
export { ProfileList } from "./ProfileList";
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { useProjectPermission } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDeleteCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
|
||||
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
|
||||
|
||||
import { CreateTemplateModal } from "./CreateTemplateModal";
|
||||
import { EditTemplateModal } from "./EditTemplateModal";
|
||||
import { TemplateList } from "./TemplateList";
|
||||
|
||||
export const CertificateTemplatesV2Tab = () => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TCertificateTemplateV2New | null>(null);
|
||||
|
||||
const deleteTemplateV2 = useDeleteCertificateTemplateV2New();
|
||||
|
||||
const canCreateTemplate = permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const handleCreateTemplate = () => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template: TCertificateTemplateV2New) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = (template: TCertificateTemplateV2New) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
try {
|
||||
await deleteTemplateV2.mutateAsync({
|
||||
templateId: selectedTemplate.id
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete template:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-mineshaft-100">Certificate Templates</h2>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Define certificate policies, validation rules, and attribute constraints for certificate
|
||||
issuance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreateTemplate && (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={handleCreateTemplate}
|
||||
>
|
||||
Create Template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TemplateList onEditTemplate={handleEditTemplate} onDeleteTemplate={handleDeleteTemplate} />
|
||||
|
||||
<CreateTemplateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
|
||||
|
||||
{selectedTemplate && (
|
||||
<>
|
||||
<EditTemplateModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
template={selectedTemplate}
|
||||
/>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title={`Delete Certificate Template ${selectedTemplate.name}?`}
|
||||
onChange={(isOpen) => {
|
||||
setIsDeleteModalOpen(isOpen);
|
||||
if (!isOpen) setSelectedTemplate(null);
|
||||
}}
|
||||
deleteKey={selectedTemplate.name}
|
||||
onDeleteApproved={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+699
@@ -0,0 +1,699 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useCreateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
|
||||
|
||||
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
|
||||
|
||||
export type FormData = TemplateFormData;
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ATTRIBUTE_TYPES = [{ value: "common_name", label: "Common Name (CN)" }];
|
||||
|
||||
const SAN_TYPES = [
|
||||
{ value: "dns_name", label: "DNS Name" },
|
||||
{ value: "ip_address", label: "IP Address" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "uri", label: "URI" }
|
||||
];
|
||||
|
||||
const INCLUDE_TYPES = [
|
||||
{ value: "mandatory", label: "Mandatory", color: "red" },
|
||||
{ value: "optional", label: "Optional", color: "blue" },
|
||||
{ value: "prohibit", label: "Prohibited", color: "gray" }
|
||||
];
|
||||
|
||||
const SIGNATURE_ALGORITHMS = [
|
||||
"SHA256-RSA",
|
||||
"SHA384-RSA",
|
||||
"SHA512-RSA",
|
||||
"SHA256-ECDSA",
|
||||
"SHA384-ECDSA",
|
||||
"SHA512-ECDSA"
|
||||
];
|
||||
|
||||
const KEY_ALGORITHMS = [
|
||||
"RSA-2048",
|
||||
"RSA-3072",
|
||||
"RSA-4096",
|
||||
"ECDSA-P256",
|
||||
"ECDSA-P384",
|
||||
"ECDSA-P521"
|
||||
];
|
||||
|
||||
export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const createTemplate = useCreateCertificateTemplateV2New();
|
||||
const [activeTab, setActiveTab] = useState<string>("basic");
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
attributes: [],
|
||||
keyUsages: { requiredUsages: [], optionalUsages: [] },
|
||||
extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
|
||||
subjectAlternativeNames: [],
|
||||
validity: {
|
||||
maxDuration: { value: 365, unit: "days" }
|
||||
},
|
||||
signatureAlgorithm: {
|
||||
allowedAlgorithms: ["SHA256-RSA"],
|
||||
defaultAlgorithm: "SHA256-RSA"
|
||||
},
|
||||
keyAlgorithm: {
|
||||
allowedKeyTypes: ["RSA-2048"],
|
||||
defaultKeyType: "RSA-2048"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const watchedAttributes = watch("attributes") || [];
|
||||
const watchedSans = watch("subjectAlternativeNames") || [];
|
||||
const watchedKeyUsages = watch("keyUsages");
|
||||
const watchedExtendedKeyUsages = watch("extendedKeyUsages");
|
||||
|
||||
const onFormSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (!currentProject?.id) return;
|
||||
|
||||
const templateData = {
|
||||
projectId: currentProject.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
attributes: data.attributes || [],
|
||||
keyUsages: {
|
||||
requiredUsages: { all: data.keyUsages?.requiredUsages || [] },
|
||||
optionalUsages: { all: data.keyUsages?.optionalUsages || [] }
|
||||
},
|
||||
extendedKeyUsages: {
|
||||
requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] },
|
||||
optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] }
|
||||
},
|
||||
subjectAlternativeNames: data.subjectAlternativeNames || [],
|
||||
validity: {
|
||||
maxDuration: data.validity?.maxDuration || { value: 365, unit: "days" as const }
|
||||
},
|
||||
signatureAlgorithm: {
|
||||
allowedAlgorithms: data.signatureAlgorithm?.allowedAlgorithms || ["SHA256-RSA"],
|
||||
defaultAlgorithm: data.signatureAlgorithm?.defaultAlgorithm || "SHA256-RSA"
|
||||
},
|
||||
keyAlgorithm: {
|
||||
allowedKeyTypes: data.keyAlgorithm?.allowedKeyTypes || ["RSA-2048"],
|
||||
defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048"
|
||||
}
|
||||
};
|
||||
|
||||
await createTemplate.mutateAsync(templateData);
|
||||
|
||||
createNotification({
|
||||
text: "Certificate template created successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error creating template:", error);
|
||||
createNotification({
|
||||
text: "Failed to create certificate template",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addAttribute = () => {
|
||||
const newAttribute = {
|
||||
type: "common_name" as const,
|
||||
include: "optional" as const,
|
||||
value: []
|
||||
};
|
||||
setValue("attributes", [...watchedAttributes, newAttribute]);
|
||||
};
|
||||
|
||||
const removeAttribute = (index: number) => {
|
||||
const newAttributes = watchedAttributes.filter((_, i) => i !== index);
|
||||
setValue("attributes", newAttributes);
|
||||
};
|
||||
|
||||
const addSan = () => {
|
||||
const newSan = {
|
||||
type: "dns_name" as const,
|
||||
include: "optional" as const,
|
||||
value: []
|
||||
};
|
||||
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
|
||||
};
|
||||
|
||||
const removeSan = (index: number) => {
|
||||
const newSans = watchedSans.filter((_, i) => i !== index);
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
};
|
||||
|
||||
const toggleKeyUsage = (usage: string, type: "required" | "optional") => {
|
||||
const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
|
||||
const otherType = type === "required" ? "optional" : "required";
|
||||
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
|
||||
const otherList = Array.isArray(current[`${otherType}Usages`])
|
||||
? current[`${otherType}Usages`]
|
||||
: [];
|
||||
|
||||
const newOtherList = (otherList || []).filter((u) => u !== usage);
|
||||
const newCurrentList = currentList?.includes(usage)
|
||||
? currentList.filter((u) => u !== usage)
|
||||
: [...(currentList || []), usage];
|
||||
|
||||
setValue("keyUsages", {
|
||||
[`${type}Usages`]: newCurrentList,
|
||||
[`${otherType}Usages`]: newOtherList
|
||||
} as any);
|
||||
};
|
||||
|
||||
const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => {
|
||||
const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
|
||||
const otherType = type === "required" ? "optional" : "required";
|
||||
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
|
||||
const otherList = Array.isArray(current[`${otherType}Usages`])
|
||||
? current[`${otherType}Usages`]
|
||||
: [];
|
||||
|
||||
const newOtherList = (otherList || []).filter((u) => u !== usage);
|
||||
const newCurrentList = currentList?.includes(usage)
|
||||
? currentList.filter((u) => u !== usage)
|
||||
: [...(currentList || []), usage];
|
||||
|
||||
setValue("extendedKeyUsages", {
|
||||
[`${type}Usages`]: newCurrentList,
|
||||
[`${otherType}Usages`]: newOtherList
|
||||
} as any);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "basic", label: "Basic Info" },
|
||||
{ id: "attributes", label: "Subject Attributes" },
|
||||
{ id: "san", label: "Subject Alternative Names" },
|
||||
{ id: "usages", label: "Key Usages" },
|
||||
{ id: "constraints", label: "Constraints" }
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title="Create Certificate Template V2"
|
||||
subTitle="Define comprehensive certificate policies, validation rules, and constraints"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-mineshaft-600">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary-500 text-primary-400"
|
||||
: "border-transparent text-bunker-300 hover:text-mineshaft-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{activeTab === "basic" && (
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Template Name"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Enter template name" className="w-full" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Description"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} placeholder="Enter template description" rows={3} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "attributes" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAttribute}
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Attribute
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{watchedAttributes.length === 0 ? (
|
||||
<div className="py-8 text-center text-bunker-300">
|
||||
No subject attributes configured yet. Click "Add Attribute" to get
|
||||
started.
|
||||
</div>
|
||||
) : (
|
||||
watchedAttributes.map((attr, index) => (
|
||||
<div
|
||||
key={`attr-${attr.type}`}
|
||||
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3"
|
||||
>
|
||||
<Select
|
||||
value={attr.type}
|
||||
onValueChange={(value) => {
|
||||
const newAttributes = [...watchedAttributes];
|
||||
newAttributes[index] = { ...attr, type: value as any };
|
||||
setValue("attributes", newAttributes);
|
||||
}}
|
||||
className="w-56"
|
||||
position="popper"
|
||||
>
|
||||
{ATTRIBUTE_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={attr.include}
|
||||
onValueChange={(value) => {
|
||||
const newAttributes = [...watchedAttributes];
|
||||
newAttributes[index] = { ...attr, include: value as any };
|
||||
setValue("attributes", newAttributes);
|
||||
}}
|
||||
className="w-36"
|
||||
position="popper"
|
||||
>
|
||||
{INCLUDE_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Pattern/Value (optional)"
|
||||
value={attr.value?.[0] || ""}
|
||||
onChange={(e) => {
|
||||
const newAttributes = [...watchedAttributes];
|
||||
newAttributes[index] = {
|
||||
...attr,
|
||||
value: e.target.value ? [e.target.value] : []
|
||||
};
|
||||
setValue("attributes", newAttributes);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeAttribute(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "san" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addSan}
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add SAN
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{watchedSans.length === 0 ? (
|
||||
<div className="py-8 text-center text-bunker-300">
|
||||
No subject alternative names configured yet. Click "Add SAN" to get
|
||||
started.
|
||||
</div>
|
||||
) : (
|
||||
watchedSans.map((san, index) => (
|
||||
<div
|
||||
key={`san-${san.type}`}
|
||||
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3"
|
||||
>
|
||||
<Select
|
||||
value={san.type}
|
||||
onValueChange={(value) => {
|
||||
const newSans = [...watchedSans];
|
||||
newSans[index] = { ...san, type: value as any };
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
}}
|
||||
className="w-36"
|
||||
position="popper"
|
||||
>
|
||||
{SAN_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={san.include}
|
||||
onValueChange={(value) => {
|
||||
const newSans = [...watchedSans];
|
||||
newSans[index] = { ...san, include: value as any };
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
}}
|
||||
className="w-36"
|
||||
position="popper"
|
||||
>
|
||||
{INCLUDE_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Pattern/Value (optional)"
|
||||
value={san.value?.[0] || ""}
|
||||
onChange={(e) => {
|
||||
const newSans = [...watchedSans];
|
||||
newSans[index] = {
|
||||
...san,
|
||||
value: e.target.value ? [e.target.value] : []
|
||||
};
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeSan(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "usages" && (
|
||||
<KeyUsagesSection
|
||||
watchedKeyUsages={watchedKeyUsages}
|
||||
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
|
||||
toggleKeyUsage={toggleKeyUsage}
|
||||
toggleExtendedKeyUsage={toggleExtendedKeyUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "constraints" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="validity.maxDuration.value"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Max Duration"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
placeholder="365"
|
||||
className="w-full"
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="validity.maxDuration.unit"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Unit"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
className="w-full"
|
||||
position="popper"
|
||||
>
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
<SelectItem value="months">Months</SelectItem>
|
||||
<SelectItem value="years">Years</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="signatureAlgorithm.allowedAlgorithms"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Allowed Signature Algorithms"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SIGNATURE_ALGORITHMS.map((alg) => {
|
||||
const isSelected = field.value?.includes(alg);
|
||||
return (
|
||||
<Button
|
||||
key={alg}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isSelected ? "solid" : "outline"}
|
||||
colorSchema={isSelected ? "primary" : "gray"}
|
||||
onClick={() => {
|
||||
const current = field.value || [];
|
||||
let newValue;
|
||||
if (isSelected) {
|
||||
if (current.length > 1) {
|
||||
newValue = current.filter((a) => a !== alg);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
newValue = [...current, alg];
|
||||
}
|
||||
field.onChange(newValue);
|
||||
|
||||
const currentDefault = watch(
|
||||
"signatureAlgorithm.defaultAlgorithm"
|
||||
);
|
||||
if (currentDefault && !newValue.includes(currentDefault)) {
|
||||
setValue(
|
||||
"signatureAlgorithm.defaultAlgorithm",
|
||||
newValue[0]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alg}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="signatureAlgorithm.defaultAlgorithm"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Default Signature Algorithm"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
className="w-full"
|
||||
position="popper"
|
||||
>
|
||||
{(watch("signatureAlgorithm.allowedAlgorithms") || []).map(
|
||||
(alg: string) => (
|
||||
<SelectItem key={alg} value={alg}>
|
||||
{alg}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyAlgorithm.allowedKeyTypes"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Allowed Key Algorithms"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEY_ALGORITHMS.map((alg) => {
|
||||
const isSelected = field.value?.includes(alg);
|
||||
return (
|
||||
<Button
|
||||
key={alg}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isSelected ? "solid" : "outline"}
|
||||
colorSchema={isSelected ? "primary" : "gray"}
|
||||
onClick={() => {
|
||||
const current = field.value || [];
|
||||
let newValue;
|
||||
if (isSelected) {
|
||||
if (current.length > 1) {
|
||||
newValue = current.filter((a) => a !== alg);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
newValue = [...current, alg];
|
||||
}
|
||||
field.onChange(newValue);
|
||||
|
||||
const currentDefault = watch("keyAlgorithm.defaultKeyType");
|
||||
if (currentDefault && !newValue.includes(currentDefault)) {
|
||||
setValue("keyAlgorithm.defaultKeyType", newValue[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alg}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyAlgorithm.defaultKeyType"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Default Key Algorithm"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
className="w-full"
|
||||
position="popper"
|
||||
>
|
||||
{(watch("keyAlgorithm.allowedKeyTypes") || []).map((alg: string) => (
|
||||
<SelectItem key={alg} value={alg}>
|
||||
{alg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" colorSchema="primary" isLoading={createTemplate.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose} disabled={createTemplate.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+778
@@ -0,0 +1,778 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-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 {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useUpdateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
|
||||
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
|
||||
|
||||
import { KeyUsagesSection } from "./shared";
|
||||
|
||||
const attributeSchema = z.object({
|
||||
type: z.enum(["common_name"]),
|
||||
include: z.enum(["mandatory", "optional", "prohibit"]),
|
||||
value: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
const sanSchema = z.object({
|
||||
type: z.enum(["dns_name", "ip_address", "email", "uri"]),
|
||||
include: z.enum(["mandatory", "optional", "prohibit"]),
|
||||
value: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1, "Template name is required"),
|
||||
description: z.string().optional(),
|
||||
attributes: z.array(attributeSchema).optional(),
|
||||
keyUsages: z
|
||||
.object({
|
||||
requiredUsages: z.array(z.string()),
|
||||
optionalUsages: z.array(z.string())
|
||||
})
|
||||
.optional(),
|
||||
extendedKeyUsages: z
|
||||
.object({
|
||||
requiredUsages: z.array(z.string()),
|
||||
optionalUsages: z.array(z.string())
|
||||
})
|
||||
.optional(),
|
||||
subjectAlternativeNames: z.array(sanSchema).optional(),
|
||||
validity: z
|
||||
.object({
|
||||
maxDuration: z.object({
|
||||
value: z.number().positive(),
|
||||
unit: z.enum(["days", "months", "years"])
|
||||
}),
|
||||
minDuration: z
|
||||
.object({
|
||||
value: z.number().positive(),
|
||||
unit: z.enum(["days", "months", "years"])
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional(),
|
||||
signatureAlgorithm: z
|
||||
.object({
|
||||
allowedAlgorithms: z.array(z.string()).min(1),
|
||||
defaultAlgorithm: z.string()
|
||||
})
|
||||
.optional(),
|
||||
keyAlgorithm: z
|
||||
.object({
|
||||
allowedKeyTypes: z.array(z.string()).min(1),
|
||||
defaultKeyType: z.string()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
template: TCertificateTemplateV2New;
|
||||
}
|
||||
|
||||
const ATTRIBUTE_TYPES = [
|
||||
{ value: "common_name", label: "Common Name (CN)" }
|
||||
];
|
||||
|
||||
const SAN_TYPES = [
|
||||
{ value: "dns_name", label: "DNS Name" },
|
||||
{ value: "ip_address", label: "IP Address" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "uri", label: "URI" }
|
||||
];
|
||||
|
||||
const INCLUDE_TYPES = [
|
||||
{ value: "mandatory", label: "Mandatory", color: "red" },
|
||||
{ value: "optional", label: "Optional", color: "blue" },
|
||||
{ value: "prohibit", label: "Prohibited", color: "gray" }
|
||||
];
|
||||
|
||||
const SIGNATURE_ALGORITHMS = [
|
||||
"SHA256-RSA",
|
||||
"SHA384-RSA",
|
||||
"SHA512-RSA",
|
||||
"SHA256-ECDSA",
|
||||
"SHA384-ECDSA",
|
||||
"SHA512-ECDSA"
|
||||
];
|
||||
|
||||
const KEY_ALGORITHMS = [
|
||||
"RSA-2048",
|
||||
"RSA-3072",
|
||||
"RSA-4096",
|
||||
"ECDSA-P256",
|
||||
"ECDSA-P384",
|
||||
"ECDSA-P521"
|
||||
];
|
||||
|
||||
export const EditTemplateModal = ({ isOpen, onClose, template }: Props) => {
|
||||
const updateTemplate = useUpdateCertificateTemplateV2New();
|
||||
const [activeTab, setActiveTab] = useState<string>("basic");
|
||||
|
||||
const getFormDefaultValues = () => {
|
||||
if (!template) {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
attributes: [],
|
||||
keyUsages: { requiredUsages: [], optionalUsages: [] },
|
||||
extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
|
||||
subjectAlternativeNames: [],
|
||||
validity: { maxDuration: { value: 365, unit: "days" as const } },
|
||||
signatureAlgorithm: { allowedAlgorithms: ["SHA256-RSA"], defaultAlgorithm: "SHA256-RSA" },
|
||||
keyAlgorithm: { allowedKeyTypes: ["RSA-2048"], defaultKeyType: "RSA-2048" }
|
||||
};
|
||||
}
|
||||
|
||||
const backendKeyUsages = template.keyUsages || {
|
||||
requiredUsages: { all: [] },
|
||||
optionalUsages: { all: [] }
|
||||
};
|
||||
const backendExtendedKeyUsages = template.extendedKeyUsages || {
|
||||
requiredUsages: { all: [] },
|
||||
optionalUsages: { all: [] }
|
||||
};
|
||||
|
||||
return {
|
||||
name: template.name,
|
||||
description: template.description || "",
|
||||
attributes: template.attributes || [],
|
||||
keyUsages: {
|
||||
requiredUsages: backendKeyUsages.requiredUsages?.all || [],
|
||||
optionalUsages: backendKeyUsages.optionalUsages?.all || []
|
||||
},
|
||||
extendedKeyUsages: {
|
||||
requiredUsages: backendExtendedKeyUsages.requiredUsages?.all || [],
|
||||
optionalUsages: backendExtendedKeyUsages.optionalUsages?.all || []
|
||||
},
|
||||
subjectAlternativeNames: template.subjectAlternativeNames || [],
|
||||
validity: template.validity || { maxDuration: { value: 365, unit: "days" as const } },
|
||||
signatureAlgorithm: template.signatureAlgorithm || {
|
||||
allowedAlgorithms: ["SHA256-RSA"],
|
||||
defaultAlgorithm: "SHA256-RSA"
|
||||
},
|
||||
keyAlgorithm: template.keyAlgorithm || {
|
||||
allowedKeyTypes: ["RSA-2048"],
|
||||
defaultKeyType: "RSA-2048"
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: getFormDefaultValues()
|
||||
});
|
||||
|
||||
const watchedAttributes = watch("attributes") || [];
|
||||
const watchedSans = watch("subjectAlternativeNames") || [];
|
||||
const watchedKeyUsages = watch("keyUsages");
|
||||
const watchedExtendedKeyUsages = watch("extendedKeyUsages");
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
reset(getFormDefaultValues());
|
||||
}
|
||||
}, [template, reset]);
|
||||
|
||||
const onFormSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const templateData = {
|
||||
templateId: template.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
attributes: data.attributes || [],
|
||||
keyUsages: {
|
||||
requiredUsages: { all: data.keyUsages?.requiredUsages || [] },
|
||||
optionalUsages: { all: data.keyUsages?.optionalUsages || [] }
|
||||
},
|
||||
extendedKeyUsages: {
|
||||
requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] },
|
||||
optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] }
|
||||
},
|
||||
subjectAlternativeNames: data.subjectAlternativeNames || [],
|
||||
validity: data.validity || {
|
||||
maxDuration: { value: 365, unit: "days" as const }
|
||||
},
|
||||
signatureAlgorithm: data.signatureAlgorithm || {
|
||||
allowedAlgorithms: ["SHA256-RSA"],
|
||||
defaultAlgorithm: "SHA256-RSA"
|
||||
},
|
||||
keyAlgorithm: data.keyAlgorithm || {
|
||||
allowedKeyTypes: ["RSA-2048"],
|
||||
defaultKeyType: "RSA-2048"
|
||||
}
|
||||
};
|
||||
|
||||
await updateTemplate.mutateAsync(templateData);
|
||||
|
||||
createNotification({
|
||||
text: "Certificate template updated successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error updating template:", error);
|
||||
createNotification({
|
||||
text: "Failed to update certificate template",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addAttribute = () => {
|
||||
const newAttribute = {
|
||||
type: "common_name" as const,
|
||||
include: "optional" as const,
|
||||
value: []
|
||||
};
|
||||
setValue("attributes", [...watchedAttributes, newAttribute]);
|
||||
};
|
||||
|
||||
const removeAttribute = (index: number) => {
|
||||
const newAttributes = watchedAttributes.filter((_, i) => i !== index);
|
||||
setValue("attributes", newAttributes);
|
||||
};
|
||||
|
||||
const addSan = () => {
|
||||
const newSan = {
|
||||
type: "dns_name" as const,
|
||||
include: "optional" as const,
|
||||
value: []
|
||||
};
|
||||
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
|
||||
};
|
||||
|
||||
const removeSan = (index: number) => {
|
||||
const newSans = watchedSans.filter((_, i) => i !== index);
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
};
|
||||
|
||||
const toggleKeyUsage = (usage: string, type: "required" | "optional") => {
|
||||
const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
|
||||
const otherType = type === "required" ? "optional" : "required";
|
||||
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
|
||||
const otherList = Array.isArray(current[`${otherType}Usages`])
|
||||
? current[`${otherType}Usages`]
|
||||
: [];
|
||||
|
||||
const newOtherList = otherList.filter((u) => u !== usage);
|
||||
|
||||
const newCurrentList = currentList.includes(usage)
|
||||
? currentList.filter((u) => u !== usage)
|
||||
: [...currentList, usage];
|
||||
|
||||
setValue("keyUsages", {
|
||||
[`${type}Usages`]: newCurrentList,
|
||||
[`${otherType}Usages`]: newOtherList
|
||||
} as any);
|
||||
};
|
||||
|
||||
const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => {
|
||||
const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
|
||||
const otherType = type === "required" ? "optional" : "required";
|
||||
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
|
||||
const otherList = Array.isArray(current[`${otherType}Usages`])
|
||||
? current[`${otherType}Usages`]
|
||||
: [];
|
||||
|
||||
const newOtherList = otherList.filter((u) => u !== usage);
|
||||
|
||||
const newCurrentList = currentList.includes(usage)
|
||||
? currentList.filter((u) => u !== usage)
|
||||
: [...currentList, usage];
|
||||
|
||||
setValue("extendedKeyUsages", {
|
||||
[`${type}Usages`]: newCurrentList,
|
||||
[`${otherType}Usages`]: newOtherList
|
||||
} as any);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "basic", label: "Basic Info" },
|
||||
{ id: "attributes", label: "Subject Attributes" },
|
||||
{ id: "san", label: "Subject Alternative Names" },
|
||||
{ id: "usages", label: "Key Usages" },
|
||||
{ id: "constraints", label: "Constraints" }
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title="Edit Certificate Template"
|
||||
subTitle={`Update configuration for ${template?.name}`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-mineshaft-600">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary-500 text-primary-400"
|
||||
: "border-transparent text-bunker-300 hover:text-mineshaft-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{activeTab === "basic" && (
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Template Name"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Enter template name" className="w-full" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Description"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} placeholder="Enter template description" rows={3} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "attributes" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAttribute}
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Attribute
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{watchedAttributes.length === 0 ? (
|
||||
<div className="py-8 text-center text-bunker-300">
|
||||
No subject attributes configured yet. Click "Add Attribute" to get
|
||||
started.
|
||||
</div>
|
||||
) : (
|
||||
watchedAttributes.map((attr, index) => (
|
||||
<div
|
||||
key={`attr-${attr.type}`}
|
||||
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3"
|
||||
>
|
||||
<Select
|
||||
value={attr.type}
|
||||
onValueChange={(value) => {
|
||||
const newAttributes = [...watchedAttributes];
|
||||
newAttributes[index] = { ...attr, type: value as any };
|
||||
setValue("attributes", newAttributes);
|
||||
}}
|
||||
className="w-56"
|
||||
>
|
||||
{ATTRIBUTE_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={attr.include}
|
||||
onValueChange={(value) => {
|
||||
const newAttributes = [...watchedAttributes];
|
||||
newAttributes[index] = { ...attr, include: value as any };
|
||||
setValue("attributes", newAttributes);
|
||||
}}
|
||||
className="w-36"
|
||||
>
|
||||
{INCLUDE_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Pattern/Value (optional)"
|
||||
value={attr.value?.[0] || ""}
|
||||
onChange={(e) => {
|
||||
const newAttributes = [...watchedAttributes];
|
||||
newAttributes[index] = {
|
||||
...attr,
|
||||
value: e.target.value ? [e.target.value] : []
|
||||
};
|
||||
setValue("attributes", newAttributes);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeAttribute(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "san" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addSan}
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add SAN
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{watchedSans.length === 0 ? (
|
||||
<div className="py-8 text-center text-bunker-300">
|
||||
No subject alternative names configured yet. Click "Add SAN" to get
|
||||
started.
|
||||
</div>
|
||||
) : (
|
||||
watchedSans.map((san, index) => (
|
||||
<div
|
||||
key={`san-${san.type}`}
|
||||
className="flex items-center gap-3 rounded border border-mineshaft-600 p-3"
|
||||
>
|
||||
<Select
|
||||
value={san.type}
|
||||
onValueChange={(value) => {
|
||||
const newSans = [...watchedSans];
|
||||
newSans[index] = { ...san, type: value as any };
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
}}
|
||||
className="w-36"
|
||||
>
|
||||
{SAN_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={san.include}
|
||||
onValueChange={(value) => {
|
||||
const newSans = [...watchedSans];
|
||||
newSans[index] = { ...san, include: value as any };
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
}}
|
||||
className="w-36"
|
||||
>
|
||||
{INCLUDE_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Pattern/Value (optional)"
|
||||
value={san.value?.[0] || ""}
|
||||
onChange={(e) => {
|
||||
const newSans = [...watchedSans];
|
||||
newSans[index] = {
|
||||
...san,
|
||||
value: e.target.value ? [e.target.value] : []
|
||||
};
|
||||
setValue("subjectAlternativeNames", newSans);
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeSan(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "usages" && (
|
||||
<KeyUsagesSection
|
||||
watchedKeyUsages={watchedKeyUsages}
|
||||
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
|
||||
toggleKeyUsage={toggleKeyUsage}
|
||||
toggleExtendedKeyUsage={toggleExtendedKeyUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "constraints" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="validity.maxDuration.value"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Max Duration"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="number" placeholder="365" className="w-full" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="validity.maxDuration.unit"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Unit"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select {...field} onValueChange={field.onChange} className="w-full">
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
<SelectItem value="months">Months</SelectItem>
|
||||
<SelectItem value="years">Years</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="signatureAlgorithm.allowedAlgorithms"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Allowed Signature Algorithms"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SIGNATURE_ALGORITHMS.map((alg) => {
|
||||
const isSelected = field.value?.includes(alg);
|
||||
return (
|
||||
<Button
|
||||
key={alg}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isSelected ? "solid" : "outline"}
|
||||
colorSchema={isSelected ? "primary" : "gray"}
|
||||
onClick={() => {
|
||||
const current = field.value || [];
|
||||
let newValue;
|
||||
if (isSelected) {
|
||||
if (current.length > 1) {
|
||||
newValue = current.filter((a) => a !== alg);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
newValue = [...current, alg];
|
||||
}
|
||||
field.onChange(newValue);
|
||||
|
||||
const currentDefault = watch(
|
||||
"signatureAlgorithm.defaultAlgorithm"
|
||||
);
|
||||
if (!newValue.includes(currentDefault)) {
|
||||
setValue(
|
||||
"signatureAlgorithm.defaultAlgorithm",
|
||||
newValue[0]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alg}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="signatureAlgorithm.defaultAlgorithm"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Default Signature Algorithm"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
className="w-full"
|
||||
>
|
||||
{(watch("signatureAlgorithm.allowedAlgorithms") || []).map(
|
||||
(alg: string) => (
|
||||
<SelectItem key={alg} value={alg}>
|
||||
{alg}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyAlgorithm.allowedKeyTypes"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Allowed Key Algorithms"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEY_ALGORITHMS.map((alg) => {
|
||||
const isSelected = field.value?.includes(alg);
|
||||
return (
|
||||
<Button
|
||||
key={alg}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={isSelected ? "solid" : "outline"}
|
||||
colorSchema={isSelected ? "primary" : "gray"}
|
||||
onClick={() => {
|
||||
const current = field.value || [];
|
||||
let newValue;
|
||||
if (isSelected) {
|
||||
if (current.length > 1) {
|
||||
newValue = current.filter((a) => a !== alg);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
newValue = [...current, alg];
|
||||
}
|
||||
field.onChange(newValue);
|
||||
|
||||
const currentDefault = watch("keyAlgorithm.defaultKeyType");
|
||||
if (!newValue.includes(currentDefault)) {
|
||||
setValue("keyAlgorithm.defaultKeyType", newValue[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alg}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyAlgorithm.defaultKeyType"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Default Key Algorithm"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
className="w-full"
|
||||
>
|
||||
{(watch("keyAlgorithm.allowedKeyTypes") || []).map((alg: string) => (
|
||||
<SelectItem key={alg} value={alg}>
|
||||
{alg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" colorSchema="primary" isLoading={updateTemplate.isPending}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose} disabled={updateTemplate.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useProject, useProjectPermission } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
|
||||
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
|
||||
|
||||
interface Props {
|
||||
onEditTemplate: (template: TCertificateTemplateV2New) => void;
|
||||
onDeleteTemplate: (template: TCertificateTemplateV2New) => void;
|
||||
}
|
||||
|
||||
export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data, isLoading } = useListCertificateTemplatesV2({
|
||||
projectId: currentProject?.id || "",
|
||||
limit: 100,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
const templates = data?.certificateTemplates || [];
|
||||
|
||||
const canEditTemplate = permission.can(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const canDeleteTemplate = permission.can(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton columns={4} innerKey="certificate-templates" />;
|
||||
}
|
||||
|
||||
if (!templates || templates.length === 0) {
|
||||
return <EmptyState title="No Certificate Templates" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Created</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{templates.map((template) => (
|
||||
<Tr
|
||||
key={template.id}
|
||||
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">{template.name}</div>
|
||||
{template.description && (
|
||||
<Tooltip content={template.description}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-bunker-300">{formatDate(template.createdAt)}</span>
|
||||
</Td>
|
||||
<Td className="text-right">
|
||||
<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">
|
||||
{canEditTemplate && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditTemplate(template);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
>
|
||||
Edit Template
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeleteTemplate && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTemplate(template);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Template
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
|
||||
export { CreateTemplateModal } from "./CreateTemplateModal";
|
||||
export { EditTemplateModal } from "./EditTemplateModal";
|
||||
export { TemplateList } from "./TemplateList";
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { Button } from "@app/components/v2";
|
||||
|
||||
import {
|
||||
EXTENDED_KEY_USAGES,
|
||||
formatUsageName,
|
||||
getUsageState,
|
||||
KEY_USAGES,
|
||||
toggleUsageState
|
||||
} from "./utils";
|
||||
|
||||
type UsageToggleProps = {
|
||||
value: "required" | "optional" | undefined;
|
||||
onChange: (value: "required" | "optional" | undefined) => void;
|
||||
};
|
||||
|
||||
export const UsageToggle = ({ value, onChange }: UsageToggleProps) => {
|
||||
return (
|
||||
<div className="flex gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
onChange(value === "required" ? undefined : "required");
|
||||
}}
|
||||
size="xs"
|
||||
className={`${
|
||||
value === "required" ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
Required
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
onChange(value === "optional" ? undefined : "optional");
|
||||
}}
|
||||
size="xs"
|
||||
className={`${
|
||||
value === "optional" ? "bg-mineshaft-500" : "bg-transparent"
|
||||
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
|
||||
>
|
||||
Optional
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type KeyUsagesSectionProps = {
|
||||
watchedKeyUsages?: {
|
||||
requiredUsages?: string[];
|
||||
optionalUsages?: string[];
|
||||
};
|
||||
watchedExtendedKeyUsages?: {
|
||||
requiredUsages?: string[];
|
||||
optionalUsages?: string[];
|
||||
};
|
||||
toggleKeyUsage: (usage: string, type: "required" | "optional") => void;
|
||||
toggleExtendedKeyUsage: (usage: string, type: "required" | "optional") => void;
|
||||
};
|
||||
|
||||
export const KeyUsagesSection = ({
|
||||
watchedKeyUsages,
|
||||
watchedExtendedKeyUsages,
|
||||
toggleKeyUsage,
|
||||
toggleExtendedKeyUsage
|
||||
}: KeyUsagesSectionProps) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">Key Usages</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{KEY_USAGES.map((usage) => {
|
||||
const requiredUsages = Array.isArray(watchedKeyUsages?.requiredUsages)
|
||||
? watchedKeyUsages.requiredUsages
|
||||
: [];
|
||||
const optionalUsages = Array.isArray(watchedKeyUsages?.optionalUsages)
|
||||
? watchedKeyUsages.optionalUsages
|
||||
: [];
|
||||
|
||||
const currentState = getUsageState(usage, requiredUsages, optionalUsages);
|
||||
|
||||
return (
|
||||
<div key={usage} className="flex items-center justify-between p-2">
|
||||
<span className="text-sm capitalize text-mineshaft-300">
|
||||
{formatUsageName(usage)}
|
||||
</span>
|
||||
<UsageToggle
|
||||
value={currentState}
|
||||
onChange={(newValue) => {
|
||||
toggleUsageState(
|
||||
usage,
|
||||
newValue,
|
||||
requiredUsages,
|
||||
optionalUsages,
|
||||
(u) => toggleKeyUsage(u, "required"),
|
||||
(u) => toggleKeyUsage(u, "optional")
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-mineshaft-200">Extended Key Usages</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{EXTENDED_KEY_USAGES.map((usage) => {
|
||||
const requiredUsages = Array.isArray(watchedExtendedKeyUsages?.requiredUsages)
|
||||
? watchedExtendedKeyUsages.requiredUsages
|
||||
: [];
|
||||
const optionalUsages = Array.isArray(watchedExtendedKeyUsages?.optionalUsages)
|
||||
? watchedExtendedKeyUsages.optionalUsages
|
||||
: [];
|
||||
|
||||
const currentState = getUsageState(usage, requiredUsages, optionalUsages);
|
||||
|
||||
return (
|
||||
<div key={usage} className="flex items-center justify-between p-2">
|
||||
<span className="text-sm capitalize text-mineshaft-300">
|
||||
{formatUsageName(usage)}
|
||||
</span>
|
||||
<UsageToggle
|
||||
value={currentState}
|
||||
onChange={(newValue) => {
|
||||
toggleUsageState(
|
||||
usage,
|
||||
newValue,
|
||||
requiredUsages,
|
||||
optionalUsages,
|
||||
(u) => toggleExtendedKeyUsage(u, "required"),
|
||||
(u) => toggleExtendedKeyUsage(u, "optional")
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./components";
|
||||
export * from "./schemas";
|
||||
export * from "./utils";
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./utils";
|
||||
|
||||
export const attributeSchema = z.object({
|
||||
type: z.enum(SUBJECT_ATTRIBUTE_TYPES),
|
||||
include: z.enum(INCLUDE_OPTIONS),
|
||||
value: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
export const sanSchema = z.object({
|
||||
type: z.enum(SAN_TYPES),
|
||||
include: z.enum(INCLUDE_OPTIONS),
|
||||
value: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
export const templateSchema = z.object({
|
||||
name: z.string().trim().min(1, "Template name is required"),
|
||||
description: z.string().optional(),
|
||||
attributes: z.array(attributeSchema).optional(),
|
||||
keyUsages: z
|
||||
.object({
|
||||
requiredUsages: z.array(z.string()).optional(),
|
||||
optionalUsages: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
extendedKeyUsages: z
|
||||
.object({
|
||||
requiredUsages: z.array(z.string()).optional(),
|
||||
optionalUsages: z.array(z.string()).optional()
|
||||
})
|
||||
.optional(),
|
||||
subjectAlternativeNames: z.array(sanSchema).optional(),
|
||||
validity: z
|
||||
.object({
|
||||
maxDuration: z
|
||||
.object({
|
||||
value: z.number().min(1, "Duration must be at least 1"),
|
||||
unit: z.enum(["days", "months", "years"])
|
||||
})
|
||||
.optional(),
|
||||
minDuration: z
|
||||
.object({
|
||||
value: z.number().min(1, "Duration must be at least 1"),
|
||||
unit: z.enum(["days", "months", "years"])
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional(),
|
||||
signatureAlgorithm: z
|
||||
.object({
|
||||
allowedAlgorithms: z.array(z.string()).optional(),
|
||||
defaultAlgorithm: z.string().optional()
|
||||
})
|
||||
.optional(),
|
||||
keyAlgorithm: z
|
||||
.object({
|
||||
allowedKeyTypes: z.array(z.string()).optional(),
|
||||
defaultKeyType: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type TemplateFormData = z.infer<typeof templateSchema>;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
export const KEY_USAGES = [
|
||||
"digital_signature",
|
||||
"key_encipherment",
|
||||
"non_repudiation",
|
||||
"data_encipherment",
|
||||
"key_agreement",
|
||||
"key_cert_sign",
|
||||
"crl_sign",
|
||||
"encipher_only",
|
||||
"decipher_only"
|
||||
] as const;
|
||||
|
||||
export const EXTENDED_KEY_USAGES = [
|
||||
"client_auth",
|
||||
"server_auth",
|
||||
"code_signing",
|
||||
"email_protection",
|
||||
"ocsp_signing",
|
||||
"time_stamping"
|
||||
] as const;
|
||||
|
||||
export const SUBJECT_ATTRIBUTE_TYPES = [
|
||||
"common_name",
|
||||
"organization_name",
|
||||
"organization_unit",
|
||||
"locality",
|
||||
"state",
|
||||
"country",
|
||||
"email",
|
||||
"street_address",
|
||||
"postal_code"
|
||||
] as const;
|
||||
|
||||
export const SAN_TYPES = ["dns_name", "ip_address", "email", "uri"] as const;
|
||||
|
||||
export const INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
|
||||
|
||||
export const formatUsageName = (usage: string): string => {
|
||||
return usage.replace(/_/g, " ");
|
||||
};
|
||||
export const getUsageState = (
|
||||
usage: string,
|
||||
requiredUsages: string[],
|
||||
optionalUsages: string[]
|
||||
): "required" | "optional" | undefined => {
|
||||
if (requiredUsages.includes(usage)) return "required";
|
||||
if (optionalUsages.includes(usage)) return "optional";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const toggleUsageState = (
|
||||
usage: string,
|
||||
newState: "required" | "optional" | undefined,
|
||||
currentRequiredUsages: string[],
|
||||
currentOptionalUsages: string[],
|
||||
toggleRequired: (usage: string) => void,
|
||||
toggleOptional: (usage: string) => void
|
||||
) => {
|
||||
const isRequired = currentRequiredUsages.includes(usage);
|
||||
const isOptional = currentOptionalUsages.includes(usage);
|
||||
|
||||
if (newState === "required") {
|
||||
if (isOptional) toggleOptional(usage);
|
||||
if (!isRequired) toggleRequired(usage);
|
||||
} else if (newState === "optional") {
|
||||
if (isRequired) toggleRequired(usage);
|
||||
if (!isOptional) toggleOptional(usage);
|
||||
} else {
|
||||
if (isRequired) toggleRequired(usage);
|
||||
if (isOptional) toggleOptional(usage);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CertificateProfilesTab } from "./CertificateProfilesTab";
|
||||
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
|
||||
@@ -0,0 +1 @@
|
||||
export { PoliciesPage } from "./PoliciesPage";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { PoliciesPage } from "./PoliciesPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies"
|
||||
)({
|
||||
component: PoliciesPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Certificate Policies"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user