Merge pull request #4728 from Infisical/feat/ENG-3942

PKI: Add support for certificate auto-renewal and manual renewal workflows
This commit is contained in:
carlosmonastyrski
2025-10-27 11:42:08 -03:00
committed by GitHub
41 changed files with 3352 additions and 251 deletions
@@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
apiConfig?: {
id: string;
autoRenew: boolean;
autoRenewDays?: number;
renewBeforeDays?: number;
};
};
@@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = {
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
renewBeforeDays?: number;
};
};
@@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = {
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
renewBeforeDays?: number;
};
};
@@ -1,2 +1,8 @@
export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations";
export {
useDeleteCert,
useImportCertificate,
useRenewCertificate,
useRevokeCert,
useUpdateRenewalConfig
} from "./mutations";
export { useGetCert, useGetCertBody } from "./queries";
@@ -9,7 +9,10 @@ import {
TDeleteCertDTO,
TImportCertificateDTO,
TImportCertificateResponse,
TRevokeCertDTO
TRenewCertificateDTO,
TRenewCertificateResponse,
TRevokeCertDTO,
TUpdateRenewalConfigDTO
} from "./types";
export const useDeleteCert = () => {
@@ -77,3 +80,57 @@ export const useImportCertificate = () => {
}
});
};
export const useRenewCertificate = () => {
const queryClient = useQueryClient();
return useMutation<TRenewCertificateResponse, object, TRenewCertificateDTO>({
mutationFn: async ({ certificateId }) => {
const { data } = await apiRequest.post<TRenewCertificateResponse>(
`/api/v3/certificates/${certificateId}/renew`,
{}
);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.allProjectCertificates()
});
if (data.projectId) {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(data.projectId)
});
}
}
});
};
export const useUpdateRenewalConfig = () => {
const queryClient = useQueryClient();
return useMutation<
{ message: string; renewBeforeDays?: number },
object,
TUpdateRenewalConfigDTO
>({
mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => {
const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>(
`/api/v3/certificates/${certificateId}/config`,
{ renewBeforeDays, enableAutoRenewal }
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
queryClient.invalidateQueries({
queryKey: projectKeys.allProjectCertificates()
});
}
});
};
@@ -4,6 +4,7 @@ export type TCertificate = {
id: string;
caId: string;
certificateTemplateId?: string;
profileId?: string;
status: CertStatus;
friendlyName: string;
commonName: string;
@@ -13,6 +14,12 @@ export type TCertificate = {
notAfter: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
renewBeforeDays?: number;
renewedBy?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
renewalError?: string;
hasPrivateKey?: boolean;
};
export type TDeleteCertDTO = {
@@ -43,3 +50,24 @@ export type TImportCertificateResponse = {
privateKey: string;
serialNumber: string;
};
export type TRenewCertificateDTO = {
certificateId: string;
};
export type TRenewCertificateResponse = {
certificate: string;
issuingCaCertificate: string;
certificateChain: string;
privateKey?: string;
serialNumber: string;
certificateId: string;
projectId: string;
};
export type TUpdateRenewalConfigDTO = {
certificateId: string;
renewBeforeDays?: number;
enableAutoRenewal?: boolean;
projectSlug: string;
};
@@ -0,0 +1,265 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useProject } from "@app/context";
import { useUpdateRenewalConfig } from "@app/hooks/api";
import { useGetCertificateProfileById } from "@app/hooks/api/certificateProfiles";
import { UsePopUpState } from "@app/hooks/usePopUp";
const DEFAULT_RENEWAL_BEFORE_DAYS = 20;
const MIN_RENEWAL_BEFORE_DAYS = 1;
const MAX_RENEWAL_BEFORE_DAYS = 30;
const createFormSchema = (ttlDays: number, notAfter: string) =>
z.object({
renewBeforeDays: z
.number()
.min(MIN_RENEWAL_BEFORE_DAYS, `Renewal days must be at least ${MIN_RENEWAL_BEFORE_DAYS}`)
.max(MAX_RENEWAL_BEFORE_DAYS, `Renewal days cannot exceed ${MAX_RENEWAL_BEFORE_DAYS}`)
.refine(
(value) => value < ttlDays,
(value) => ({
message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)`
})
)
.refine(
(value) => {
const expiryDate = new Date(notAfter);
const renewalDate = new Date(expiryDate.getTime() - value * 24 * 60 * 60 * 1000);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return renewalDate >= tomorrow;
},
() => ({
message: "Renewals can only be scheduled from tomorrow onwards."
})
)
});
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
type Props = {
popUp: UsePopUpState<["manageRenewal"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void;
};
const RenewalConfigForm = ({
control,
errors,
onSubmit,
isLoading,
buttonText,
onCancel
}: {
control: any;
errors: { renewBeforeDays?: { message?: string } };
onSubmit: (e?: React.BaseSyntheticEvent) => Promise<void>;
isLoading: boolean;
buttonText: string;
onCancel: () => void;
}) => (
<form onSubmit={onSubmit}>
<FormControl
label="Auto-renew days before expiry"
isError={Boolean(errors.renewBeforeDays)}
errorText={errors.renewBeforeDays?.message}
className="mb-6"
>
<Controller
control={control}
name="renewBeforeDays"
render={({ field }) => (
<Input
{...field}
type="number"
min={MIN_RENEWAL_BEFORE_DAYS}
max={MAX_RENEWAL_BEFORE_DAYS}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
field.onChange(value);
}}
placeholder="Enter days before expiration"
/>
)}
/>
</FormControl>
<div className="flex justify-end gap-3">
<Button type="button" colorSchema="secondary" variant="plain" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" colorSchema="primary" isLoading={isLoading} isDisabled={isLoading}>
{buttonText}
</Button>
</div>
</form>
);
export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentProject } = useProject();
const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } =
useUpdateRenewalConfig();
const certificateData = popUp.manageRenewal.data as {
certificateId: string;
commonName: string;
profileId: string;
renewBeforeDays?: number;
ttlDays?: number;
notAfter: string;
renewalError?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
};
const { data: profileData } = useGetCertificateProfileById({
profileId: certificateData?.profileId || ""
});
const defaultRenewalDays = useMemo(() => {
if (certificateData?.renewBeforeDays) {
return certificateData.renewBeforeDays;
}
if (profileData?.apiConfig?.renewBeforeDays) {
return profileData.apiConfig.renewBeforeDays;
}
return DEFAULT_RENEWAL_BEFORE_DAYS;
}, [certificateData?.renewBeforeDays, profileData?.apiConfig?.renewBeforeDays]);
const isAutoRenewalEnabled = Boolean(
certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0
);
const hasRenewalError = Boolean(certificateData?.renewalError);
const formSchema = createFormSchema(
certificateData?.ttlDays || 365,
certificateData?.notAfter || ""
);
const {
control,
handleSubmit,
formState: { errors },
reset
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
renewBeforeDays: defaultRenewalDays
}
});
useEffect(() => {
if (popUp.manageRenewal.isOpen) {
reset({
renewBeforeDays: defaultRenewalDays
});
}
}, [popUp.manageRenewal.isOpen, defaultRenewalDays, reset]);
const onUpdateRenewal = async (data: FormData) => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Unable to update auto-renewal: Project not found. Please refresh the page and try again.",
type: "error"
});
return;
}
await updateRenewalConfig({
certificateId: certificateData.certificateId,
renewBeforeDays: data.renewBeforeDays,
projectSlug: currentProject.slug
});
createNotification({
text: isAutoRenewalEnabled
? "Auto-renewal configuration updated successfully"
: "Auto-renewal enabled successfully",
type: "success"
});
handlePopUpToggle("manageRenewal", false);
} catch (err) {
console.error(err);
createNotification({
text: isAutoRenewalEnabled
? "Failed to update auto-renewal configuration. Please check your inputs and try again."
: "Failed to enable auto-renewal. Please check your inputs and try again.",
type: "error"
});
}
};
const getModalTitle = () => {
if (hasRenewalError) {
return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`;
}
if (isAutoRenewalEnabled) {
return `Manage Auto-Renewal for ${certificateData?.commonName || ""}`;
}
return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`;
};
if (!certificateData) {
return null;
}
return (
<Modal
isOpen={popUp?.manageRenewal?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("manageRenewal", isOpen);
}}
>
<ModalContent title={getModalTitle()}>
{hasRenewalError && (
<div className="mb-6 rounded-md border border-red-600 bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<div className="mt-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-600">
<span className="text-xs font-bold text-white">!</span>
</div>
<div className="flex-1">
<h3 className="font-medium text-red-400">Automatic Renewal Failed</h3>
<p className="mt-1 text-sm text-red-300">
The last automatic renewal attempt failed: {certificateData.renewalError}
</p>
<p className="mt-2 text-sm text-red-300">
You can reconfigure auto-renewal below or disable it completely.
</p>
</div>
</div>
</div>
)}
{(!isAutoRenewalEnabled || hasRenewalError) && (
<RenewalConfigForm
control={control}
errors={errors}
onSubmit={handleSubmit(onUpdateRenewal)}
isLoading={isUpdatingConfig}
buttonText={isAutoRenewalEnabled ? "Update Configuration" : "Enable Auto-Renewal"}
onCancel={() => handlePopUpToggle("manageRenewal", false)}
/>
)}
{isAutoRenewalEnabled && !hasRenewalError && (
<RenewalConfigForm
control={control}
errors={errors}
onSubmit={handleSubmit(onUpdateRenewal)}
isLoading={isUpdatingConfig}
buttonText="Update Configuration"
onCancel={() => handlePopUpToggle("manageRenewal", false)}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,178 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useProject } from "@app/context";
import { useUpdateRenewalConfig } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const createFormSchema = (ttlDays: number) =>
z.object({
renewBeforeDays: z
.number()
.min(1, "Renewal days must be at least 1")
.max(365, "Renewal days cannot exceed 365")
.refine(
(value) => value < ttlDays,
(value) => ({
message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)`
})
)
});
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
type Props = {
popUp: UsePopUpState<["configureRenewal"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["configureRenewal"]>,
state?: boolean
) => void;
};
export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentProject } = useProject();
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
const certificateData = popUp.configureRenewal.data as {
certificateId: string;
commonName: string;
profileId: string;
renewBeforeDays?: number;
ttlDays: number;
};
const formSchema = createFormSchema(certificateData.ttlDays);
const {
control,
handleSubmit,
formState: { errors },
watch
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
renewBeforeDays: certificateData?.renewBeforeDays || 1
}
});
const renewBeforeDays = watch("renewBeforeDays");
const onSubmit = async (data: FormData) => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Project not found",
type: "error"
});
return;
}
await updateRenewalConfig({
certificateId: certificateData.certificateId,
renewBeforeDays: data.renewBeforeDays,
projectSlug: currentProject.slug
});
createNotification({
text: "Successfully updated auto-renewal configuration",
type: "success"
});
handlePopUpToggle("configureRenewal", false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update auto-renewal configuration",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.configureRenewal?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("configureRenewal", isOpen);
}}
>
<ModalContent title={`Configure Auto-Renewal: ${certificateData?.commonName || ""}`}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4">
<p className="mb-4 text-sm text-mineshaft-300">
Configure when this certificate should be automatically renewed. The certificate will
be renewed when it has the specified number of days remaining before expiration.
</p>
<div className="mb-4 rounded border bg-mineshaft-800 p-3">
<p className="text-sm text-mineshaft-300">
<strong>Certificate TTL:</strong> {certificateData?.ttlDays} days
</p>
<p className="text-sm text-mineshaft-300">
<strong>Current Setting:</strong>{" "}
{certificateData?.renewBeforeDays
? `${certificateData.renewBeforeDays} days before expiration`
: "Disabled"}
</p>
</div>
<Controller
control={control}
name="renewBeforeDays"
render={({ field }) => (
<FormControl
label="Renew Before Days"
isError={Boolean(errors.renewBeforeDays)}
errorText={errors.renewBeforeDays?.message}
>
<Input
{...field}
type="number"
min={1}
max={certificateData?.ttlDays ? certificateData.ttlDays - 1 : undefined}
placeholder="Enter days before expiration"
onChange={(e) => {
const value = parseInt(e.target.value, 10);
field.onChange(Number.isNaN(value) ? 0 : value);
}}
/>
</FormControl>
)}
/>
{renewBeforeDays && certificateData?.ttlDays && (
<div className="mt-2 rounded bg-primary-900/20 p-2">
<p className="text-sm text-primary-300">
{renewBeforeDays >= certificateData.ttlDays
? "⚠️ Renewal days must be less than certificate TTL"
: `✓ Certificate will be renewed ${renewBeforeDays} days before expiration`}
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || renewBeforeDays >= (certificateData?.ttlDays || 0)}
>
Update Configuration
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("configureRenewal", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,94 @@
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalContent } from "@app/components/v2";
import { useProject } from "@app/context";
import { useUpdateRenewalConfig } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["disableRenewal"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["disableRenewal"]>, state?: boolean) => void;
};
export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentProject } = useProject();
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
const certificateData = popUp.disableRenewal.data as {
certificateId: string;
commonName: string;
};
const onDisableConfirm = async () => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Project not found",
type: "error"
});
return;
}
await updateRenewalConfig({
certificateId: certificateData.certificateId,
projectSlug: currentProject.slug,
enableAutoRenewal: false
});
createNotification({
text: "Successfully disabled auto-renewal",
type: "success"
});
handlePopUpToggle("disableRenewal", false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to disable auto-renewal",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.disableRenewal?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("disableRenewal", isOpen);
}}
>
<ModalContent title={`Disable Auto-Renewal: ${certificateData?.commonName || ""}`}>
<div className="mb-4">
<p className="mb-3 text-sm text-mineshaft-300">
Are you sure you want to disable auto-renewal for this certificate?
</p>
<div className="rounded border border-yellow-700/50 bg-yellow-900/20 p-3">
<p className="text-sm text-yellow-300">
<strong>Warning:</strong> Once disabled, this certificate will not be automatically
renewed and may expire without notice. You can re-enable auto-renewal at any time.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
className="mr-4"
size="sm"
colorSchema="danger"
onClick={onDisableConfirm}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Disable Auto-Renewal
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("disableRenewal", false)}
>
Cancel
</Button>
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,80 @@
import { faRedo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalContent } from "@app/components/v2";
import { useRenewCertificate } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["renewCertificate"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["renewCertificate"]>,
state?: boolean
) => void;
};
export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
const { mutateAsync: renewCertificate, isPending: isRenewing } = useRenewCertificate();
const onRenewConfirm = async () => {
try {
const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
await renewCertificate({
certificateId
});
createNotification({
text: "Certificate renewed successfully",
type: "success"
});
handlePopUpToggle("renewCertificate", false);
} catch (err) {
console.error(err);
}
};
const certificateData = popUp.renewCertificate.data as {
certificateId: string;
commonName: string;
profileId: string;
};
return (
<Modal
isOpen={popUp?.renewCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("renewCertificate", isOpen);
}}
>
<ModalContent title={`Renew Certificate: ${certificateData?.commonName || ""}`}>
<div className="mb-6">
<p className="mb-4 text-sm text-mineshaft-300">
Are you sure you want to renew this certificate now?
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={onRenewConfirm}
colorSchema="primary"
isLoading={isRenewing}
isDisabled={isRenewing}
>
<FontAwesomeIcon icon={faRedo} className="mr-2" />
Renew Now
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("renewCertificate", false)}
>
Cancel
</Button>
</div>
</ModalContent>
</Modal>
);
};
@@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRenewalModal } from "./CertificateRenewalModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
@@ -33,7 +35,9 @@ export const CertificatesSection = () => {
"certificateImport",
"certificateCert",
"deleteCertificate",
"revokeCertificate"
"revokeCertificate",
"manageRenewal",
"renewCertificate"
] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => {
@@ -98,6 +102,8 @@ export const CertificatesSection = () => {
)}
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
@@ -5,12 +5,15 @@ import {
faEllipsis,
faEye,
faFileExport,
faQuestionCircle,
faRedo,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
@@ -33,25 +36,136 @@ import {
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProject
useProject,
useSubscription
} from "@app/context";
import { useListWorkspaceCertificates } from "@app/hooks/api";
import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api";
import { caSupportsCapability } from "@app/hooks/api/ca/constants";
import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CertStatus } from "@app/hooks/api/certificates/enums";
import { TCertificate } from "@app/hooks/api/certificates/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
const isExpiringWithinOneDay = (notAfter: string): boolean => {
const expiryDate = new Date(notAfter);
const now = new Date();
const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
return expiryDate <= oneDayFromNow;
};
const getAutoRenewalInfo = (certificate: TCertificate) => {
if (certificate.renewedByCertificateId) {
return { text: "Renewed", variant: "instance" as const };
}
const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date();
const hasNoProfile = !certificate.profileId;
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
if (isRevoked) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Renewal is not available for revoked certificates"
};
}
if (isExpired) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Renewal is not available for expired certificates"
};
}
if (hasNoProfile) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Renewal requires a certificate profile"
};
}
if (certificate.hasPrivateKey === false) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Renewal is not available for certificates with externally generated private keys"
};
}
if (isExpiringWithinDay) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Auto-renewal is not available for certificates expiring within 24 hours"
};
}
if (certificate.renewalError) {
return {
text: "Failed",
variant: "danger" as const,
tooltip: certificate.renewalError
};
}
if (!certificate.renewBeforeDays) {
return { text: "Auto-Renewal Disabled", variant: "primary" as const };
}
const notAfterDate = new Date(certificate.notAfter);
const renewalDate = new Date(
notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000
);
const now = new Date();
if (renewalDate <= now) {
return { text: "Due Now", variant: "danger" as const };
}
const daysUntilRenewal = Math.floor(
(renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
if (daysUntilRenewal === 0) {
return { text: "Renews today", variant: "primary" as const };
}
if (daysUntilRenewal <= 7) {
return { text: `Renews in ${daysUntilRenewal}d`, variant: "primary" as const };
}
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
};
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"]
[
"certificate",
"deleteCertificate",
"revokeCertificate",
"certificateCert",
"manageRenewal",
"renewCertificate"
]
>,
data?: {
serialNumber?: string;
commonName?: string;
certificateId?: string;
profileId?: string;
renewBeforeDays?: number;
ttlDays?: number;
notAfter?: string;
renewalError?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
}
) => void;
};
@@ -61,6 +175,7 @@ const PER_PAGE_INIT = 25;
export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { subscription } = useSubscription();
const { currentProject } = useProject();
const { data, isPending } = useListWorkspaceCertificates({
@@ -69,10 +184,11 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
limit: perPage
});
// Fetch CA data to determine capabilities
const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
const { data: caData } = useListCasByProjectId(currentProject?.id ?? "");
// Create mapping from caId to CA type for capability checking
const caCapabilityMap = useMemo(() => {
if (!caData) return {};
@@ -83,6 +199,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
return map;
}, [caData]);
const handleDisableAutoRenewal = async (certificateId: string, commonName: string) => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Unable to disable auto-renewal: Project not found. Please refresh the page and try again.",
type: "error"
});
return;
}
await updateRenewalConfig({
certificateId,
projectSlug: currentProject.slug,
enableAutoRenewal: false
});
createNotification({
text: `Auto-renewal disabled for ${commonName}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to disable auto-renewal. Please try again or contact support if the issue persists.",
type: "error"
});
}
};
return (
<TableContainer>
<Table>
@@ -92,14 +237,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th>Renewal Status</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
{!isPending &&
data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
const autoRenewalInfo = getAutoRenewalInfo(certificate);
const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date();
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
const hasFailed = Boolean(certificate.renewalError);
const isAutoRenewalEnabled = Boolean(
certificate.renewBeforeDays && certificate.renewBeforeDays > 0
);
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td>
@@ -120,6 +275,25 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
<Td>
{autoRenewalInfo &&
(autoRenewalInfo.tooltip ? (
<div className="flex items-center gap-2">
<Badge variant={autoRenewalInfo.variant}>
{autoRenewalInfo.text}
<Tooltip content={autoRenewalInfo.tooltip}>
<FontAwesomeIcon
icon={faQuestionCircle}
className="ml-1 cursor-help text-red-400 hover:text-red-300"
size="sm"
/>
</Tooltip>
</Badge>
</div>
) : (
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge>
))}
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
@@ -151,31 +325,172 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificate", {
serialNumber: certificate.serialNumber
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
{isLegacyTemplatesEnabled && (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificate", {
serialNumber: certificate.serialNumber
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
View Details
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{/* Manage auto renewal option - not shown for failed renewals */}
{(() => {
const canManageRenewal =
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!hasFailed &&
!isExpiringWithinDay;
if (!canManageRenewal) return null;
return (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
View Details
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{(isAllowed) => {
return (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
const notAfterDate = new Date(certificate.notAfter);
const notBeforeDate = certificate.notBefore
? new Date(certificate.notBefore)
: new Date(
notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000
);
const ttlDays = Math.max(
1,
Math.ceil(
(notAfterDate.getTime() - notBeforeDate.getTime()) /
(24 * 60 * 60 * 1000)
)
);
handlePopUpOpen("manageRenewal", {
certificateId: certificate.id,
commonName: certificate.commonName,
profileId: certificate.profileId,
renewBeforeDays: certificate.renewBeforeDays,
ttlDays,
notAfter: certificate.notAfter,
renewalError: certificate.renewalError,
renewedFromCertificateId:
certificate.renewedFromCertificateId,
renewedByCertificateId: certificate.renewedByCertificateId
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faRedo} />}
>
{isAutoRenewalEnabled
? "Manage auto renewal"
: "Enable auto renewal"}
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
);
})()}
{/* Disable auto renewal option - only shown when auto renewal is active */}
{(() => {
const canDisableRenewal =
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!isExpiringWithinDay &&
isAutoRenewalEnabled;
if (!canDisableRenewal) return null;
return (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
await handleDisableAutoRenewal(
certificate.id,
certificate.commonName
);
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
Disable auto renewal
</DropdownMenuItem>
)}
</ProjectPermissionCan>
);
})()}
{/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */}
{(() => {
const canRenew =
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired;
if (!canRenew) return null;
return (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("renewCertificate", {
certificateId: certificate.id,
commonName: certificate.commonName
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faRedo} />}
>
Renew Now
</DropdownMenuItem>
)}
</ProjectPermissionCan>
);
})()}
{/* Only show revoke button if CA supports revocation */}
{(() => {
const caType = caCapabilityMap[certificate.caId];
// If caId not found in map, assume CA supports revocation to avoid hiding revoke option
const supportsRevocation =
!caType ||
caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES);
@@ -11,6 +11,26 @@ import {
mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
const convertTemplateTtlToCertificateTtl = (templateTtl: string): string => {
const match = templateTtl.match(/^(\d+)([dmyh])$/);
if (!match) return templateTtl;
const [, value, unit] = match;
const numValue = parseInt(value, 10);
switch (unit) {
case "m":
return `${numValue * 30}d`;
case "y":
return `${numValue * 365}d`;
case "d":
case "h":
return templateTtl;
default:
return templateTtl;
}
};
export type TemplateConstraints = {
allowedKeyUsages: string[];
allowedExtendedKeyUsages: string[];
@@ -118,7 +138,7 @@ export const useCertificateTemplate = (
// Set TTL if available
if (templateData.validity?.max) {
setValue("ttl", templateData.validity.max);
setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max));
}
// Handle SAN types
@@ -1,5 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -13,7 +15,8 @@ import {
ModalContent,
Select,
SelectItem,
TextArea
TextArea,
Tooltip
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
@@ -67,7 +70,7 @@ const createSchema = z
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional()
})
@@ -115,7 +118,7 @@ const editSchema = z
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional()
})
@@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined
}
@@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
certificateTemplateId: "",
apiConfig: {
autoRenew: false,
autoRenewDays: 30
renewBeforeDays: 30
}
}
});
@@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined
});
@@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
} else {
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
renewBeforeDays: 30
});
setValue("estConfig", undefined);
}
@@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
setValue("estConfig", undefined);
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
renewBeforeDays: 30
});
}
onChange(value);
@@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
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>
<div className="flex items-center gap-2">
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
Enable Auto-Renewal By Default
</Checkbox>
<Tooltip content="If enabled, certificates issued against this profile will auto-renew at specified days before expiration.">
<FontAwesomeIcon
icon={faQuestionCircle}
className="cursor-help text-mineshaft-400 hover:text-mineshaft-300"
size="sm"
/>
</Tooltip>
</div>
</FormControl>
)}
/>
@@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
<div className="mb-4 space-y-4">
<Controller
control={control}
name="apiConfig.autoRenewDays"
name="apiConfig.renewBeforeDays"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Auto-Renewal Days"
label="Auto-Renewal Days Before Expiration"
isError={Boolean(error)}
errorText={error?.message}
>