Files
infisical/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx
T

957 lines
35 KiB
TypeScript

/* eslint-disable no-nested-ternary */
import { useEffect } 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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
useCreateCertificateTemplateV2WithPolicies,
useUpdateCertificateTemplateV2WithPolicies
} from "@app/hooks/api/certificateTemplates/mutations";
import {
TCertificateTemplateV2Policy,
TCertificateTemplateV2WithPolicies
} from "@app/hooks/api/certificateTemplates/types";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType,
SAN_INCLUDE_OPTIONS,
SAN_TYPE_OPTIONS,
SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS,
SUBJECT_ATTRIBUTE_TYPE_OPTIONS
} from "./shared/certificate-constants";
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
export type FormData = TemplateFormData;
type AttributeTransform = NonNullable<TCertificateTemplateV2Policy["subject"]>[0];
type SanTransform = NonNullable<TCertificateTemplateV2Policy["sans"]>[0];
type KeyUsagesTransform = TCertificateTemplateV2Policy["keyUsages"];
type ExtendedKeyUsagesTransform = TCertificateTemplateV2Policy["extendedKeyUsages"];
type AlgorithmsTransform = TCertificateTemplateV2Policy["algorithms"];
type ValidityTransform = TCertificateTemplateV2Policy["validity"];
interface Props {
isOpen: boolean;
onClose: () => void;
template?: TCertificateTemplateV2WithPolicies;
mode?: "create" | "edit";
}
const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = {
common_name: "Common Name (CN)"
};
const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = {
dns_name: "DNS Name",
ip_address: "IP Address",
email: "Email",
uri: "URI"
};
const SUBJECT_ATTRIBUTE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number], string> =
{
optional: "Allow",
prohibit: "Deny"
};
const SAN_INCLUDE_LABELS: Record<(typeof SAN_INCLUDE_OPTIONS)[number], string> = {
mandatory: "Require",
optional: "Allow",
prohibit: "Deny"
};
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, template, mode = "create" }: Props) => {
const { currentProject } = useProject();
const createTemplate = useCreateCertificateTemplateV2WithPolicies();
const updateTemplate = useUpdateCertificateTemplateV2WithPolicies();
const isEdit = mode === "edit" && template;
const convertApiToUiFormat = (templateData: TCertificateTemplateV2WithPolicies): FormData => {
const attributes: FormData["attributes"] = [];
if (templateData.subject && Array.isArray(templateData.subject)) {
templateData.subject.forEach((subj) => {
if (subj.allowed && Array.isArray(subj.allowed)) {
subj.allowed.forEach((allowedValue) => {
attributes.push({
type: subj.type as CertSubjectAttributeType,
include: "optional",
value: [allowedValue]
});
});
}
if (subj.denied && Array.isArray(subj.denied)) {
subj.denied.forEach((deniedValue) => {
attributes.push({
type: subj.type as CertSubjectAttributeType,
include: "prohibit",
value: [deniedValue]
});
});
}
});
}
const subjectAlternativeNames: FormData["subjectAlternativeNames"] = [];
if (templateData.sans && Array.isArray(templateData.sans)) {
templateData.sans.forEach((san) => {
if (san.required && Array.isArray(san.required)) {
san.required.forEach((requiredValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "mandatory",
value: [requiredValue]
});
});
}
if (san.allowed && Array.isArray(san.allowed)) {
san.allowed.forEach((allowedValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "optional",
value: [allowedValue]
});
});
}
if (san.denied && Array.isArray(san.denied)) {
san.denied.forEach((deniedValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "prohibit",
value: [deniedValue]
});
});
}
});
}
const keyUsages = {
requiredUsages: (templateData.keyUsages?.required || []) as CertKeyUsageType[],
optionalUsages: (templateData.keyUsages?.allowed || []) as CertKeyUsageType[]
};
const extendedKeyUsages = {
requiredUsages: (templateData.extendedKeyUsages?.required ||
[]) as CertExtendedKeyUsageType[],
optionalUsages: (templateData.extendedKeyUsages?.allowed || []) as CertExtendedKeyUsageType[]
};
const validity = templateData.validity?.max
? (() => {
const maxValue = templateData.validity.max;
if (maxValue.length < 2) return undefined;
const lastChar = maxValue.slice(-1);
const numberPart = maxValue.slice(0, -1);
const value = parseInt(numberPart, 10);
if (Number.isNaN(value) || value <= 0 || numberPart !== value.toString()) {
return undefined;
}
let unit: CertDurationUnit = CertDurationUnit.DAYS;
if (lastChar === "d") {
unit = CertDurationUnit.DAYS;
} else if (lastChar === "m") {
unit = CertDurationUnit.MONTHS;
} else if (lastChar === "y") {
unit = CertDurationUnit.YEARS;
} else {
return undefined;
}
return {
maxDuration: { value, unit }
};
})()
: { maxDuration: { value: 365, unit: CertDurationUnit.DAYS } };
const signatureAlgorithm = {
allowedAlgorithms: templateData.algorithms?.signature || [],
defaultAlgorithm: ""
};
const keyAlgorithm = {
allowedKeyTypes: templateData.algorithms?.keyAlgorithm || [],
defaultKeyType: ""
};
return {
name: templateData.name || "",
description: templateData.description || "",
attributes,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
validity,
signatureAlgorithm,
keyAlgorithm
};
};
const getDefaultValues = (): FormData => ({
name: "",
description: "",
attributes: [],
keyUsages: { requiredUsages: [], optionalUsages: [] },
extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
subjectAlternativeNames: [],
validity: {
maxDuration: { value: 365, unit: CertDurationUnit.DAYS }
},
signatureAlgorithm: {
allowedAlgorithms: []
},
keyAlgorithm: {
allowedKeyTypes: []
}
});
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(templateSchema),
defaultValues: getDefaultValues(),
mode: "onChange",
reValidateMode: "onChange",
criteriaMode: "all"
});
useEffect(() => {
if (isEdit && template) {
const convertedData = convertApiToUiFormat(template);
reset(convertedData);
} else if (!isEdit) {
reset(getDefaultValues());
}
}, [isEdit, template, reset]);
const watchedAttributes = watch("attributes") || [];
const watchedSans = watch("subjectAlternativeNames") || [];
const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] };
const watchedExtendedKeyUsages = watch("extendedKeyUsages") || {
requiredUsages: [],
optionalUsages: []
};
const consolidateByType = <
T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] }
>(
items: T[]
): T[] => {
const consolidated = new Map<string, T>();
items.forEach((item) => {
const existing = consolidated.get(item.type);
if (existing) {
const mergedItem = {
...item,
allowed: [...new Set([...(existing.allowed || []), ...(item.allowed || [])])],
required: [...new Set([...(existing.required || []), ...(item.required || [])])],
denied: [...new Set([...(existing.denied || []), ...(item.denied || [])])]
} as T;
if (mergedItem.allowed?.length === 0) delete mergedItem.allowed;
if (mergedItem.required?.length === 0) delete mergedItem.required;
if (mergedItem.denied?.length === 0) delete mergedItem.denied;
consolidated.set(item.type, mergedItem);
} else {
consolidated.set(item.type, item);
}
});
return Array.from(consolidated.values());
};
const transformToApiFormat = (data: FormData) => {
const subjectRaw =
data.attributes?.map((attr) => {
const result: AttributeTransform = { type: attr.type };
if (attr.include === "optional" && attr.value && attr.value.length > 0) {
result.allowed = attr.value;
} else if (attr.include === "prohibit" && attr.value && attr.value.length > 0) {
result.denied = attr.value;
}
return result;
}) || [];
const sansRaw =
data.subjectAlternativeNames?.map((san) => {
const result: SanTransform = { type: san.type };
if (san.include === "mandatory" && san.value && san.value.length > 0) {
result.required = san.value;
} else if (san.include === "optional" && san.value && san.value.length > 0) {
result.allowed = san.value;
} else if (san.include === "prohibit" && san.value && san.value.length > 0) {
result.denied = san.value;
}
return result;
}) || [];
const subject = consolidateByType(subjectRaw);
const sans = consolidateByType(sansRaw);
const keyUsages: KeyUsagesTransform = {
required: [],
allowed: []
};
if (data.keyUsages?.requiredUsages && data.keyUsages.requiredUsages.length > 0) {
keyUsages.required = data.keyUsages.requiredUsages;
}
if (data.keyUsages?.optionalUsages && data.keyUsages.optionalUsages.length > 0) {
keyUsages.allowed = data.keyUsages.optionalUsages;
}
const extendedKeyUsages: ExtendedKeyUsagesTransform = {
required: [],
allowed: []
};
if (
data.extendedKeyUsages?.requiredUsages &&
data.extendedKeyUsages.requiredUsages.length > 0
) {
extendedKeyUsages.required = data.extendedKeyUsages.requiredUsages;
}
if (
data.extendedKeyUsages?.optionalUsages &&
data.extendedKeyUsages.optionalUsages.length > 0
) {
extendedKeyUsages.allowed = data.extendedKeyUsages.optionalUsages;
}
const algorithms: AlgorithmsTransform = {};
if (
data.signatureAlgorithm?.allowedAlgorithms &&
data.signatureAlgorithm.allowedAlgorithms.length > 0
) {
algorithms.signature = data.signatureAlgorithm.allowedAlgorithms as Array<
| "SHA256-RSA"
| "SHA384-RSA"
| "SHA512-RSA"
| "SHA256-ECDSA"
| "SHA384-ECDSA"
| "SHA512-ECDSA"
>;
}
if (data.keyAlgorithm?.allowedKeyTypes && data.keyAlgorithm.allowedKeyTypes.length > 0) {
algorithms.keyAlgorithm = data.keyAlgorithm.allowedKeyTypes as Array<
"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384"
>;
}
const validity: ValidityTransform = {};
if (data.validity?.maxDuration) {
let unit = "d";
if (data.validity.maxDuration.unit === CertDurationUnit.DAYS) {
unit = "d";
} else if (data.validity.maxDuration.unit === CertDurationUnit.MONTHS) {
unit = "m";
} else {
unit = "y";
}
validity.max = `${data.validity.maxDuration.value}${unit}`;
}
return {
name: data.name,
description: data.description,
subject,
sans,
keyUsages,
extendedKeyUsages,
algorithms,
validity
};
};
const onFormSubmit = async (data: FormData) => {
if (!currentProject?.id && !isEdit) return;
const hasEmptyAttributeValues = data.attributes?.some(
(attr) => !attr.value || attr.value.length === 0 || attr.value.some((v) => !v.trim())
);
const hasEmptySanValues = data.subjectAlternativeNames?.some(
(san) => !san.value || san.value.length === 0 || san.value.some((v) => !v.trim())
);
if (hasEmptyAttributeValues || hasEmptySanValues) {
createNotification({
text: "All values must be non-empty. Use wildcards (*) if needed.",
type: "error"
});
return;
}
const transformedData = transformToApiFormat(data);
if (isEdit) {
const updateData = {
templateId: template.id,
...transformedData
};
await updateTemplate.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
throw new Error("Project ID is required for creating a template");
}
const createData = {
projectId: currentProject.id,
...transformedData
};
await createTemplate.mutateAsync(createData);
}
createNotification({
text: `Certificate template ${isEdit ? "updated" : "created"} successfully`,
type: "success"
});
reset();
onClose();
};
const addAttribute = () => {
const newAttribute = {
type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0],
include: SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS[0],
value: ["*"]
};
setValue("attributes", [...watchedAttributes, newAttribute]);
};
const removeAttribute = (index: number) => {
const newAttributes = watchedAttributes.filter((_, i) => i !== index);
setValue("attributes", newAttributes);
};
const addSan = () => {
const newSan = {
type: SAN_TYPE_OPTIONS[0],
include: SAN_INCLUDE_OPTIONS[1],
value: ["*"]
};
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
};
const removeSan = (index: number) => {
const newSans = watchedSans.filter((_, i) => i !== index);
setValue("subjectAlternativeNames", newSans);
};
const handleKeyUsagesChange = (usages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
}) => {
setValue("keyUsages", {
requiredUsages: usages.requiredUsages,
optionalUsages: usages.optionalUsages
});
};
const handleExtendedKeyUsagesChange = (usages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
}) => {
setValue("extendedKeyUsages", {
requiredUsages: usages.requiredUsages,
optionalUsages: usages.optionalUsages
});
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
}
onClose();
}}
>
<ModalContent
className="max-w-4xl"
title={isEdit ? "Edit Certificate Template" : "Create Certificate Template"}
subTitle={
isEdit
? `Update configuration for ${template?.name}`
: "Define comprehensive certificate policies, validation rules, and constraints"
}
>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<Accordion type="multiple" defaultValue={["basic", "algorithms"]} className="w-full">
<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>
<AccordionItem value="attributes">
<AccordionTrigger>Subject Attributes</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addAttribute}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Attribute
</Button>
</div>
<div className="space-y-2">
{watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
started.
</div>
) : (
watchedAttributes.map((attr, index) => {
return (
<div
// eslint-disable-next-line react/no-array-index-key
key={`attr-${attr.type}-${attr.include}-${index}`}
className="flex items-start gap-2"
>
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
type: value as CertSubjectAttributeType
};
setValue("attributes", newAttributes);
}}
className="w-48"
>
{SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
include:
value as (typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number]
};
setValue("attributes", newAttributes);
}}
className="w-32"
>
{SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SUBJECT_ATTRIBUTE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("attributes", newAttributes);
}}
className={`flex-1 ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
{watchedAttributes.length > 0 && (
<IconButton
ariaLabel="Remove Attribute"
variant="plain"
size="sm"
onClick={() => removeAttribute(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
);
})
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="san" className="mt-4">
<AccordionTrigger>Subject Alternative Names</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addSan}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SAN
</Button>
</div>
<div className="space-y-2">
{watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to
get started.
</div>
) : (
watchedSans.map((san, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`san-${san.type}-${san.include}-${index}`}
className="flex items-start gap-2"
>
<Select
value={san.type}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
type: value as CertSubjectAlternativeNameType
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-36"
>
{SAN_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={san.include}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
include: value as (typeof SAN_INCLUDE_OPTIONS)[number]
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-32"
>
{SAN_INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_INCLUDE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={san.value?.[0] || ""}
onChange={(e) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("subjectAlternativeNames", newSans);
}}
className={`flex-1 ${
san.value && san.value.length > 0 && san.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
{watchedSans.length > 0 && (
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => removeSan(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
))
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="algorithms" className="mt-4">
<AccordionTrigger>Algorithms</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Signature Algorithms
<span className="ml-1 text-red-500">*</span>
</h4>
<Controller
control={control}
name="signatureAlgorithm.allowedAlgorithms"
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="grid grid-cols-2 gap-2">
{SIGNATURE_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<div key={alg} className="flex items-center space-x-3">
<Checkbox
id={`sig-alg-${alg}`}
isChecked={isSelected}
onCheckedChange={(checked) => {
const current = field.value || [];
let newValue;
if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
field.onChange(newValue);
}}
/>
<label
htmlFor={`sig-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
>
{alg}
</label>
</div>
);
})}
</div>
</FormControl>
)}
/>
</div>
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Key Algorithms
<span className="ml-1 text-red-500">*</span>
</h4>
<Controller
control={control}
name="keyAlgorithm.allowedKeyTypes"
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="grid grid-cols-2 gap-2">
{KEY_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<div key={alg} className="flex items-center space-x-3">
<Checkbox
id={`key-alg-${alg}`}
isChecked={isSelected}
onCheckedChange={(checked) => {
const current = field.value || [];
let newValue;
if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
field.onChange(newValue);
}}
/>
<label
htmlFor={`key-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
>
{alg}
</label>
</div>
);
})}
</div>
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="usages" className="mt-4">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<KeyUsagesSection
watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
onKeyUsagesChange={handleKeyUsagesChange}
onExtendedKeyUsagesChange={handleExtendedKeyUsagesChange}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="validity" className="mt-4">
<AccordionTrigger>Certificate Validity</AccordionTrigger>
<AccordionContent>
<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>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex gap-3">
<Button
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateTemplate.isPending : createTemplate.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
<Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateTemplate.isPending : createTemplate.isPending}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};