Merge branch 'main' into fix/remove-duplicate-error-notifications

This commit is contained in:
Victor Santos
2025-11-03 18:41:54 -03:00
283 changed files with 6603 additions and 1582 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,444 @@
import React, { useEffect, useState } from "react";
import { faSearch, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
CertStatus,
useAddCertificatesToPkiSync,
useListPkiSyncCertificates,
useRemoveCertificatesFromPkiSync
} from "@app/hooks/api";
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
type Props = {
isOpen: boolean;
onClose: () => void;
pkiSync?: TPkiSync;
onCertificatesUpdated?: () => void;
selectedCertificateIds?: string[];
onCertificateSelectionChange?: (certificateIds: string[]) => void;
title?: string;
subtitle?: string;
saveButtonText?: string;
};
export const CertificateManagementModal = ({
isOpen,
onClose,
pkiSync,
onCertificatesUpdated,
selectedCertificateIds,
onCertificateSelectionChange,
title = "Manage Certificate Sync",
subtitle = "Select which certificates should be synced.",
saveButtonText = "Save Changes"
}: Props) => {
const { currentProject } = useProject();
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const pageSize = 10;
const isCreateMode = !pkiSync;
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
setCurrentPage(1);
}, 300);
return () => {
clearTimeout(handler);
};
}, [searchTerm]);
const { data } = useListWorkspaceCertificates({
projectId: currentProject?.id || "",
offset: (currentPage - 1) * pageSize,
limit: pageSize,
commonName: debouncedSearchTerm || undefined,
friendlyName: debouncedSearchTerm || undefined,
forPkiSync: true
});
const allCertificates = data?.certificates || [];
const totalCount = data?.totalCount || 0;
const { data: syncData } = useListPkiSyncCertificates(pkiSync?.id || "");
const syncCertificates = syncData?.certificates || [];
const addCertificatesToSync = useAddCertificatesToPkiSync();
const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync();
const syncedCertificateIds = isCreateMode
? selectedCertificateIds || []
: syncCertificates.map((sc) => sc.certificateId);
const totalPages = Math.ceil(totalCount / pageSize);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
React.useEffect(() => {
setSelectedIds(syncedCertificateIds);
}, [JSON.stringify(syncedCertificateIds)]);
const handleToggleSelection = (certId: string) => {
setSelectedIds((prev) =>
prev.includes(certId) ? prev.filter((id) => id !== certId) : [...prev, certId]
);
};
const handleSelectAll = () => {
const currentPageIds = allCertificates.map((cert) => cert.id);
const allCurrentPageSelected = currentPageIds.every((id) => selectedIds.includes(id));
if (allCurrentPageSelected) {
setSelectedIds((prev) => prev.filter((id) => !currentPageIds.includes(id)));
} else {
setSelectedIds((prev) => [...new Set([...prev, ...currentPageIds])]);
}
};
const clearSearch = () => {
setSearchTerm("");
setCurrentPage(1);
};
React.useEffect(() => {
if (isOpen) {
setCurrentPage(1);
setSearchTerm("");
}
}, [isOpen]);
const handleSaveCertificates = async () => {
try {
if (isCreateMode) {
if (onCertificateSelectionChange) {
onCertificateSelectionChange(selectedIds);
onClose();
}
return;
}
if (!pkiSync) return;
const certificatesToAdd = selectedIds.filter((id) => !syncedCertificateIds.includes(id));
const certificatesToRemove = syncedCertificateIds.filter((id) => !selectedIds.includes(id));
const invalidCertificates = certificatesToAdd
.map((id) => allCertificates.find((cert) => cert.id === id))
.filter((cert) => {
if (!cert) return false;
const isExpired = new Date(cert.notAfter) < new Date();
const isRevoked = cert.status === CertStatus.REVOKED;
return isExpired || isRevoked;
});
if (invalidCertificates.length > 0) {
const invalidNames = invalidCertificates.map((cert) => cert?.commonName).join(", ");
createNotification({
text: `Cannot add expired or revoked certificates: ${invalidNames}`,
type: "error"
});
return;
}
const operations = [];
if (certificatesToAdd.length > 0) {
operations.push(
addCertificatesToSync
.mutateAsync({
pkiSyncId: pkiSync.id,
certificateIds: certificatesToAdd
})
.then(() => ({
type: "add",
count: certificatesToAdd.length,
success: true
}))
.catch((error) => ({
type: "add",
count: certificatesToAdd.length,
success: false,
error
}))
);
}
if (certificatesToRemove.length > 0) {
operations.push(
removeCertificatesFromSync
.mutateAsync({
pkiSyncId: pkiSync.id,
certificateIds: certificatesToRemove
})
.then(() => ({
type: "remove",
count: certificatesToRemove.length,
success: true
}))
.catch((error) => ({
type: "remove",
count: certificatesToRemove.length,
success: false,
error
}))
);
}
if (operations.length === 0) {
createNotification({
text: "No changes to save",
type: "info"
});
onClose();
return;
}
const results = await Promise.all(operations);
const failures = results.filter((r) => !r.success);
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
const addCount = successes.find((r) => r.type === "add")?.count || 0;
const removeCount = successes.find((r) => r.type === "remove")?.count || 0;
let message = "Certificate selection updated successfully";
if (addCount > 0 && removeCount > 0) {
message = `Added ${addCount} and removed ${removeCount} certificate(s)`;
} else if (addCount > 0) {
message = `Added ${addCount} certificate(s)`;
} else if (removeCount > 0) {
message = `Removed ${removeCount} certificate(s)`;
}
createNotification({
text: message,
type: "success"
});
if (onCertificatesUpdated) {
onCertificatesUpdated();
}
onClose();
} else {
const partialSuccess = successes.length > 0;
console.error("Certificate sync operation failures:", failures);
createNotification({
text: partialSuccess
? "Some certificate changes failed. Check console for details."
: "Failed to update certificate selection",
type: partialSuccess ? "warning" : "error"
});
if (partialSuccess && onCertificatesUpdated) {
onCertificatesUpdated();
}
}
} catch (error) {
console.error("Unexpected error during certificate sync operation:", error);
createNotification({
text: "An unexpected error occurred while updating certificates",
type: "error"
});
}
};
const isLoading = addCertificatesToSync.isPending || removeCertificatesFromSync.isPending;
return (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()}>
<ModalContent title={title} subTitle={subtitle} className="max-w-4xl">
<div className="space-y-4">
<div className="space-y-3">
<div className="relative">
<Input
placeholder="Search by common name, serial number, or SAN..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-9"
/>
<FontAwesomeIcon
icon={faSearch}
className="absolute top-1/2 left-3 h-3 w-3 -translate-y-1/2 transform text-bunker-300"
/>
{searchTerm && (
<button
type="button"
onClick={clearSearch}
className="absolute top-1/2 right-3 -translate-y-1/2 transform text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={faX} className="h-3 w-3" />
</button>
)}
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-12">
<Checkbox
id="select-all-certificates"
isChecked={
allCertificates.length > 0 &&
allCertificates.every((cert) => selectedIds.includes(cert.id))
}
onCheckedChange={handleSelectAll}
/>
</Th>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/6">Issued At</Th>
<Th className="w-1/6">Expires At</Th>
</Tr>
</THead>
<TBody>
{allCertificates.map((cert) => {
const isExpired = new Date(cert.notAfter) < new Date();
const isRevoked = cert.status === CertStatus.REVOKED;
const cannotBeAdded = isExpired || isRevoked;
const isAlreadySynced = syncedCertificateIds.includes(cert.id);
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
return (
<Tr
key={cert.id}
className={`cursor-pointer hover:bg-mineshaft-700 ${
cannotBeAdded && !isAlreadySynced ? "opacity-50" : ""
}`}
onClick={() => {
if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id);
}
}}
>
<Td className="max-w-0" onClick={(e) => e.stopPropagation()}>
<Checkbox
id={cert.id}
isChecked={selectedIds.includes(cert.id)}
onCheckedChange={() => {
if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id);
}
}}
isDisabled={cannotBeAdded && !isAlreadySynced}
/>
</Td>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notBefore).toLocaleDateString()}
</span>
</Td>
<Td className="max-w-0">
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{allCertificates.length === 0 && (
<EmptyState title="No certificates found">
{searchTerm
? "No certificates match your search criteria."
: "No certificates available for sync."}
</EmptyState>
)}
</TableContainer>
{totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
<Button
variant="solid"
colorSchema="primary"
onClick={handleSaveCertificates}
isLoading={isLoading}
>
{saveButtonText}
</Button>
</div>
</ModalContent>
</Modal>
);
};

View File

@@ -11,21 +11,24 @@ type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
selectSync?: PkiSync | null;
initialData?: any;
};
type ContentProps = {
onComplete: (pkiSync: TPkiSync) => void;
selectedSync: PkiSync | null;
setSelectedSync: (selectedSync: PkiSync | null) => void;
initialData?: any;
};
const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => {
const Content = ({ onComplete, setSelectedSync, selectedSync, initialData }: ContentProps) => {
if (selectedSync) {
return (
<CreatePkiSyncForm
onComplete={onComplete}
onCancel={() => setSelectedSync(null)}
destination={selectedSync}
initialData={initialData}
/>
);
}
@@ -33,7 +36,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) =>
return <PkiSyncSelect onSelect={setSelectedSync} />;
};
export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => {
export const CreatePkiSyncModal = ({
onOpenChange,
selectSync = null,
initialData,
...props
}: Props) => {
const [selectedSync, setSelectedSync] = useState<PkiSync | null>(selectSync);
useEffect(() => {
@@ -69,6 +77,7 @@ export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }
}}
selectedSync={selectedSync}
setSelectedSync={setSelectedSync}
initialData={initialData}
/>
</ModalContent>
</Modal>

View File

@@ -71,7 +71,7 @@ export const PkiSyncSelect = ({ onSelect }: Props) => {
enterprise && !subscription.enterpriseCertificateSyncs
? handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true,
text: "You can use every Certificate Sync if you switch to Infisical's Enterprise plan."
text: "All Certificate Syncs can be unlocked if you switch to Infisical Enterprise plan."
})
: onSelect(destination)
}
@@ -152,7 +152,7 @@ export const PkiSyncSelect = ({ onSelect }: Props) => {
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan."
text={popUp.upgradePlan.data?.text}
/>
</div>
);

View File

