PKI revamp: frontend implementation

This commit is contained in:
Carlos Monastyrski
2025-10-10 01:50:10 -03:00
parent 4ddce06887
commit 15a6b8da04
57 changed files with 5271 additions and 298 deletions
@@ -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>
);
};
@@ -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-----&#10;MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV&#10;BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX&#10;...&#10;-----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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,4 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CreateProfileModal } from "./CreateProfileModal";
export { EditProfileModal } from "./EditProfileModal";
export { ProfileList } from "./ProfileList";
@@ -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>
);
};
@@ -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 &quot;Add Attribute&quot; 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 &quot;Add SAN&quot; 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>
);
};
@@ -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 &quot;Add Attribute&quot; 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 &quot;Add SAN&quot; 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>
);
};
@@ -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>
);
};
@@ -0,0 +1,4 @@
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
export { CreateTemplateModal } from "./CreateTemplateModal";
export { EditTemplateModal } from "./EditTemplateModal";
export { TemplateList } from "./TemplateList";
@@ -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>
);
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./schemas";
export * from "./utils";
@@ -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>;
@@ -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";