@@ -13,27 +13,28 @@ import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema";
import { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields";
import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields";
import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields";
import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields";
import { PkiSyncReviewFields } from "./PkiSyncReviewFields";
import { PkiSyncSourceFields } from "./PkiSyncSourceFields";
type Props = {
onComplete: (pkiSync: TPkiSync) => void;
destination: PkiSync;
onCancel: () => void;
initialData?: any;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [
{ name: "Source", key: "source", fields: ["subscriberId"] },
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] },
{ name: "Sync Options", key: "options", fields: ["syncOptions"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Certificates", key: "certificates", fields: ["certificateIds"] },
{ name: "Review", key: "review", fields: [] }
];
export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => {
export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => {
const createPkiSync = useCreatePkiSync();
const { currentProject } = useProject();
const { name: destinationName } = PKI_SYNC_MAP[destination];
@@ -49,26 +50,39 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
defaultValues: {
destination,
isAutoSyncEnabled: false,
certificateIds: [],
syncOptions: {
canImportCertificates: false,
canRemoveCertificates: false,
preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema
}
},
...initialData
} as Partial<TPkiSyncForm>,
reValidateMode: "onChange"
});
const onSubmit = async ({ connection, destinationConfig, ...formData }: TPkiSyncForm) => {
const onSubmit = async ({
connection,
destinationConfig,
certificateIds,
...formData
}: TPkiSyncForm) => {
try {
const pkiSync = await createPkiSync.mutateAsync({
...formData,
connectionId: connection.id,
projectId: currentProject.id,
destinationConfig
destinationConfig,
certificateIds: certificateIds || []
});
createNotification({
text: `Successfully added ${destinationName} Certificate Sync`,
text: `Successfully created ${destinationName} Certificate Sync${
certificateIds && certificateIds.length > 0
? ` with ${certificateIds.length} certificate(s)`
: ""
}`,
type: "success"
});
onComplete(pkiSync);
@@ -178,9 +192,6 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<PkiSyncSourceFields />
</Tab.Panel>
<Tab.Panel>
<PkiSyncDestinationFields />
</Tab.Panel>
@@ -194,8 +205,8 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
<FormControl
helperText={
value
? "Certificates will automatically be synced when changes occur in the source subscriber."
: "Certificates will not automatically be synced when changes occur in the source subscriber. You can still trigger syncs manually."
? "Certificates will automatically be synced when changes occur in the selected certificates."
: "Certificates will not automatically be synced when changes occur. You can still trigger syncs manually."
}
isError={Boolean(error)}
errorText={error?.message}
@@ -217,6 +228,9 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
<Tab.Panel>
<PkiSyncDetailsFields />
</Tab.Panel>
<Tab.Panel>
<PkiSyncCertificatesFields />
</Tab.Panel>
<Tab.Panel>
<PkiSyncReviewFields />
</Tab.Panel>

View File

@@ -27,12 +27,16 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => {
const formMethods = useForm<TUpdatePkiSyncForm>({
resolver: zodResolver(UpdatePkiSyncFormSchema),
defaultValues: {
...pkiSync,
name: pkiSync.name,
destination: pkiSync.destination,
description: pkiSync.description ?? "",
connection: {
id: pkiSync.connectionId,
name: pkiSync.appConnectionName
}
},
syncOptions: pkiSync.syncOptions,
destinationConfig: pkiSync.destinationConfig,
isAutoSyncEnabled: pkiSync.isAutoSyncEnabled
} as Partial<TUpdatePkiSyncForm>,
reValidateMode: "onChange"
});

View File

@@ -0,0 +1,189 @@
import { useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { faCertificate, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
FormControl,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import { CertStatus } from "@app/hooks/api";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { CertificateManagementModal } from "../CertificateManagementModal";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
export const PkiSyncCertificatesFields = () => {
const { control, watch, setValue } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject();
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false);
const certificateIds = watch("certificateIds") || [];
const { data, isLoading } = useListWorkspaceCertificates({
projectId: currentProject?.id || "",
offset: 0,
limit: 100,
forPkiSync: true
});
const certificates = data?.certificates || [];
const activeCertificates = useMemo(
() => certificates.filter((cert) => cert.status === CertStatus.ACTIVE),
[certificates]
);
const selectedCertificates = useMemo(
() => activeCertificates.filter((cert) => certificateIds.includes(cert.id)),
[activeCertificates, certificateIds]
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-bunker-300">Loading certificates...</div>
</div>
);
}
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Select certificates to sync with this integration. Only active certificates can be synced.
You can modify this selection after creating the sync.
</p>
<Controller
control={control}
name="certificateIds"
render={({ field: { value = [], onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="space-y-4">
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => setIsSelectionModalOpen(true)}
>
Add Certificates
</Button>
<div className="max-h-64 overflow-y-auto">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/6">Issued At</Th>
<Th className="w-1/6">Expires At</Th>
<Th className="w-12">Remove</Th>
</Tr>
</THead>
<TBody>
{selectedCertificates.map((cert) => {
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
const isExpired = new Date(cert.notAfter) < new Date();
return (
<Tr key={cert.id}>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notBefore).toLocaleDateString()}
</span>
</Td>
<Td className="max-w-0">
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
<Td>
<Button
size="xs"
variant="plain"
colorSchema="secondary"
className="pl-5"
aria-label="Remove certificate"
onClick={() => {
const newIds = value.filter((id: string) => id !== cert.id);
onChange(newIds);
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{selectedCertificates.length === 0 && (
<EmptyState title="No certificates selected" icon={faCertificate} />
)}
</TableContainer>
</div>
</div>
</FormControl>
)}
/>
<CertificateManagementModal
isOpen={isSelectionModalOpen}
onClose={() => setIsSelectionModalOpen(false)}
selectedCertificateIds={certificateIds}
onCertificateSelectionChange={(newCertificateIds) => {
setValue("certificateIds", newCertificateIds);
}}
title="Select Certificates for Sync"
subtitle="Choose which certificates you want to include in this sync. You can modify this selection after creating the sync."
saveButtonText="Update Selection"
/>
</>
);
};

View File

@@ -1,14 +1,18 @@
import { Controller, useFormContext } from "react-hook-form";
import { SingleValue } from "react-select";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { useRouterState } from "@tanstack/react-router";
import { AppConnectionOption } from "@app/components/app-connections";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context";
import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs";
import { usePopUp } from "@app/hooks";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { AddAppConnectionModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
@@ -18,12 +22,30 @@ type Props = {
export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
const { permission } = useProjectPermission();
const { control, watch } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject();
const { control, watch, setValue } = useFormContext<TPkiSyncForm>();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addConnection"] as const);
const destination = watch("destination");
const app = PKI_SYNC_CONNECTION_MAP[destination];
const { currentProject } = useProject();
const {
location: { pathname }
} = useRouterState();
const getPkiSyncReturnUrl = () => {
if (pathname.includes("selectedTab=secret-syncs")) {
return pathname.replace("selectedTab=secret-syncs", "selectedTab=pki-syncs");
}
if (!pathname.includes("selectedTab=")) {
const separator = pathname.includes("?") ? "&" : "?";
return `${pathname}${separator}selectedTab=pki-syncs`;
}
return pathname;
};
const { data: availableConnections, isPending } = useListAvailableAppConnections(
app,
currentProject.id
@@ -47,6 +69,7 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="App Connections can be created from the Project Settings page."
isError={Boolean(error)}
errorText={error?.message}
label={`${connectionName} Connection`}
@@ -54,36 +77,54 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
<FilterableSelect
value={value}
onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string; name: string }>)?.id === "_create") {
handlePopUpOpen("addConnection");
onChange(null);
const formData = { ...watch(), returnUrl: getPkiSyncReturnUrl() };
localStorage.setItem("pkiSyncFormData", JSON.stringify(formData));
if (callback) callback();
return;
}
onChange(newValue);
if (callback) callback();
}}
isLoading={isPending}
options={availableConnections}
options={[
...(canCreateConnection ? [{ id: "_create", name: "Create Connection" }] : []),
...(availableConnections ?? [])
]}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
components={{ Option: AppConnectionOption }}
/>
</FormControl>
)}
control={control}
name="connection"
/>
{availableConnections?.length === 0 && (
{!isPending && !availableConnections?.length && !canCreateConnection && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? (
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link to="/organization/app-connections" className="underline">
App Connections
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
You do not have access to any {appName} Connections. Contact an admin to create one.
</p>
)}
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => {
localStorage.removeItem("pkiSyncFormData");
handlePopUpToggle("addConnection", isOpen);
}}
projectType={currentProject.type}
projectId={currentProject.id}
app={app}
onComplete={(connection) => {
if (connection) {
setValue("connection", connection);
}
}}
/>
</>
);
};

View File

@@ -71,14 +71,14 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
isChecked={value}
>
<p>
Enable Certificate Removal{" "}
Enable Removal of Expired/Revoked Certificates{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, Infisical will remove certificates from the destination during
a sync if they are no longer managed by Infisical.
a sync if they are no longer active in Infisical.
</p>
<p className="mt-4">
Disable this option if you intend to manage some certificates manually
@@ -95,6 +95,94 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
)}
/>
{currentDestination === PkiSync.AwsCertificateManager && (
<Controller
control={control}
name="syncOptions.preserveArn"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-arn"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Preserve ARN on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, Infisical will replace the contents of existing certificates
while preserving the same ARN during certificate renewal syncs.
</p>
<p className="mt-4">
This allows consuming services like load balancers to continue using the
same ARN without requiring manual updates.
</p>
<p className="mt-4">
When disabled, new certificates will be created with new ARNs, and old
certificates will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
{currentDestination === PkiSync.AzureKeyVault && (
<Controller
control={control}
name="syncOptions.enableVersioning"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-version"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Enable Versioning on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, Infisical will create a new version of the existing
certificate in Azure Key Vault during certificate renewal syncs,
preserving the original certificate name.
</p>
<p className="mt-4">
This allows consuming services to continue using the same certificate name
while automatically using the latest version without requiring manual
updates.
</p>
<p className="mt-4">
When disabled, new certificates will be created with new names, and old
certificates will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="syncOptions.certificateNameSchema"

View File

@@ -1,10 +1,20 @@
import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/v2";
import {
GenericFieldLabel,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { useProject } from "@app/context";
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { useListWorkspacePkiSubscribers } from "@app/hooks/api";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
@@ -12,18 +22,24 @@ export const PkiSyncReviewFields = () => {
const { watch } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject();
const { data: pkiSubscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || "");
const { data } = useListWorkspaceCertificates({
projectId: currentProject?.id || "",
offset: 0,
limit: 100
});
const getSubscriberName = (subscriberId?: string) => {
const subscriber = pkiSubscribers.find((sub) => sub.id === subscriberId);
return subscriber?.name || "Unknown";
const certificates = data?.certificates || [];
const getSelectedCertificates = (certificateIds?: string[]) => {
if (!certificateIds || certificateIds.length === 0) return [];
return certificates.filter((cert) => certificateIds.includes(cert.id));
};
const {
name,
description,
connection,
subscriberId,
certificateIds,
syncOptions,
destination,
destinationConfig,
@@ -31,17 +47,79 @@ export const PkiSyncReviewFields = () => {
} = watch();
const destinationName = PKI_SYNC_MAP[destination].name;
const selectedCertificates = getSelectedCertificates(certificateIds);
return (
<div className="mb-4 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Source</span>
<span className="text-sm text-mineshaft-300">Certificates</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="PKI Subscriber">
{getSubscriberName(subscriberId)}
</GenericFieldLabel>
<div className="w-full">
{selectedCertificates.length === 0 ? (
<span className="text-bunker-400">No certificates selected</span>
) : (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/2">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/4">Expires At</Th>
</Tr>
</THead>
<TBody>
{selectedCertificates.map((cert) => {
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
return (
<Tr key={cert.id}>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
)}
</div>
</div>
<div className="flex flex-col gap-3">
@@ -62,11 +140,13 @@ export const PkiSyncReviewFields = () => {
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Sync Options</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div className="flex flex-wrap gap-x-8 gap-y-3">
<GenericFieldLabel label="Auto-Sync">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
<div className="mt-1">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel>
{/* Hidden for now - Import certificates functionality disabled
{syncOptions?.canImportCertificates !== undefined && (
@@ -79,9 +159,11 @@ export const PkiSyncReviewFields = () => {
*/}
{syncOptions?.canRemoveCertificates !== undefined && (
<GenericFieldLabel label="Remove Certificates">
<Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}>
{syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
<div className="mt-1">
<Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}>
{syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel>
)}
</div>

View File

@@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema";
const AwsCertificateManagerSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(false),
preserveArn: z.boolean().default(true),
certificateNameSchema: z
.string()
.optional()

View File

@@ -4,7 +4,46 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { BasePkiSyncSchema } from "./base-pki-sync-schema";
export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema().merge(
const AzureKeyVaultSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true),
enableVersioning: z.boolean().default(true),
certificateNameSchema: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
const allowedOptionalPlaceholders = ["{{environment}}"];
const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders]
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join("|");
const allowedContentRegex = new RegExp(
`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`
);
const contentIsValid = allowedContentRegex.test(val);
if (val.trim()) {
const certificateIdRegex = /\{\{certificateId\}\}/;
const certificateIdIsPresent = certificateIdRegex.test(val);
return contentIsValid && certificateIdIsPresent;
}
return contentIsValid;
},
{
message:
"Certificate name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders."
}
)
});
export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema(
AzureKeyVaultSyncOptionsSchema
).merge(
z.object({
destination: z.literal(PkiSync.AzureKeyVault),
destinationConfig: z.object({

View File

@@ -53,7 +53,8 @@ export const BasePkiSyncSchema = <T extends AnyZodObject | undefined = undefined
.max(255, "Name must be less than 255 characters"),
description: z.string().optional(),
isAutoSyncEnabled: z.boolean().default(true),
subscriberId: z.string().min(1, "PKI Subscriber is required"),
subscriberId: z.string().nullable().optional(),
certificateIds: z.array(z.string()).optional(),
connection: z.object({
id: z.string().uuid("Invalid connection ID format"),
name: z.string().max(255, "Connection name must be less than 255 characters")

View File

@@ -68,7 +68,8 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
onClick={() =>
enterprise && !subscription.enterpriseSecretSyncs
? handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
isEnterpriseFeature: true,
text: "All Secret Syncs can be unlocked if you switch to Infisical Enterprise plan."
})
: onSelect(destination)
}
@@ -149,7 +150,7 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
isOpen={popUp.upgradePlan.isOpen}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use every Secret Sync if you switch to Infisical's Enterprise plan."
text={popUp.upgradePlan.data?.text}
/>
</div>
);

View File

@@ -0,0 +1,93 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl } from "@app/components/v2";
import {
TChefDataBag,
TChefDataBagItem,
useChefConnectionListDataBagItems,
useChefConnectionListDataBags
} from "@app/hooks/api/appConnections/chef";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const ChefSyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.Chef }
>();
const connectionId = useWatch({ name: "connection.id", control });
const dataBagName = useWatch({ name: "destinationConfig.dataBagName", control });
const { data: dataBags, isLoading: isDataBagsLoading } = useChefConnectionListDataBags(
connectionId,
{
enabled: Boolean(connectionId)
}
);
const { data: dataBagItems, isLoading: isDataBagItemsLoading } =
useChefConnectionListDataBagItems(connectionId, dataBagName, {
enabled: Boolean(connectionId && dataBagName)
});
const handleChangeConnection = () => {
setValue("destinationConfig.dataBagName", "");
setValue("destinationConfig.dataBagItemName", "");
};
return (
<>
<SecretSyncConnectionField onChange={handleChangeConnection} />
<Controller
name="destinationConfig.dataBagName"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Data Bag">
<FilterableSelect
menuPlacement="top"
isLoading={isDataBagsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={dataBags?.find((dataBag) => dataBag.name === value) ?? null}
onChange={(option) => {
const selectedDataBag = option as SingleValue<TChefDataBag>;
onChange(selectedDataBag?.name ?? "");
setValue("destinationConfig.dataBagItemName", "");
}}
options={dataBags}
placeholder="Select a data bag..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.dataBagItemName"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Data Bag Item">
<FilterableSelect
menuPlacement="top"
isLoading={isDataBagItemsLoading && Boolean(connectionId && dataBagName)}
isDisabled={!connectionId || !dataBagName}
value={dataBagItems?.find((dataBagItem) => dataBagItem.name === value) ?? null}
onChange={(option) => {
const selectedDataBagItem = option as SingleValue<TChefDataBagItem>;
onChange(selectedDataBagItem?.name ?? "");
}}
options={dataBagItems}
placeholder="Select a data bag item..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.name}
/>
</FormControl>
)}
/>
</>
);
};

View File

@@ -12,6 +12,7 @@ import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields";
import { BitbucketSyncFields } from "./BitbucketSyncFields";
import { CamundaSyncFields } from "./CamundaSyncFields";
import { ChecklySyncFields } from "./ChecklySyncFields";
import { ChefSyncFields } from "./ChefSyncFields";
import { CloudflarePagesSyncFields } from "./CloudflarePagesSyncFields";
import { CloudflareWorkersSyncFields } from "./CloudflareWorkersSyncFields";
import { DatabricksSyncFields } from "./DatabricksSyncFields";
@@ -104,6 +105,8 @@ export const SecretSyncDestinationFields = () => {
return <BitbucketSyncFields />;
case SecretSync.LaravelForge:
return <LaravelForgeSyncFields />;
case SecretSync.Chef:
return <ChefSyncFields />;
case SecretSync.Northflank:
return <NorthflankSyncFields />;
default:

View File

@@ -71,6 +71,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.Northflank:
case SecretSync.Bitbucket:
case SecretSync.LaravelForge:
case SecretSync.Chef:
AdditionalSyncOptionsFieldsComponent = null;
break;
default:

View File

@@ -0,0 +1,18 @@
import { useFormContext } from "react-hook-form";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const ChefSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Chef }>();
const dataBagName = watch("destinationConfig.dataBagName");
const dataBagItemName = watch("destinationConfig.dataBagItemName");
return (
<>
<GenericFieldLabel label="Data Bag">{dataBagName}</GenericFieldLabel>
<GenericFieldLabel label="Data Bag Item">{dataBagItemName}</GenericFieldLabel>
</>
);
};

View File

@@ -24,6 +24,7 @@ import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields";
import { BitbucketSyncReviewFields } from "./BitbucketSyncReviewFields";
import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields";
import { ChecklySyncReviewFields } from "./ChecklySyncReviewFields";
import { ChefSyncReviewFields } from "./ChefSyncReviewFields";
import { CloudflarePagesSyncReviewFields } from "./CloudflarePagesReviewFields";
import { CloudflareWorkersSyncReviewFields } from "./CloudflareWorkersReviewFields";
import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
@@ -177,6 +178,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.LaravelForge:
DestinationFieldsComponent = <LaravelForgeSyncReviewFields />;
break;
case SecretSync.Chef:
DestinationFieldsComponent = <ChefSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const ChefSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.Chef),
destinationConfig: z.object({
dataBagName: z.string().trim().min(1, "Data Bag required"),
dataBagItemName: z.string().trim().min(1, "Data Bag Item required")
})
})
);

View File

@@ -9,6 +9,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti
import { BitbucketSyncDestinationSchema } from "./bitbucket-sync-destination-schema";
import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema";
import { ChecklySyncDestinationSchema } from "./checkly-sync-destination-schema";
import { ChefSyncDestinationSchema } from "./chef-sync-destination-schema";
import { CloudflarePagesSyncDestinationSchema } from "./cloudflare-pages-sync-destination-schema";
import { CloudflareWorkersSyncDestinationSchema } from "./cloudflare-workers-sync-destination-schema";
import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema";
@@ -65,7 +66,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
NetlifySyncDestinationSchema,
NorthflankSyncDestinationSchema,
BitbucketSyncDestinationSchema,
LaravelForgeSyncDestinationSchema
LaravelForgeSyncDestinationSchema,
ChefSyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema;

View File

@@ -0,0 +1,100 @@
import { ReactNode } from "react";
import { Tooltip } from "@app/components/v2";
interface CertificateNameData {
altNames?: string | null;
commonName?: string | null;
certificateAltNames?: string | null;
certificateCommonName?: string | null;
}
interface DisplayNameResult {
originalDisplayName: string;
displayName: string;
isTruncated: boolean;
}
/**
* Extracts and formats the display name for a certificate from SAN/CN data
* @param cert - Certificate object with potential altNames/commonName fields
* @param maxLength - Maximum length before truncating (default: 64)
* @param fallback - Fallback text when no name is found (default: "—")
* @returns Object with original name, truncated name, and truncation flag
*/
export const getCertificateDisplayName = (
cert: CertificateNameData,
maxLength: number = 64,
fallback: string = "—"
): DisplayNameResult => {
// Extract original display name - prioritize SAN over CN
let originalDisplayName = fallback;
// Handle different property name variations
const altNames = cert.altNames || cert.certificateAltNames;
const commonName = cert.commonName || cert.certificateCommonName;
if (altNames && altNames.trim()) {
originalDisplayName = altNames.trim();
} else if (commonName && commonName.trim()) {
originalDisplayName = commonName.trim();
}
// Handle truncation
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > maxLength) {
displayName = `${originalDisplayName.substring(0, maxLength)}...`;
isTruncated = true;
}
return {
originalDisplayName,
displayName,
isTruncated
};
};
/**
* Renders a certificate display name with optional tooltip for truncated names
* @param cert - Certificate object with potential altNames/commonName fields
* @param maxLength - Maximum length before truncating (default: 64)
* @param fallback - Fallback text when no name is found (default: "—")
* @param className - Optional CSS class for the display element
* @param tooltipClassName - Optional CSS class for the tooltip (default: "max-w-lg")
* @returns JSX element with certificate name and optional tooltip
*/
export const CertificateDisplayName = ({
cert,
maxLength = 64,
fallback = "—",
className = "truncate",
tooltipClassName = "max-w-lg"
}: {
cert: CertificateNameData;
maxLength?: number;
fallback?: string;
className?: string;
tooltipClassName?: string;
}): ReactNode => {
const { originalDisplayName, displayName, isTruncated } = getCertificateDisplayName(
cert,
maxLength,
fallback
);
if (isTruncated) {
return (
<Tooltip content={originalDisplayName} className={tooltipClassName}>
<div className={className}>{displayName}</div>
</Tooltip>
);
}
return (
<div className={className} title={originalDisplayName}>
{displayName}
</div>
);
};

View File

@@ -9,22 +9,10 @@ export const HighlightText = ({
}) => {
if (!text) return null;
const renderTextWithNewlines = (input: string, baseKeyPrefix: string = ""): React.ReactNode[] => {
if (!input) return [];
const lines = input.split("\n");
return lines.flatMap((line, index) => {
const nodes: React.ReactNode[] = [line];
if (index < lines.length - 1) {
nodes.push(<br key={`${baseKeyPrefix}-br-${line}`} />);
}
return nodes;
});
};
const searchTerm = highlight.toLowerCase().trim();
if (!searchTerm) {
return <span>{renderTextWithNewlines(text, "full-text")}</span>;
return <span>{text}</span>;
}
const parts: React.ReactNode[] = [];
@@ -36,16 +24,12 @@ export const HighlightText = ({
text.replace(regex, (match: string, offset: number) => {
if (offset > lastIndex) {
const preMatchText = text.substring(lastIndex, offset);
parts.push(
<span key={`pre-${lastIndex}`}>
{renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)}
</span>
);
parts.push(<span key={`pre-${lastIndex}`}>{preMatchText}</span>);
}
parts.push(
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
{renderTextWithNewlines(match, `match-${offset}`)}
{match}
</span>
);
@@ -56,11 +40,7 @@ export const HighlightText = ({
if (lastIndex < text.length) {
const postMatchText = text.substring(lastIndex);
parts.push(
<span key={`post-${lastIndex}`}>
{renderTextWithNewlines(postMatchText, `post-${lastIndex}`)}
</span>
);
parts.push(<span key={`post-${lastIndex}`}>{postMatchText}</span>);
}
return parts;

View File

@@ -301,10 +301,6 @@ export const ROUTE_PATHS = Object.freeze({
"/projects/cert-management/$projectId/subscribers",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/subscribers"
),
CertificatesPage: setRoute(
"/projects/cert-management/$projectId/certificates",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates"
),
CertificateAuthoritiesPage: setRoute(
"/projects/cert-management/$projectId/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities"

View File

@@ -46,6 +46,7 @@ import {
} from "@app/hooks/api/appConnections/types";
import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/bitbucket-connection";
import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection";
import { ChefConnectionMethod } from "@app/hooks/api/appConnections/types/chef-connection";
import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean";
import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection";
import { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-connection";
@@ -129,7 +130,8 @@ export const APP_CONNECTION_MAP: Record<
name: "Laravel Forge",
image: "Laravel Forge.png",
size: 65
}
},
[AppConnection.Chef]: { name: "Chef", image: "Chef.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -202,6 +204,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case RenderConnectionMethod.ApiKey:
case ChecklyConnectionMethod.ApiKey:
return { name: "API Key", icon: faKey };
case ChefConnectionMethod.UserKey:
return { name: "User Key", icon: faKey };
case AzureClientSecretsConnectionMethod.ClientSecret:
case AzureAppConfigurationConnectionMethod.ClientSecret:
case AzureKeyVaultConnectionMethod.ClientSecret:

View File

@@ -121,6 +121,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
[SecretSync.LaravelForge]: {
name: "Laravel Forge",
image: "Laravel Forge.png"
},
[SecretSync.Chef]: {
name: "Chef",
image: "Chef.png"
}
};
@@ -156,7 +160,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Netlify]: AppConnection.Netlify,
[SecretSync.Northflank]: AppConnection.Northflank,
[SecretSync.Bitbucket]: AppConnection.Bitbucket,
[SecretSync.LaravelForge]: AppConnection.LaravelForge
[SecretSync.LaravelForge]: AppConnection.LaravelForge,
[SecretSync.Chef]: AppConnection.Chef
};
export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record<

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";

View File

@@ -0,0 +1,68 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "@app/hooks/api/appConnections";
import { TChefDataBag, TChefDataBagItem } from "./types";
const chefConnectionKeys = {
all: [...appConnectionKeys.all, "chef"] as const,
listDataBags: (connectionId: string) =>
[...chefConnectionKeys.all, "data-bags", connectionId] as const,
listDataBagItems: (connectionId: string, dataBagName: string) =>
[...chefConnectionKeys.all, "data-bag-items", connectionId, dataBagName] as const
};
export const useChefConnectionListDataBags = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TChefDataBag[],
unknown,
TChefDataBag[],
ReturnType<typeof chefConnectionKeys.listDataBags>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: chefConnectionKeys.listDataBags(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TChefDataBag[]>(
`/api/v1/app-connections/chef/${connectionId}/data-bags`
);
return data;
},
...options
});
};
export const useChefConnectionListDataBagItems = (
connectionId: string,
dataBagName: string,
options?: Omit<
UseQueryOptions<
TChefDataBagItem[],
unknown,
TChefDataBagItem[],
ReturnType<typeof chefConnectionKeys.listDataBagItems>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: chefConnectionKeys.listDataBagItems(connectionId, dataBagName),
queryFn: async () => {
const params = { dataBagName };
const { data } = await apiRequest.get<TChefDataBagItem[]>(
`/api/v1/app-connections/chef/${connectionId}/data-bag-items`,
{ params }
);
return data;
},
enabled: Boolean(connectionId && dataBagName),
...options
});
};

View File

@@ -0,0 +1,7 @@
export type TChefDataBag = {
name: string;
};
export type TChefDataBagItem = {
name: string;
};

View File

@@ -39,5 +39,6 @@ export enum AppConnection {
Northflank = "northflank",
Okta = "okta",
Redis = "redis",
LaravelForge = "laravel-forge"
LaravelForge = "laravel-forge",
Chef = "chef"
}

View File

@@ -148,6 +148,10 @@ export type TChecklyConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Checkly;
};
export type TChefConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Chef;
};
export type TSupabaseConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Supabase;
};
@@ -220,7 +224,8 @@ export type TAppConnectionOption =
| TNorthflankConnectionOption
| TOktaConnectionOption
| TAzureAdCsConnectionOption
| TLaravelForgeConnectionOption;
| TLaravelForgeConnectionOption
| TChefConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -264,4 +269,5 @@ export type TAppConnectionOptionMap = {
[AppConnection.AzureADCS]: TAzureAdCsConnectionOption;
[AppConnection.Redis]: TRedisConnectionOption;
[AppConnection.LaravelForge]: TLaravelForgeConnectionOption;
[AppConnection.Chef]: TChefConnectionOption;
};

View File

@@ -0,0 +1,16 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum ChefConnectionMethod {
UserKey = "user-key"
}
export type TChefConnection = TRootAppConnection & { app: AppConnection.Chef } & {
method: ChefConnectionMethod.UserKey;
credentials: {
instanceUrl?: string;
orgName: string;
userName: string;
privateKey: string;
};
};

View File

@@ -11,6 +11,7 @@ import { TAzureKeyVaultConnection } from "./azure-key-vault-connection";
import { TBitbucketConnection } from "./bitbucket-connection";
import { TCamundaConnection } from "./camunda-connection";
import { TChecklyConnection } from "./checkly-connection";
import { TChefConnection } from "./chef-connection";
import { TCloudflareConnection } from "./cloudflare-connection";
import { TDatabricksConnection } from "./databricks-connection";
import { TDigitalOceanConnection } from "./digital-ocean";
@@ -53,6 +54,7 @@ export * from "./azure-key-vault-connection";
export * from "./bitbucket-connection";
export * from "./camunda-connection";
export * from "./checkly-connection";
export * from "./chef-connection";
export * from "./cloudflare-connection";
export * from "./databricks-connection";
export * from "./flyio-connection";
@@ -124,7 +126,8 @@ export type TAppConnection =
| TNetlifyConnection
| TNorthflankConnection
| TOktaConnection
| TRedisConnection;
| TRedisConnection
| TChefConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id" | "projectId">;

View File

@@ -152,7 +152,7 @@ export const useCreateCertificate = () => {
});
};
export const useCreateCertificateV3 = () => {
export const useCreateCertificateV3 = (options?: { projectId?: string }) => {
const queryClient = useQueryClient();
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
mutationFn: async (body) => {
@@ -167,6 +167,12 @@ export const useCreateCertificateV3 = () => {
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
if (options?.projectId) {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(options.projectId)
});
}
queryClient.invalidateQueries({
queryKey: ["certificate-profiles"]
});

View File

@@ -8,7 +8,6 @@ export {
useGetCertificateProfileById,
useGetCertificateProfileBySlug,
useGetProfileCertificates,
useGetProfileMetrics,
useListCertificateProfiles
} from "./queries";
export type * from "./types";

View File

@@ -4,7 +4,6 @@ import { apiRequest } from "@app/config/request";
import {
TCertificateProfile,
TCertificateProfileMetrics,
TCertificateProfileWithDetails,
TGetCertificateProfileByIdDTO,
TGetCertificateProfileBySlugDTO,
@@ -20,7 +19,6 @@ export const certificateProfileKeys = {
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: string;
expiringDays?: number;
@@ -51,10 +49,8 @@ export const useListCertificateProfiles = ({
limit = 20,
offset = 0,
search,
includeMetrics = false,
includeConfigs = false,
enrollmentType,
expiringDays = 7
enrollmentType
}: TListCertificateProfilesDTO) => {
return useQuery({
queryKey: certificateProfileKeys.list({
@@ -62,10 +58,8 @@ export const useListCertificateProfiles = ({
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
enrollmentType
}),
queryFn: async () => {
const { data } = await apiRequest.get<{
@@ -77,10 +71,8 @@ export const useListCertificateProfiles = ({
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
enrollmentType
}
});
return data;
@@ -145,18 +137,3 @@ export const useGetProfileCertificates = ({
enabled: Boolean(profileId)
});
};
export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }),
queryFn: async () => {
const { data } = await apiRequest.get<{
metrics: TCertificateProfileMetrics;
}>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, {
params: { expiringDays }
});
return data.metrics;
},
enabled: Boolean(profileId)
});
};

View File

@@ -10,7 +10,6 @@ export type TCertificateProfile = {
apiConfigId?: string;
createdAt: string;
updatedAt: string;
metrics?: TCertificateProfileMetrics;
};
export type TCertificateProfileWithDetails = TCertificateProfile & {
@@ -81,10 +80,8 @@ export type TListCertificateProfilesDTO = {
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: "api" | "est";
expiringDays?: number;
};
export type TGetCertificateProfileByIdDTO = {
@@ -96,15 +93,6 @@ export type TGetCertificateProfileBySlugDTO = {
slug: string;
};
export type TCertificateProfileMetrics = {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
};
export type TProfileCertificate = {
id: string;
serialNumber: string;
@@ -126,5 +114,4 @@ export type TGetProfileCertificatesDTO = {
export type TGetProfileMetricsDTO = {
profileId: string;
expiringDays?: number;
};

View File

@@ -42,8 +42,8 @@ export type TCreateCertificateTemplateDTO = {
subjectAlternativeName: string;
ttl: string;
projectId: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
keyUsages: string[];
extendedKeyUsages: string[];
};
export type TUpdateCertificateTemplateDTO = {
@@ -55,8 +55,8 @@ export type TUpdateCertificateTemplateDTO = {
subjectAlternativeName?: string;
ttl?: string;
projectId: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
keyUsages?: string[];
extendedKeyUsages?: string[];
};
export type TDeleteCertificateTemplateDTO = {
@@ -71,8 +71,8 @@ export type TCreateCertificateTemplateV2DTO = {
subjectAlternativeName: string;
ttl: string;
projectId: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
keyUsages: string[];
extendedKeyUsages: string[];
};
export type TUpdateCertificateTemplateV2DTO = {
@@ -83,8 +83,8 @@ export type TUpdateCertificateTemplateV2DTO = {
subjectAlternativeName?: string;
ttl?: string;
projectId: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
keyUsages?: string[];
extendedKeyUsages?: string[];
};
export type TDeleteCertificateTemplateV2DTO = {

View File

@@ -1,3 +1,4 @@
export { CertStatus } from "./enums";
export {
useDeleteCert,
useImportCertificate,

View File

@@ -9,6 +9,7 @@ export type TCertificate = {
friendlyName: string;
commonName: string;
subjectAltNames: string;
altNames?: string;
serialNumber: string;
notBefore: string;
notAfter: string;

View File

@@ -28,6 +28,7 @@ export * from "./organization";
export * from "./pkiAlerts";
export * from "./pkiCollections";
export * from "./pkiSubscriber";
export * from "./pkiSyncs";
export * from "./projects";
export * from "./projectUserAdditionalPrivilege";
export * from "./rateLimit";

View File

@@ -47,8 +47,8 @@ export type TCreatePkiSubscriberDTO = {
commonName: string;
ttl?: string;
subjectAlternativeNames: string[];
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
keyUsages: string[];
extendedKeyUsages: string[];
enableAutoRenewal?: boolean;
autoRenewalPeriodInDays?: number;
properties?: TPkiSubscriberProperties;
@@ -63,8 +63,8 @@ export type TUpdatePkiSubscriberDTO = {
status?: PkiSubscriberStatus;
ttl?: string;
subjectAlternativeNames?: string[];
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
keyUsages?: string[];
extendedKeyUsages?: string[];
enableAutoRenewal?: boolean;
autoRenewalPeriodInDays?: number;
properties?: TPkiSubscriberProperties;

View File

@@ -9,3 +9,10 @@ export enum PkiSyncStatus {
Succeeded = "succeeded",
Failed = "failed"
}
export enum CertificateSyncStatus {
Pending = "pending",
Syncing = "syncing",
Succeeded = "succeeded",
Failed = "failed"
}

View File

@@ -198,3 +198,47 @@ export const useTriggerPkiSyncRemoveCertificates = () => {
}
});
};
export const useAddCertificatesToPkiSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
pkiSyncId,
certificateIds
}: {
pkiSyncId: string;
certificateIds: string[];
}) => {
const { data } = await apiRequest.post(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, {
certificateIds
});
return data;
},
onSuccess: (_, { pkiSyncId }) => {
queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) });
}
});
};
export const useRemoveCertificatesFromPkiSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
pkiSyncId,
certificateIds
}: {
pkiSyncId: string;
certificateIds: string[];
}) => {
const { data } = await apiRequest.delete(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, {
data: { certificateIds }
});
return data;
},
onSuccess: (_, { pkiSyncId }) => {
queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) });
}
});
};

View File

@@ -2,14 +2,25 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { PkiSync, TPkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { TListPkiSyncOptions, TListPkiSyncs, TPkiSync } from "@app/hooks/api/pkiSyncs/types";
import {
TListPkiSyncOptions,
TListPkiSyncs,
TPkiSync,
TPkiSyncCertificate
} from "@app/hooks/api/pkiSyncs/types";
export const pkiSyncKeys = {
all: ["pki-sync"] as const,
options: () => [...pkiSyncKeys.all, "options"] as const,
list: (projectId: string) => [...pkiSyncKeys.all, "list", projectId] as const,
listWithCertificate: (projectId: string, certificateId: string) =>
[...pkiSyncKeys.all, "list", projectId, "with-certificate", certificateId] as const,
byId: (syncId: string, projectId: string) =>
[...pkiSyncKeys.all, "by-id", syncId, projectId] as const
[...pkiSyncKeys.all, "by-id", syncId, projectId] as const,
certificates: (syncId: string, pagination?: { offset: number; limit: number }) =>
pagination
? ([...pkiSyncKeys.all, "certificates", syncId, pagination] as const)
: ([...pkiSyncKeys.all, "certificates", syncId] as const)
};
export const usePkiSyncOptions = (
@@ -41,9 +52,14 @@ export const usePkiSyncOption = (destination: PkiSync) => {
return { syncOption, isPending };
};
export const fetchPkiSyncsByProjectId = async (projectId: string) => {
export const fetchPkiSyncsByProjectId = async (projectId: string, certificateId?: string) => {
const params: { projectId: string; certificateId?: string } = { projectId };
if (certificateId) {
params.certificateId = certificateId;
}
const { data } = await apiRequest.get<TListPkiSyncs>("/api/v1/pki/syncs", {
params: { projectId }
params
});
return data.pkiSyncs;
@@ -63,6 +79,27 @@ export const useListPkiSyncs = (
});
};
export const useListPkiSyncsWithCertificate = (
projectId: string,
certificateId: string,
options?: Omit<
UseQueryOptions<
TPkiSync[],
unknown,
TPkiSync[],
ReturnType<typeof pkiSyncKeys.listWithCertificate>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pkiSyncKeys.listWithCertificate(projectId, certificateId),
queryFn: () => fetchPkiSyncsByProjectId(projectId, certificateId),
enabled: !!projectId && !!certificateId,
...options
});
};
export const useGetPkiSync = (
{ syncId, projectId }: { syncId: string; projectId: string },
options?: Omit<
@@ -82,3 +119,33 @@ export const useGetPkiSync = (
...options
});
};
export const useListPkiSyncCertificates = (
syncId: string,
pagination?: { offset?: number; limit?: number },
options?: Omit<
UseQueryOptions<
{ certificates: TPkiSyncCertificate[]; totalCount: number },
unknown,
{ certificates: TPkiSyncCertificate[]; totalCount: number },
ReturnType<typeof pkiSyncKeys.certificates>
>,
"queryKey" | "queryFn"
>
) => {
const { offset = 0, limit = 20 } = pagination || {};
return useQuery({
queryKey: pkiSyncKeys.certificates(syncId, { offset, limit }),
queryFn: async () => {
const { data } = await apiRequest.get(`/api/v1/pki/syncs/${syncId}/certificates`, {
params: { offset, limit }
});
return {
certificates: data.certificates || [],
totalCount: data.totalCount || 0
};
},
...options
});
};

View File

@@ -1,6 +1,6 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PkiSyncStatus } from "../enums";
import { CertificateSyncStatus, PkiSyncStatus } from "../enums";
export type RootPkiSyncOptions = {
canImportCertificates: boolean;
@@ -43,4 +43,26 @@ export type TRootPkiSync = {
} | null;
appConnectionName?: string;
appConnectionApp?: string;
hasCertificate?: boolean;
};
export type TPkiSyncCertificate = {
id: string;
pkiSyncId: string;
certificateId: string;
syncStatus?: CertificateSyncStatus | null;
lastSyncMessage?: string | null;
lastSyncedAt?: string | null;
createdAt: string;
updatedAt: string;
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
certificateRenewBeforeDays?: number;
certificateRenewalError?: string;
pkiSyncName?: string;
pkiSyncDestination?: string;
};

View File

@@ -33,7 +33,8 @@ type TCreatePkiSyncDTOBase = {
certificateNameSchema?: string;
};
isAutoSyncEnabled: boolean;
subscriberId?: string;
subscriberId?: string | null;
certificateIds?: string[];
projectId: string;
};

View File

@@ -664,17 +664,26 @@ export const useListWorkspaceCas = ({
export const useListWorkspaceCertificates = ({
projectId,
offset,
limit
limit,
friendlyName,
commonName,
forPkiSync
}: {
projectId: string;
offset: number;
limit: number;
friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
}) => {
return useQuery({
queryKey: projectKeys.specificProjectCertificates({
projectId,
offset,
limit
limit,
friendlyName,
commonName,
forPkiSync
}),
queryFn: async () => {
const params = new URLSearchParams({
@@ -682,6 +691,16 @@ export const useListWorkspaceCertificates = ({
limit: String(limit)
});
if (friendlyName) {
params.append("friendlyName", friendlyName);
}
if (commonName) {
params.append("commonName", commonName);
}
if (forPkiSync) {
params.append("forPkiSync", "true");
}
const {
data: { certificates, totalCount }
} = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>(
@@ -693,7 +712,8 @@ export const useListWorkspaceCertificates = ({
return { certificates, totalCount };
},
enabled: Boolean(projectId)
enabled: Boolean(projectId),
placeholderData: (previousData) => previousData
});
};

View File

@@ -39,12 +39,22 @@ export const projectKeys = {
specificProjectCertificates: ({
projectId,
offset,
limit
limit,
friendlyName,
commonName,
forPkiSync
}: {
projectId: string;
offset: number;
limit: number;
}) => [...projectKeys.forProjectCertificates(projectId), { offset, limit }] as const,
friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
}) =>
[
...projectKeys.forProjectCertificates(projectId),
{ offset, limit, friendlyName, commonName, forPkiSync }
] as const,
getProjectPkiAlerts: (projectId: string) => [{ projectId }, "project-pki-alerts"] as const,
getProjectPkiSubscribers: (projectId: string) =>
[{ projectId }, "project-pki-subscribers"] as const,

View File

@@ -30,7 +30,8 @@ export enum SecretSync {
Netlify = "netlify",
Northflank = "northflank",
Bitbucket = "bitbucket",
LaravelForge = "laravel-forge"
LaravelForge = "laravel-forge",
Chef = "chef"
}
export enum SecretSyncStatus {

View File

@@ -0,0 +1,16 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TChefSync = TRootSecretSync & {
destination: SecretSync.Chef;
destinationConfig: {
dataBagName: string;
dataBagItemName: string;
};
connection: {
app: AppConnection.Chef;
name: string;
id: string;
};
};

View File

@@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync";
import { TBitbucketSync } from "./bitbucket-sync";
import { TCamundaSync } from "./camunda-sync";
import { TChecklySync } from "./checkly-sync";
import { TChefSync } from "./chef-sync";
import { TCloudflarePagesSync } from "./cloudflare-pages-sync";
import { TCloudflareWorkersSync } from "./cloudflare-workers-sync";
import { TDatabricksSync } from "./databricks-sync";
@@ -73,7 +74,8 @@ export type TSecretSync =
| TNetlifySync
| TNorthflankSync
| TBitbucketSync
| TLaravelForgeSync;
| TLaravelForgeSync
| TChefSync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };

View File

@@ -19,7 +19,8 @@ export const PamLayout = () => {
useEffect(() => {
if (subscription && !subscription.pam) {
handlePopUpOpen("upgradePlan", {
description: "You can use PAM if you switch to Infisical's Enterprise plan.",
description:
"Your current plan does not provide access to Infisical PAM. To unlock this feature, please upgrade to Infisical Enterprise plan.",
isEnterpriseFeature: true
});
}

View File

@@ -52,25 +52,7 @@ export const PkiManagerLayout = () => {
projectId: currentProject.id
}}
>
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Policies</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/pki-collections\//)
? "selected"
: ""
}
>
Certificates
</Tab>
)}
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificate-authorities"

View File

@@ -225,7 +225,7 @@ export const ProjectSelect = () => {
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You have exceeded the number of projects allowed on the free plan."
text="You’ve reached the maximum number of projects available on the Free plan. Upgrade to the Infisical Pro plan to create more projects."
/>
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}

View File

@@ -411,7 +411,7 @@ export const ServerAdminsTable = () => {
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
text="Your current plan does not allow removing server admins. To unlock this feature, please upgrade to Infisical Pro plan."
/>
<DeleteActionModal
isOpen={popUp.removeUsers.isOpen}

View File

@@ -55,7 +55,7 @@ export const EncryptionPageForm = () => {
if (!subscription.hsm) {
handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true,
description: "Hardware Security Module's (HSM's), are only available on Enterprise plans."
text: "Your current plan does not include access to Hardware Security Module (HSM). To unlock this feature, please upgrade to Infisical Enterprise plan."
});
return;
}
@@ -137,7 +137,7 @@ export const EncryptionPageForm = () => {
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
text={popUp.upgradePlan?.data?.text}
isEnterpriseFeature={popUp.upgradePlan?.data?.isEnterpriseFeature}
/>
</>

View File

@@ -58,9 +58,6 @@ import {
import { User } from "@app/hooks/api/users/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions";
const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user";
const UserPanelTable = ({
handlePopUpOpen,
users,
@@ -84,7 +81,7 @@ const UserPanelTable = ({
data?: {
username: string;
id: string;
message?: string;
text?: string;
}
) => void;
isPending: boolean;
@@ -260,7 +257,7 @@ const UserPanelTable = ({
handlePopUpOpen("upgradePlan", {
username,
id,
message: addServerAdminUpgradePlanMessage
text: "Your current plan does not allow setting additional server admins. To unlock this feature, please upgrade to Infisical Pro plan."
});
return;
}
@@ -288,7 +285,7 @@ const UserPanelTable = ({
handlePopUpOpen("upgradePlan", {
username,
id,
message: removeServerAdminUpgradePlanMessage
text: "Your current plan does not allow removing server admins. To unlock this feature, please upgrade to Infisical Pro plan."
});
return;
}
@@ -502,7 +499,7 @@ export const UserIdentitiesTable = () => {
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
text={popUp.upgradePlan.data?.text}
/>
<DeleteActionModal
isOpen={popUp.removeUsers.isOpen}

View File

@@ -1,7 +1,6 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
@@ -24,8 +23,7 @@ export const CaSection = () => {
"caCert",
"installCaCert",
"deleteCa",
"caStatus", // enable / disable
"upgradePlan"
"caStatus" // enable / disable
] as const);
const onRemoveCaSubmit = async (caName: string) => {
@@ -109,11 +107,6 @@ export const CaSection = () => {
onUpdateCaStatus(popUp?.caStatus?.data as { caName: string; status: CaStatus })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -34,15 +34,12 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "upgradePlan"]
>,
popUpName: keyof UsePopUpState<["installCaCert", "caCert", "ca", "deleteCa", "caStatus"]>,
data?: {
caId?: string;
caName?: string;
dn?: string;
status?: CaStatus;
description?: string;
}
) => void;
};

View File

@@ -124,7 +124,7 @@ type Props = {
const caTypes = [
{ label: "ACME", value: CaType.ACME },
{ label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS }
{ label: "Active Directory Certificate Services (AD CS)", value: CaType.AZURE_AD_CS }
];
export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {

View File

@@ -1,7 +1,6 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
@@ -20,8 +19,7 @@ export const ExternalCaSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"ca",
"deleteCa",
"caStatus", // enable / disable
"upgradePlan"
"caStatus" // enable / disable
] as const);
const onRemoveCaSubmit = async (caName: string, type: CaType) => {
@@ -116,11 +114,6 @@ export const ExternalCaSection = () => {
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -33,12 +33,11 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus", "upgradePlan"]>,
popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus"]>,
data?: {
name?: string;
type?: CaType;
status?: CaStatus;
description?: string;
}
) => void;
};

View File

@@ -1,61 +0,0 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiCollectionSection } from "../AlertingPage/components";
import { CertificatesSection } from "./components";
export const CertificatesPage = () => {
const { t } = useTranslation();
const { permission } = useProjectPermission();
const canAccessPkiColl = permission.can(
ProjectPermissionActions.Read,
ProjectPermissionSub.PkiCollections
);
const canAccessCerts = permission.can(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
return (
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificates"
description="View and track issued certificates, monitor expiration dates, and manage certificate lifecycles."
/>
{/* If both are false, the section does not render. This is to prevent duplicate banners. */}
{(canAccessCerts || canAccessPkiColl) && (
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.PkiCollections}
>
<PkiCollectionSection />
</ProjectPermissionCan>
)}
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionCertificateActions.Read}
a={ProjectPermissionSub.Certificates}
>
<CertificatesSection />
</ProjectPermissionCan>
</div>
</div>
);
};

View File

@@ -122,11 +122,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "",
includeMetrics: false,
enrollmentType: "api"
});
const { mutateAsync: createCertificate } = useCreateCertificateV3();
const { mutateAsync: createCertificate } = useCreateCertificateV3({
projectId: currentProject?.id
});
const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection));

View File

@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useProject } from "@app/context";
import {
PkiSync,
useAddCertificatesToPkiSync,
useListPkiSyncsWithCertificate,
useRemoveCertificatesFromPkiSync
} from "@app/hooks/api/pkiSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
type Props = {
popUp: {
isOpen: boolean;
data?: {
certificateId?: string;
commonName?: string;
};
};
handlePopUpToggle: (popUpName: "managePkiSyncs", state?: boolean) => void;
};
const PER_PAGE = 10;
export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Props) => {
const [selectedSyncIds, setSelectedSyncIds] = useState<Set<string>>(new Set());
const [initialSyncIds, setInitialSyncIds] = useState<Set<string>>(new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const { currentProject } = useProject();
const navigate = useNavigate();
const { certificateId, commonName } = popUp.data || {};
const { data: pkiSyncs = [], isPending } = useListPkiSyncsWithCertificate(
currentProject?.id || "",
certificateId || "",
{
enabled: !!currentProject?.id && !!certificateId
}
);
const addCertificatesToSync = useAddCertificatesToPkiSync();
const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync();
const filteredSyncs = useMemo(() => {
if (!searchTerm.trim()) return pkiSyncs;
const searchLower = searchTerm.toLowerCase();
return pkiSyncs.filter((sync) => sync.name.toLowerCase().includes(searchLower));
}, [pkiSyncs, searchTerm]);
const startIndex = (currentPage - 1) * PER_PAGE;
const endIndex = startIndex + PER_PAGE;
const paginatedSyncs = filteredSyncs.slice(startIndex, endIndex);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
const handleClose = () => {
handlePopUpToggle("managePkiSyncs", false);
setSelectedSyncIds(new Set());
setInitialSyncIds(new Set());
setSearchTerm("");
setCurrentPage(1);
};
const handleNavigateToPkiSyncs = () => {
if (!currentProject?.id) return;
navigate({
to: ROUTE_PATHS.CertManager.IntegrationsListPage.path,
params: {
projectId: currentProject.id
},
search: {
selectedTab: IntegrationsListPageTabs.PkiSyncs
}
});
handleClose();
};
const getDestinationDisplayName = (destination: string) => {
switch (destination) {
case PkiSync.AzureKeyVault:
return "Azure Key Vault";
case PkiSync.AwsCertificateManager:
return "AWS Certificate Manager";
default:
return destination;
}
};
useEffect(() => {
if (!certificateId || !pkiSyncs || pkiSyncs.length === 0) return;
const currentSyncIds = new Set(
pkiSyncs.filter((sync) => sync.hasCertificate).map((sync) => sync.id)
);
setSelectedSyncIds(currentSyncIds);
setInitialSyncIds(new Set(currentSyncIds));
}, [certificateId, pkiSyncs]);
const handleSyncToggle = (syncId: string) => {
setSelectedSyncIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(syncId)) {
newSet.delete(syncId);
} else {
newSet.add(syncId);
}
return newSet;
});
};
const handleSaveChanges = async () => {
if (!certificateId) return;
try {
setIsSubmitting(true);
const syncsToAdd = Array.from(selectedSyncIds).filter((id) => !initialSyncIds.has(id));
const syncsToRemove = Array.from(initialSyncIds).filter((id) => !selectedSyncIds.has(id));
await Promise.all(
syncsToAdd.map((syncId) =>
addCertificatesToSync.mutateAsync({
pkiSyncId: syncId,
certificateIds: [certificateId]
})
)
);
await Promise.all(
syncsToRemove.map((syncId) =>
removeCertificatesFromSync.mutateAsync({
pkiSyncId: syncId,
certificateIds: [certificateId]
})
)
);
createNotification({
text: `PKI sync settings updated for certificate "${commonName}"`,
type: "success"
});
handleClose();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update PKI sync settings",
type: "error"
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={popUp.isOpen} onOpenChange={handleClose}>
<ModalContent
title="Manage PKI Syncs"
subTitle={`Select which PKI syncs "${commonName}" should be part of`}
className="max-w-3xl"
>
<div className="mb-4">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search PKI syncs by name..."
/>
</div>
<div className="mt-4 max-h-96 overflow-y-auto">
{isPending && (
<div className="flex h-32 items-center justify-center">
<div className="text-bunker-300">Loading PKI syncs...</div>
</div>
)}
{!isPending && pkiSyncs.length === 0 && (
<EmptyState title="No PKI syncs available" icon={faPlus}>
<div className="mt-1">
Create a{" "}
<button
type="button"
onClick={handleNavigateToPkiSyncs}
className="cursor-pointer underline hover:text-mineshaft-300"
>
PKI sync
</button>{" "}
first to manage certificate syncing.
</div>
</EmptyState>
)}
{!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && (
<EmptyState title="No PKI syncs found" icon={faSearch}>
<div className="mt-1">
No PKI syncs match your search criteria. Try a different search term.
</div>
</EmptyState>
)}
{!isPending && filteredSyncs.length > 0 && (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-12" />
<Th className="w-1/2">Name</Th>
<Th className="w-1/2">Destination</Th>
</Tr>
</THead>
<TBody>
{paginatedSyncs.map((sync) => (
<Tr
key={sync.id}
className="cursor-pointer hover:bg-mineshaft-700"
onClick={() => handleSyncToggle(sync.id)}
>
<Td>
<Checkbox
isChecked={selectedSyncIds.has(sync.id)}
onCheckedChange={() => handleSyncToggle(sync.id)}
id={`sync-${sync.id}`}
/>
</Td>
<Td className="w-1/2 max-w-0">
<div className="truncate" title={sync.name}>
{sync.name}
</div>
</Td>
<Td className="w-1/2 max-w-0">
<div
className="truncate capitalize"
title={getDestinationDisplayName(sync.destination)}
>
{getDestinationDisplayName(sync.destination)}
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
)}
{!isPending && filteredSyncs.length > PER_PAGE && (
<div className="mt-4">
<Pagination
count={filteredSyncs.length}
page={currentPage}
perPage={PER_PAGE}
onChangePage={setCurrentPage}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline_bg" onClick={handleClose} isDisabled={isSubmitting}>
Cancel
</Button>
<Button
variant="solid"
colorSchema="primary"
onClick={handleSaveChanges}
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Save Changes
</Button>
</div>
</ModalContent>
</Modal>
);
};

View File

@@ -97,7 +97,7 @@ export const CertificateTemplatesSection = ({ caId }: Props) => {
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan."
text="Your current plan does not include access to managing template enrollment options for EST. To unlock this feature, please upgrade to Infisical Enterprise plan."
/>
</div>
);

View File

@@ -7,8 +7,7 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProject,
useSubscription
useProject
} from "@app/context";
import { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -16,6 +15,7 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal";
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRenewalModal } from "./CertificateRenewalModal";
@@ -24,10 +24,10 @@ import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { mutateAsync: deleteCert } = useDeleteCert();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
// TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation
const isLegacyTemplatesEnabled = true;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificateIssuance",
@@ -37,7 +37,8 @@ export const CertificatesSection = () => {
"deleteCertificate",
"revokeCertificate",
"manageRenewal",
"renewCertificate"
"renewCertificate",
"managePkiSyncs"
] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => {
@@ -97,6 +98,10 @@ export const CertificatesSection = () => {
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateManagePkiSyncsModal
popUp={popUp.managePkiSyncs}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure you want to remove the certificate ${

View File

@@ -2,19 +2,24 @@ import { useMemo, useState } from "react";
import {
faBan,
faCertificate,
faClockRotateLeft,
faEllipsis,
faEye,
faFileExport,
faLink,
faRedo,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { CircleQuestionMarkIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,15 +41,14 @@ import { Badge } from "@app/components/v3";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProject,
useSubscription
useProject
} from "@app/context";
import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api";
import { 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 { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
@@ -56,93 +60,6 @@ const isExpiringWithinOneDay = (notAfter: string): boolean => {
return expiryDate <= oneDayFromNow;
};
const getAutoRenewalInfo = (certificate: TCertificate) => {
if (certificate.renewedByCertificateId) {
return { text: "Renewed", variant: "neutral" 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: "neutral" as const,
tooltip: "Renewal is not available for revoked certificates"
};
}
if (isExpired) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for expired certificates"
};
}
if (hasNoProfile) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal requires a certificate profile"
};
}
if (certificate.hasPrivateKey === false) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for certificates with externally generated private keys"
};
}
if (isExpiringWithinDay) {
return {
text: "Not Available",
variant: "neutral" 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: "warning" 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: "warning" as const };
}
if (daysUntilRenewal <= 7) {
return { text: `Renews in ${daysUntilRenewal}d`, variant: "warning" as const };
}
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
};
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
@@ -152,7 +69,8 @@ type Props = {
"revokeCertificate",
"certificateCert",
"manageRenewal",
"renewCertificate"
"renewCertificate",
"managePkiSyncs"
]
>,
data?: {
@@ -175,7 +93,6 @@ 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({
@@ -185,7 +102,8 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
});
const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
// TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation
const isLegacyTemplatesEnabled = true;
const { data: caData } = useListCasByProjectId(currentProject?.id ?? "");
@@ -225,20 +143,18 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Common Name</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th>Renewal Status</Th>
<Th />
<Th className="w-1/2">SAN / CN</Th>
<Th className="w-1/6">Status</Th>
<Th className="w-1/6">Not Before</Th>
<Th className="w-1/6">Not After</Th>
<Th className="w-12" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
{isPending && <TableSkeleton columns={4} 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();
@@ -247,9 +163,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const isAutoRenewalEnabled = Boolean(
certificate.renewBeforeDays && certificate.renewBeforeDays > 0
);
const canShowAutoRenewalIcon = Boolean(
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!isExpiringWithinDay
);
// Still need originalDisplayName for other uses in the component
const { originalDisplayName } = getCertificateDisplayName(certificate, 64, "—");
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td>
<Tr className="group h-10" key={`certificate-${certificate.id}`}>
<Td className="max-w-0">
<CertificateDisplayName cert={certificate} maxLength={64} fallback="—" />
</Td>
<Td>
{certificate.status === CertStatus.REVOKED ? (
<Badge variant="danger">Revoked</Badge>
@@ -267,22 +198,64 @@ 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">
<Tooltip content={autoRenewalInfo.tooltip}>
<Badge variant={autoRenewalInfo.variant}>
{autoRenewalInfo.text}
<CircleQuestionMarkIcon />
</Badge>
</Tooltip>
</div>
) : (
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge>
))}
</Td>
<Td className="flex justify-end">
<Td className="flex items-center justify-end gap-2">
<div
className={`transition-opacity ${(() => {
if (!canShowAutoRenewalIcon) return "";
if (isAutoRenewalEnabled) return "opacity-100";
return "opacity-0 group-hover:opacity-100";
})()}`}
>
{canShowAutoRenewalIcon && (
<Tooltip
content={(() => {
if (hasFailed && certificate.renewalError) {
return `Auto-renewal failed: ${certificate.renewalError}`;
}
if (isAutoRenewalEnabled) {
const expiryDate = new Date(certificate.notAfter);
const now = new Date();
const daysUntilExpiry = Math.ceil(
(expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
const daysUntilRenewal = Math.max(
0,
daysUntilExpiry - (certificate.renewBeforeDays || 0)
);
return `Auto-renews in ${daysUntilRenewal}d`;
}
return "Set auto renewal";
})()}
>
<button
type="button"
className={(() => {
if (hasFailed) return "pr-1 text-red-500 hover:text-red-400";
return "pr-1 text-primary-500 hover:text-primary-400";
})()}
aria-label="Certificate auto-renewal"
onClick={(e) => {
e.stopPropagation();
if (hasFailed) return;
handlePopUpOpen("manageRenewal", {
certificateId: certificate.id,
commonName: originalDisplayName,
profileId: certificate.profileId || "",
renewBeforeDays: certificate.renewBeforeDays || 7,
ttlDays: Math.ceil(
(new Date(certificate.notAfter).getTime() -
new Date(certificate.notBefore).getTime()) /
(24 * 60 * 60 * 1000)
)
});
}}
>
<FontAwesomeIcon icon={faClockRotateLeft} />
</button>
</Tooltip>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -476,6 +449,33 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
</ProjectPermissionCan>
);
})()}
{/* PKI Sync management - only for active certificates that are not renewed */}
{certificate.status === CertStatus.ACTIVE &&
!certificate.renewedByCertificateId && (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("managePkiSyncs", {
certificateId: certificate.id,
commonName: certificate.commonName
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faLink} />}
>
Manage PKI Syncs
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{/* Only show revoke button if CA supports revocation */}
{(() => {
const caType = caCapabilityMap[certificate.caId];

View File

@@ -1,19 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { CertificatesPage } from "./CertificatesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates"
)({
component: CertificatesPage,
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Certificates"
}
]
};
}
});

View File

@@ -12,7 +12,6 @@ import {
faToggleOff,
faToggleOn,
faTrash,
faTriangleExclamation,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -47,7 +46,6 @@ import { useToggle } from "@app/hooks";
import { PkiSyncStatus, TPkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { PkiSyncDestinationCol } from "./PkiSyncDestinationCol";
import { PkiSyncTableCell } from "./PkiSyncTableCell";
type Props = {
pkiSync: TPkiSync;
@@ -163,23 +161,6 @@ export const PkiSyncRow = ({
<p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p>
</div>
</Td>
{subscriberId ? (
<PkiSyncTableCell
primaryText={pkiSync.subscriber?.name || subscriberId}
secondaryText="PKI Subscriber"
/>
) : (
<Td>
<Tooltip content="The PKI subscriber for this sync has been deleted. Configure a new source or remove this sync.">
<div className="w-min">
<Badge variant="warning">
<FontAwesomeIcon icon={faTriangleExclamation} />
<span>Source Deleted</span>
</Badge>
</div>
</Tooltip>
</Td>
)}
<PkiSyncDestinationCol pkiSync={pkiSync} />
<Td>
<div className="flex items-center gap-1">

View File

@@ -57,7 +57,6 @@ import { PkiSyncRow } from "./PkiSyncRow";
enum PkiSyncsOrderBy {
Destination = "destination",
Source = "source",
Name = "name",
Status = "status"
}
@@ -160,14 +159,6 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case PkiSyncsOrderBy.Source:
return (syncOne.subscriber?.name ?? syncOne.subscriberId ?? "")
.toLowerCase()
.localeCompare(
syncTwo.subscriber?.name?.toLowerCase() ??
syncTwo.subscriberId?.toLowerCase() ??
""
);
case PkiSyncsOrderBy.Destination:
return getPkiSyncDestinationColValues(syncOne)
.primaryText.toLowerCase()
@@ -356,7 +347,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
<THead>
<Tr>
<Th className="w-2" />
<Th className="w-1/4">
<Th className="w-1/2">
<div className="flex items-center">
Name
<IconButton
@@ -369,20 +360,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Source
<IconButton
variant="plain"
className={getClassName(PkiSyncsOrderBy.Source)}
ariaLabel="sort"
onClick={() => handleSort(PkiSyncsOrderBy.Source)}
>
<FontAwesomeIcon icon={getColSortIcon(PkiSyncsOrderBy.Source)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<Th className="w-1/4">
<div className="flex items-center">
Destination
<IconButton
@@ -395,7 +373,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton>
</div>
</Th>
<Th className="min-w-42">
<Th className="w-1/4 min-w-42">
<div className="flex items-center">
Status
<IconButton

View File

@@ -12,13 +12,14 @@ import { ProjectPermissionSub, useProject } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useListPkiSyncs } from "@app/hooks/api/pkiSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import { PkiSyncsTable } from "./PkiSyncTable";
export const PkiSyncsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const);
const { addSync, ...search } = useSearch({
const { addSync, connectionId, connectionName, ...search } = useSearch({
from: ROUTE_PATHS.CertManager.IntegrationsListPage.id
});
@@ -45,6 +46,42 @@ export const PkiSyncsTab = () => {
navigateToBase();
}, [addSync, handlePopUpOpen, navigateToBase]);
useEffect(() => {
const storedFormData = localStorage.getItem("pkiSyncFormData");
if (storedFormData && !popUp.addSync.isOpen) {
try {
const parsedData = JSON.parse(storedFormData);
if (connectionId && connectionName) {
const initialData = {
...parsedData,
connection: { id: connectionId, name: connectionName }
};
handlePopUpOpen("addSync", { destination: parsedData.destination, initialData });
navigate({
to: ROUTE_PATHS.CertManager.IntegrationsListPage.path,
params: { projectId: currentProject?.id },
search: { selectedTab: IntegrationsListPageTabs.PkiSyncs },
replace: true
});
} else {
handlePopUpOpen("addSync", { destination: parsedData.destination });
}
localStorage.removeItem("pkiSyncFormData");
} catch (error) {
console.error("Failed to parse stored PKI sync form data:", error);
localStorage.removeItem("pkiSyncFormData");
handlePopUpOpen("addSync");
}
}
}, [
handlePopUpOpen,
popUp.addSync.isOpen,
connectionId,
connectionName,
navigate,
currentProject?.id
]);
const { data: pkiSyncs = [], isPending: isPkiSyncsPending } = useListPkiSyncs(
currentProject?.id || "",
{
@@ -94,7 +131,8 @@ export const PkiSyncsTab = () => {
<PkiSyncsTable pkiSyncs={pkiSyncs} />
</div>
<CreatePkiSyncModal
selectSync={popUp.addSync.data}
selectSync={popUp.addSync.data?.destination || popUp.addSync.data}
initialData={popUp.addSync.data?.initialData}
isOpen={popUp.addSync.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)}
/>

View File

@@ -9,7 +9,9 @@ import { IntegrationsListPage } from "./IntegrationsListPage";
const IntegrationsListPageQuerySchema = z.object({
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(),
addSync: z.nativeEnum(PkiSync).optional()
addSync: z.nativeEnum(PkiSync).optional(),
connectionId: z.string().optional(),
connectionName: z.string().optional()
});
export const Route = createFileRoute(

View File

@@ -58,9 +58,9 @@ export const PkiCollectionPage = () => {
});
handlePopUpClose("deletePkiCollection");
navigate({
to: "/projects/cert-management/$projectId/certificates",
to: "/projects/cert-management/$projectId/policies",
params: {
projectId
projectId: params.projectId
}
});
};
@@ -70,9 +70,9 @@ export const PkiCollectionPage = () => {
{data && (
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/certificates"
to="/projects/cert-management/$projectId/policies"
params={{
projectId
projectId: params.projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>

View File

@@ -5,13 +5,9 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useProject } from "@app/context";
import {
CaStatus,
useAddItemToPkiCollection,
useListWorkspaceCas,
useListWorkspaceCertificates
} from "@app/hooks/api";
import { CaStatus, useAddItemToPkiCollection, useListWorkspaceCas } from "@app/hooks/api";
import { PkiItemType, pkiItemTypeToNameMap } from "@app/hooks/api/pkiCollections/constants";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z

View File

@@ -13,7 +13,7 @@ export const Route = createFileRoute(
{
label: "Certificate Collections",
link: linkOptions({
to: "/projects/cert-management/$projectId/certificates",
to: "/projects/cert-management/$projectId/policies",
params: {
projectId: params.projectId
}

View File

@@ -350,8 +350,14 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
commonName,
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList,
keyUsages: keyUsagesList.map((key) =>
key === CertKeyUsage.CRL_SIGN
? "cRLSign"
: key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
),
extendedKeyUsages: extendedKeyUsagesList.map((key) =>
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
),
enableAutoRenewal,
autoRenewalPeriodInDays,
properties: Object.keys(properties).length > 0 ? properties : undefined
@@ -364,8 +370,14 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
commonName,
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList,
keyUsages: keyUsagesList.map((key) =>
key === CertKeyUsage.CRL_SIGN
? "cRLSign"
: key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
),
extendedKeyUsages: extendedKeyUsagesList.map((key) =>
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
),
enableAutoRenewal,
autoRenewalPeriodInDays,
properties: Object.keys(properties).length > 0 ? properties : undefined

View File

@@ -7,8 +7,7 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProject,
useSubscription
useProject
} from "@app/context";
import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api";
import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types";
@@ -19,10 +18,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const projectId = currentProject.id;
const canCreateLegacySubscribers = subscription.pkiLegacyTemplates;
// TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation
const canCreateLegacySubscribers = true;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();

View File

@@ -18,10 +18,10 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import {
PkiSyncActionTriggers,
PkiSyncAuditLogsSection,
PkiSyncCertificatesSection,
PkiSyncDestinationSection,
PkiSyncDetailsSection,
PkiSyncOptionsSection,
PkiSyncSourceSection
PkiSyncOptionsSection
} from "./components";
const PageContent = () => {
@@ -62,7 +62,6 @@ const PageContent = () => {
const destinationDetails = PKI_SYNC_MAP[pkiSync.destination];
const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details);
const handleEditSource = () => handlePopUpOpen("editSync", PkiSyncEditFields.Source);
const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options);
const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination);
@@ -103,7 +102,6 @@ const PageContent = () => {
<div className="flex justify-center">
<div className="mr-4 flex w-72 flex-col gap-4">
<PkiSyncDetailsSection pkiSync={pkiSync} onEditDetails={handleEditDetails} />
<PkiSyncSourceSection pkiSync={pkiSync} onEditSource={handleEditSource} />
<PkiSyncOptionsSection pkiSync={pkiSync} onEditOptions={handleEditOptions} />
</div>
<div className="flex flex-1 flex-col gap-4">
@@ -111,6 +109,7 @@ const PageContent = () => {
pkiSync={pkiSync}
onEditDestination={handleEditDestination}
/>
<PkiSyncCertificatesSection pkiSync={pkiSync} />
<PkiSyncAuditLogsSection pkiSync={pkiSync} />
</div>
</div>

View File

@@ -26,7 +26,7 @@ export const PkiSyncAuditLogsSection = ({ pkiSync }: Props) => {
return (
<div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Sync Logs</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Sync Logs</h3>
{subscription.auditLogs && (
<p className="text-xs text-bunker-300">
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days

View File

@@ -0,0 +1,328 @@
import { useState } from "react";
import { subject } from "@casl/ability";
import {
faCertificate,
faClockRotateLeft,
faEdit,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import {
DeleteActionModal,
EmptyState,
IconButton,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { useListPkiSyncCertificates, useRemoveCertificatesFromPkiSync } from "@app/hooks/api";
import { CertificateSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs";
type Props = {
pkiSync: TPkiSync;
};
const getSyncStatusVariant = (status?: CertificateSyncStatus | null) => {
if (status === CertificateSyncStatus.Succeeded) return "success";
if (status === CertificateSyncStatus.Failed) return "danger";
if (status === CertificateSyncStatus.Syncing) return "neutral";
return "project";
};
const getSyncStatusText = (status?: CertificateSyncStatus | null) => {
if (status === CertificateSyncStatus.Succeeded) return "Synced";
if (status === CertificateSyncStatus.Failed) return "Failed";
if (status === CertificateSyncStatus.Syncing) return "Syncing";
if (status === CertificateSyncStatus.Pending) return "Pending";
return "Unknown";
};
const getCertificateStatusVariant = (isExpired: boolean, isRevoked: boolean) => {
if (isRevoked) return "danger";
if (isExpired) return "danger";
return "success";
};
const getCertificateStatusText = (isExpired: boolean, isRevoked: boolean) => {
if (isRevoked) return "Revoked";
if (isExpired) return "Expired";
return "Active";
};
export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
const [isManageModalOpen, setIsManageModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [certificateToDelete, setCertificateToDelete] = useState<{
id: string;
displayName: string;
} | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
const { data, refetch: refetchSyncCertificates } = useListPkiSyncCertificates(pkiSync.id, {
offset: (currentPage - 1) * pageSize,
limit: pageSize
});
const syncCertificates = data?.certificates || [];
const totalCount = data?.totalCount || 0;
const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync();
const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, {
subscriberId: pkiSync.subscriberId || ""
});
const handleRemoveCertificate = async (certificateId: string) => {
try {
await removeCertificatesFromSync.mutateAsync({
pkiSyncId: pkiSync.id,
certificateIds: [certificateId]
});
await refetchSyncCertificates();
createNotification({
text: "Certificate removed from sync",
type: "success"
});
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
} catch {
createNotification({
text: "Failed to remove certificate from sync",
type: "error"
});
}
};
const handleDeleteClick = (certificateId: string, displayName: string) => {
setCertificateToDelete({ id: certificateId, displayName });
setIsDeleteModalOpen(true);
};
const totalPages = Math.ceil(totalCount / pageSize);
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">Certificates</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit certificates"
onClick={() => setIsManageModalOpen(true)}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-2/8">SAN / CN</Th>
<Th className="w-3/16">Certificate Status</Th>
<Th className="w-3/16">Serial Number</Th>
<Th className="w-1/8">Sync Status</Th>
<Th className="w-1/8">Expires At</Th>
<Th className="w-1/8" />
</Tr>
</THead>
<TBody>
{syncCertificates.map((syncCert) => {
const isExpired = syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter) < new Date()
: false;
const isRevoked = syncCert.certificateStatus === "revoked";
// Calculate auto-renewal timeline
const hasAutoRenewal = Boolean(
syncCert.certificateRenewBeforeDays &&
syncCert.certificateRenewBeforeDays > 0 &&
!syncCert.certificateRenewalError &&
syncCert.certificateNotAfter
);
const daysUntilRenewal =
hasAutoRenewal && syncCert.certificateNotAfter
? (() => {
const expiryDate = new Date(syncCert.certificateNotAfter);
const renewalDate = new Date(
expiryDate.getTime() -
syncCert.certificateRenewBeforeDays! * 24 * 60 * 60 * 1000
);
const now = new Date();
const diffInMs = renewalDate.getTime() - now.getTime();
return Math.max(0, Math.ceil(diffInMs / (24 * 60 * 60 * 1000)));
})()
: null;
const { originalDisplayName } = getCertificateDisplayName(
{
altNames: syncCert.certificateAltNames,
commonName: syncCert.certificateCommonName
},
34,
"Unknown"
);
return (
<Tr key={syncCert.id}>
<Td className="max-w-0">
<CertificateDisplayName
cert={{
altNames: syncCert.certificateAltNames,
commonName: syncCert.certificateCommonName
}}
maxLength={34}
fallback="Unknown"
/>
</Td>
<Td>
<Badge variant={getCertificateStatusVariant(isExpired, isRevoked)}>
{getCertificateStatusText(isExpired, isRevoked)}
</Badge>
</Td>
<Td className="max-w-0">
<div
className="truncate text-xs"
title={syncCert.certificateSerialNumber || "Unknown"}
>
{(() => {
const serial = syncCert.certificateSerialNumber;
if (!serial || serial === "Unknown") return "Unknown";
if (serial.length <= 8) return serial;
return `${serial.substring(0, 4)}...${serial.substring(serial.length - 4)}`;
})()}
</div>
</Td>
<Td>
{syncCert.lastSyncMessage &&
syncCert.syncStatus === CertificateSyncStatus.Failed ? (
<Tooltip content={syncCert.lastSyncMessage}>
<Badge variant="danger">Failed</Badge>
</Tooltip>
) : (
<Badge variant={getSyncStatusVariant(syncCert.syncStatus)}>
{getSyncStatusText(syncCert.syncStatus)}
</Badge>
)}
</Td>
<Td>
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter).toLocaleDateString()
: "Unknown"}
</span>
</Td>
<Td className="flex items-center justify-end gap-2 pr-4">
{hasAutoRenewal && daysUntilRenewal !== null && (
<Tooltip content={`Auto-renews in ${daysUntilRenewal}d`}>
<div className="text-primary-500">
<FontAwesomeIcon icon={faClockRotateLeft} size="sm" />
</div>
</Tooltip>
)}
<ProjectPermissionCan
I={ProjectPermissionPkiSyncActions.Edit}
a={permissionSubject}
>
{(isAllowed) => (
<IconButton
size="xs"
variant="plain"
colorSchema="danger"
ariaLabel="Remove certificate"
isDisabled={!isAllowed}
onClick={() =>
handleDeleteClick(syncCert.certificateId, originalDisplayName)
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{syncCertificates.length === 0 && (
<EmptyState
title="No certificates are part of this certificate sync"
icon={faCertificate}
/>
)}
</TableContainer>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
</div>
</div>
<CertificateManagementModal
pkiSync={pkiSync}
isOpen={isManageModalOpen}
onClose={() => setIsManageModalOpen(false)}
onCertificatesUpdated={() => {
refetchSyncCertificates();
}}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
}}
title="Remove Certificate from Sync"
subTitle={`Are you sure you want to remove "${certificateToDelete?.displayName}" from this PKI sync?`}
deleteKey="confirm"
onDeleteApproved={async () => {
if (certificateToDelete) {
await handleRemoveCertificate(certificateToDelete.id);
}
}}
buttonText="Remove Certificate"
/>
</div>
);
};

View File

@@ -11,12 +11,15 @@ import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionC
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
import { AzureKeyVaultPkiSyncDestinationSection } from "./PkiSyncDestinationSection/index";
import {
AwsCertificateManagerPkiSyncDestinationSection,
AzureKeyVaultPkiSyncDestinationSection
} from "./PkiSyncDestinationSection/index";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div>
<label className="text-sm text-bunker-300">{label}</label>
<div className="mt-1">{children}</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
@@ -32,6 +35,9 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
let DestinationComponents: ReactNode;
switch (destination) {
case PkiSync.AwsCertificateManager:
DestinationComponents = <AwsCertificateManagerPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.AzureKeyVault:
DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
@@ -47,7 +53,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Destination Configuration</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Destination Configuration</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
@@ -62,7 +68,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
)}
</ProjectPermissionCan>
</div>
<div className="flex w-full flex-wrap gap-8">
<div className="flex w-full flex-wrap gap-8 pt-2">
<GenericFieldLabel label={`${destinationDetails.name} Connection`}>
{pkiSync.appConnectionName || "Default Connection"}
</GenericFieldLabel>

View File

@@ -0,0 +1,21 @@
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
};
export const AwsCertificateManagerPkiSyncDestinationSection = ({ pkiSync }: Props) => {
const region =
pkiSync.destinationConfig && "region" in pkiSync.destinationConfig
? pkiSync.destinationConfig.region
: undefined;
return <GenericFieldLabel label="AWS Region">{region || "Not specified"}</GenericFieldLabel>;
};

View File

@@ -2,9 +2,9 @@
import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div>
<label className="text-sm text-bunker-300">{label}</label>
<div className="mt-1">{children}</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);

View File

@@ -1 +1,2 @@
export { AwsCertificateManagerPkiSyncDestinationSection } from "./AwsCertificateManagerPkiSyncDestinationSection";
export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection";

View File

@@ -21,9 +21,9 @@ const GenericFieldLabel = ({
children: React.ReactNode;
labelClassName?: string;
}) => (
<div>
<label className={`text-sm text-bunker-300 ${labelClassName || ""}`}>{label}</label>
<div className="mt-1">{children}</div>
<div className="mb-4">
<p className={`text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
@@ -57,7 +57,7 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Details</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
@@ -72,31 +72,27 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-3">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel>
<GenericFieldLabel label="Source Subscriber">
{subscriber ? subscriber.name : "Subscriber deleted"}
<div className="pt-2">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel>
{subscriber && (
<GenericFieldLabel label="Source Subscriber">{subscriber.name}</GenericFieldLabel>
)}
{syncStatus && (
<GenericFieldLabel label="Status">
<PkiSyncStatusBadge status={syncStatus} />
</GenericFieldLabel>
{syncStatus && (
<GenericFieldLabel label="Status">
<PkiSyncStatusBadge status={syncStatus} />
</GenericFieldLabel>
)}
{lastSyncedAt && (
<GenericFieldLabel label="Last Synced">
{format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")}
</GenericFieldLabel>
)}
{syncStatus === PkiSyncStatus.Failed && failureMessage && (
<GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
<p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">
{failureMessage}
</p>
</GenericFieldLabel>
)}
</div>
)}
{lastSyncedAt && (
<GenericFieldLabel label="Last Synced">
{format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")}
</GenericFieldLabel>
)}
{syncStatus === PkiSyncStatus.Failed && failureMessage && (
<GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
<p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">{failureMessage}</p>
</GenericFieldLabel>
)}
</div>
</div>
);

View File

@@ -3,13 +3,27 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({
label,
children,
labelClassName
}: {
label: string;
children: React.ReactNode;
labelClassName?: string;
}) => (
<div className="mb-4">
<p className={`text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
onEditOptions: VoidFunction;
@@ -28,7 +42,7 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Sync Options</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Sync Options</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
@@ -43,21 +57,19 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-3">
{/* Hidden for now - Import certificates functionality disabled
<div className="pt-1">
{/* Hidden for now - Import certificates functionality disabled
<GenericFieldLabel label="Certificate Import">
<Badge variant={canImportCertificates ? "success" : "danger"}>
{canImportCertificates ? "Enabled" : "Disabled"}
</Badge>
</GenericFieldLabel>
*/}
<GenericFieldLabel label="Certificate Removal">
<Badge variant={canRemoveCertificates ? "success" : "danger"}>
{canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</GenericFieldLabel>
</div>
<GenericFieldLabel label="Inactive Certificate Removal" labelClassName="mb-1">
<Badge variant={canRemoveCertificates ? "success" : "danger"}>
{canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</GenericFieldLabel>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
export { PkiSyncActionTriggers } from "./PkiSyncActionTriggers";
export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection";
export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection";
export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection";
export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection";
export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection";

View File

@@ -106,30 +106,29 @@ export const PkiTemplateListPage = () => {
/>
</div>
<div className="container mx-auto mb-6 max-w-8xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
{subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
</div>
{/* TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation */}
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
</div>
)}
</div>
<TableContainer>
<Table>
<THead>
@@ -289,7 +288,7 @@ export const PkiTemplateListPage = () => {
<UpgradePlanModal
isOpen={popUp.estUpgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("estUpgradePlan", isOpen)}
text="You can only configure template enrollment methods if you switch to Infisical's Enterprise plan."
text="Your current plan does not include access to configuring template enrollment methods. To unlock this feature, please upgrade to Infisical Enterprise plan."
isEnterpriseFeature={popUp.estUpgradePlan.data?.isEnterpriseFeature}
/>
</>

View File

@@ -139,10 +139,14 @@ export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => {
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
.map(([key]) =>
key === CertKeyUsage.CRL_SIGN
? "cRLSign"
: key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
.map(([key]) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()))
});
createNotification({
@@ -159,10 +163,14 @@ export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => {
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
.map(([key]) =>
key === CertKeyUsage.CRL_SIGN
? "cRLSign"
: key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
.map(([key]) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()))
});
createNotification({

View File

@@ -2,17 +2,20 @@ import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { useProject } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
import { CertificatesTab } from "./components/CertificatesTab";
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
import { PkiCollectionsTab } from "./components/PkiCollectionsTab";
enum TabSections {
CertificateProfiles = "profiles",
CertificateTemplatesV2 = "templates-v2"
CertificateTemplatesV2 = "templates-v2",
Certificates = "certificates",
PkiCollections = "pki-collections"
}
export const PoliciesPage = () => {
@@ -25,59 +28,54 @@ export const PoliciesPage = () => {
}
return (
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
if (!isAllowed) {
return (
<div className="mx-auto flex h-full flex-col justify-center bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl text-center">
<p>You don&apos;t have permission to access certificate policies.</p>
</div>
</div>
);
}
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Management" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificate Management"
description="Streamline certificate management by creating and maintaining templates, profiles, and certificates in one place"
/>
return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Policies" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificate Policies"
description="Manage certificate templates and profiles for unified certificate issuance"
/>
<Tabs
orientation="vertical"
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.CertificateProfiles}>
Certificate Profiles
</Tab>
<Tab variant="project" value={TabSections.CertificateTemplatesV2}>
Certificate Templates
</Tab>
<Tab variant="project" value={TabSections.Certificates}>
Certificates
</Tab>
<Tab variant="project" value={TabSections.PkiCollections}>
Certificate Collections
</Tab>
</TabList>
<Tabs
orientation="vertical"
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.CertificateProfiles}>
Certificate Profiles
</Tab>
<Tab variant="project" value={TabSections.CertificateTemplatesV2}>
Certificate Templates
</Tab>
</TabList>
<TabPanel value={TabSections.CertificateProfiles}>
<CertificateProfilesTab />
</TabPanel>
<TabPanel value={TabSections.CertificateProfiles}>
<CertificateProfilesTab />
</TabPanel>
<TabPanel value={TabSections.CertificateTemplatesV2}>
<CertificateTemplatesV2Tab />
</TabPanel>
<TabPanel value={TabSections.CertificateTemplatesV2}>
<CertificateTemplatesV2Tab />
</TabPanel>
</Tabs>
</div>
</div>
);
}}
</ProjectPermissionCan>
<TabPanel value={TabSections.Certificates}>
<CertificatesTab />
</TabPanel>
<TabPanel value={TabSections.PkiCollections}>
<PkiCollectionsTab />
</TabPanel>
</Tabs>
</div>
</div>
);
};

View File

@@ -410,7 +410,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="enrollmentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Enrollment Type"
label="Enrollment Method"
isRequired
isError={Boolean(error)}
errorText={error?.message}

View File

@@ -29,8 +29,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
projectId: currentProject?.id || "",
limit: 100,
offset: 0,
includeConfigs: true,
includeMetrics: true
includeConfigs: true
});
const profiles = data?.certificateProfiles || [];
@@ -42,10 +41,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Enrollment Method</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
@@ -67,10 +65,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Enrollment Method</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>

View File

@@ -33,43 +33,6 @@ import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
const MetricsBadges = ({
metrics
}: {
metrics?: {
totalCertificates: number;
activeCertificates: number;
expiringCertificates: number;
expiredCertificates: number;
revokedCertificates: number;
};
}) => {
if (!metrics) {
return <Badge variant="warning">No metrics</Badge>;
}
if (metrics.totalCertificates === 0) {
return <Badge variant="warning">No certificates</Badge>;
}
return (
<>
{metrics.activeCertificates > 0 && (
<Badge variant="success">{metrics.activeCertificates} active</Badge>
)}
{metrics.expiringCertificates > 0 && (
<Badge variant="warning">{metrics.expiringCertificates} expiring</Badge>
)}
{metrics.expiredCertificates > 0 && (
<Badge variant="danger">{metrics.expiredCertificates} expired</Badge>
)}
{metrics.revokedCertificates > 0 && (
<Badge variant="danger">{metrics.revokedCertificates} revoked</Badge>
)}
</>
);
};
interface Props {
profile: TCertificateProfile;
onEditProfile: (profile: TCertificateProfile) => void;
@@ -118,8 +81,8 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const getEnrollmentTypeBadge = (enrollmentType: string) => {
const config = {
api: { variant: "success" as const, label: "API" },
est: { variant: "warning" as const, label: "EST" }
api: { variant: "ghost" as const, label: "API" },
est: { variant: "ghost" as const, label: "EST" }
} as const;
const configKey = Object.keys(config).includes(enrollmentType)
@@ -153,11 +116,6 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
{templateData?.name || profile.certificateTemplateId}
</span>
</Td>
<Td>
<div className="flex flex-wrap gap-1">
<MetricsBadges metrics={profile.metrics} />
</div>
</Td>
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">

View File

@@ -0,0 +1,5 @@
import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection";
export const CertificatesTab = () => {
return <CertificatesSection />;
};

View File

@@ -0,0 +1 @@
export { CertificatesTab } from "./CertificatesTab";

View File

@@ -0,0 +1,5 @@
import { PkiCollectionSection } from "../../../AlertingPage/components/PkiCollectionSection";
export const PkiCollectionsTab = () => {
return <PkiCollectionSection />;
};

View File

@@ -0,0 +1 @@
export { PkiCollectionsTab } from "./PkiCollectionsTab";

View File

@@ -1,2 +1,4 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CertificatesTab } from "./CertificatesTab";
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
export { PkiCollectionsTab } from "./PkiCollectionsTab";

Some files were not shown because too many files have changed in this diff Show More