PKI Syncs revamp

This commit is contained in:
Carlos Monastyrski
2025-10-29 13:01:27 -03:00
parent 3e83ae95d1
commit 39eecb328d
64 changed files with 2799 additions and 350 deletions
@@ -0,0 +1,434 @@
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 {
Badge,
Button,
Checkbox,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
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>
{allCertificates.length === 0 ? (
<EmptyState title="No certificates found">
{searchTerm
? "No certificates match your search criteria."
: "No certificates available for sync."}
</EmptyState>
) : (
<>
<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">Common Name</Th>
<Th className="w-1/3">Serial Number</Th>
<Th className="w-1/6">Status</Th>
<Th className="w-2/6">Expires</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);
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">
<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">
<div className="truncate" title={cert.commonName}>
{cert.commonName}
</div>
</Td>
<Td className="max-w-0">
<div
className="truncate font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{cert.serialNumber}
</div>
</Td>
<Td className="max-w-0">
<Badge
variant={
cert.status === CertStatus.ACTIVE && !isExpired
? "success"
: "danger"
}
>
{(() => {
if (isRevoked) return "Revoked";
if (isExpired) return "Expired";
return cert.status === CertStatus.ACTIVE ? "Active" : cert.status;
})()}
</Badge>
</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>
</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>
);
};
@@ -13,11 +13,11 @@ 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;
@@ -26,10 +26,10 @@ type Props = {
};
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: [] }
];
@@ -49,34 +49,46 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
defaultValues: {
destination,
isAutoSyncEnabled: false,
certificateIds: [],
syncOptions: {
canImportCertificates: false,
canRemoveCertificates: false,
preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema
}
} 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);
} catch (err: Error | unknown) {
console.error(err);
console.error("PKI sync creation failed:", err);
setShowConfirmation(false);
createNotification({
title: `Failed to add ${destinationName} Certificate Sync`,
title: `Failed to create ${destinationName} Certificate Sync`,
text: err instanceof Error ? err.message : "An unknown error occurred",
type: "error"
});
@@ -184,9 +196,6 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<PkiSyncSourceFields />
</Tab.Panel>
<Tab.Panel>
<PkiSyncDestinationFields />
</Tab.Panel>
@@ -200,8 +209,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}
@@ -223,6 +232,9 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
<Tab.Panel>
<PkiSyncDetailsFields />
</Tab.Panel>
<Tab.Panel>
<PkiSyncCertificatesFields />
</Tab.Panel>
<Tab.Panel>
<PkiSyncReviewFields />
</Tab.Panel>
@@ -0,0 +1,139 @@
import { useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
FormControl,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
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>
{selectedCertificates.length === 0 ? (
<EmptyState title="No certificates selected" icon={faPlus} />
) : (
<div className="max-h-64 overflow-y-auto">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-2/5">Common Name</Th>
<Th className="w-2/5">Serial Number</Th>
<Th className="w-1/5">Remove</Th>
</Tr>
</THead>
<TBody>
{selectedCertificates.map((cert) => (
<Tr key={cert.id}>
<Td className="max-w-xs truncate">{cert.commonName}</Td>
<Td className="font-mono text-xs text-bunker-300">
{cert.serialNumber}
</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>
</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"
/>
</>
);
};
@@ -95,6 +95,49 @@ 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>
)}
/>
)}
<Controller
control={control}
name="syncOptions.certificateNameSchema"
@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form";
import { Badge, GenericFieldLabel } from "@app/components/v2";
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";
@@ -11,18 +11,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,
@@ -30,17 +36,28 @@ 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>
{selectedCertificates.length === 0 ? (
<span className="text-bunker-400">No certificates selected</span>
) : (
<div className="space-y-1">
{selectedCertificates.map((cert) => (
<div key={cert.id} className="text-sm">
{cert.commonName}
</div>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-3">
@@ -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()
@@ -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")
@@ -1,3 +1,4 @@
export { CertStatus } from "./enums";
export {
useDeleteCert,
useImportCertificate,
+1
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";
+2 -2
View File
@@ -1,9 +1,9 @@
import { PamResourceType, PamSessionStatus } from "../enums";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
export * from "./postgres-resource";
export * from "./mysql-resource";
export * from "./postgres-resource";
export type TPamResource = TPostgresResource | TMySQLResource;
+7
View File
@@ -9,3 +9,10 @@ export enum PkiSyncStatus {
Succeeded = "succeeded",
Failed = "failed"
}
export enum CertificateSyncStatus {
Pending = "pending",
Syncing = "syncing",
Succeeded = "succeeded",
Failed = "failed"
}
@@ -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) });
}
});
};
+71 -4
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
});
};
@@ -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,23 @@ 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;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
pkiSyncName?: string;
pkiSyncDestination?: string;
};
@@ -33,7 +33,8 @@ type TCreatePkiSyncDTOBase = {
certificateNameSchema?: string;
};
isAutoSyncEnabled: boolean;
subscriberId?: string;
subscriberId?: string | null;
certificateIds?: string[];
projectId: string;
};
+23 -3
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
});
};
+12 -2
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,
@@ -51,25 +51,9 @@ export const PkiManagerLayout = () => {
params={{
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>
<Tab value={isActive ? "selected" : ""}>Certificate Management</Tab>
)}
</Link>
<Link
@@ -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>
);
};
@@ -0,0 +1,253 @@
import { useEffect, useMemo, useState } from "react";
import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons";
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 { useProject } from "@app/context";
import {
useAddCertificatesToPkiSync,
useListPkiSyncsWithCertificate,
useRemoveCertificatesFromPkiSync
} from "@app/hooks/api/pkiSyncs";
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 { 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);
};
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="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}>
Create a PKI sync first to manage certificate syncing.
</EmptyState>
)}
{!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && (
<EmptyState title="No PKI syncs found" icon={faSearch}>
No PKI syncs match your search criteria. Try a different search term.
</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={sync.destination.replace(/-/g, " ")}
>
{sync.destination.replace(/-/g, " ")}
</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>
);
};
@@ -16,6 +16,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";
@@ -37,7 +38,8 @@ export const CertificatesSection = () => {
"deleteCertificate",
"revokeCertificate",
"manageRenewal",
"renewCertificate"
"renewCertificate",
"managePkiSyncs"
] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => {
@@ -105,6 +107,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 ${
@@ -5,6 +5,7 @@ import {
faEllipsis,
faEye,
faFileExport,
faLink,
faQuestionCircle,
faRedo,
faTrash
@@ -39,12 +40,13 @@ import {
useProject,
useSubscription
} 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";
@@ -152,7 +154,8 @@ type Props = {
"revokeCertificate",
"certificateCert",
"manageRenewal",
"renewCertificate"
"renewCertificate",
"managePkiSyncs"
]
>,
data?: {
@@ -488,6 +491,31 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
</ProjectPermissionCan>
);
})()}
{/* PKI Sync management - only for active certificates */}
{certificate.status === CertStatus.ACTIVE && (
<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} />}
>
PKI Syncs
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{/* Only show revoke button if CA supports revocation */}
{(() => {
const caType = caCapabilityMap[certificate.caId];
@@ -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"
}
]
};
}
});
@@ -59,9 +59,9 @@ export const PkiCollectionPage = () => {
});
handlePopUpClose("deletePkiCollection");
navigate({
to: "/projects/cert-management/$projectId/certificates",
to: "/projects/cert-management/$projectId/policies",
params: {
projectId
projectId: params.projectId
}
});
} catch {
@@ -77,9 +77,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"
>
@@ -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
@@ -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
}
@@ -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>
@@ -0,0 +1,223 @@
import { useState } from "react";
import { subject } from "@casl/ability";
import { faEdit, faPlus, 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 {
Badge,
EmptyState,
IconButton,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
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 "primary";
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";
};
export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
const [isManageModalOpen, setIsManageModalOpen] = useState(false);
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"
});
} catch {
createNotification({
text: "Failed to remove certificate from sync",
type: "error"
});
}
};
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="font-medium text-mineshaft-100">Certificates ({totalCount})</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>
{syncCertificates.length === 0 ? (
<EmptyState title="No certificates" icon={faPlus}>
No certificates are currently synced with this PKI destination.
</EmptyState>
) : (
<div className="space-y-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/3">Common Name</Th>
<Th className="w-1/3">Serial Number</Th>
<Th className="w-1/9">Status</Th>
<Th className="w-1/9">Expires</Th>
<Th className="w-1/9">Actions</Th>
</Tr>
</THead>
<TBody>
{syncCertificates.map((syncCert) => {
const isExpired = syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter) < new Date()
: false;
return (
<Tr key={syncCert.id}>
<Td className="max-w-0">
<div
className="truncate"
title={syncCert.certificateCommonName || "Unknown"}
>
{syncCert.certificateCommonName || "Unknown"}
</div>
</Td>
<Td className="max-w-0">
<div
className="truncate text-xs"
title={syncCert.certificateSerialNumber || "Unknown"}
>
{syncCert.certificateSerialNumber || "Unknown"}
</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">
<ProjectPermissionCan
I={ProjectPermissionPkiSyncActions.Edit}
a={permissionSubject}
>
{(isAllowed) => (
<IconButton
size="xs"
variant="plain"
colorSchema="danger"
ariaLabel="Remove certificate"
isDisabled={!isAllowed}
onClick={() => handleRemoveCertificate(syncCert.certificateId)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</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();
}}
/>
</div>
);
};
@@ -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";
@@ -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="Manage certificate templates, profiles, certificates, and PKI collections for unified certificate issuance"
/>
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>
);
};
@@ -0,0 +1,5 @@
import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection";
export const CertificatesTab = () => {
return <CertificatesSection />;
};
@@ -0,0 +1 @@
export { CertificatesTab } from "./CertificatesTab";
@@ -0,0 +1,5 @@
import { PkiCollectionSection } from "../../../AlertingPage/components/PkiCollectionSection";
export const PkiCollectionsTab = () => {
return <PkiCollectionSection />;
};
@@ -0,0 +1 @@
export { PkiCollectionsTab } from "./PkiCollectionsTab";
@@ -1,2 +1,4 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CertificatesTab } from "./CertificatesTab";
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
export { PkiCollectionsTab } from "./PkiCollectionsTab";
@@ -1,14 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TMySQLAccount } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
type Props = {
account?: TMySQLAccount;
@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
@@ -12,10 +12,10 @@ import {
} from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
type Props = {
account?: TPostgresAccount;
-20
View File
@@ -114,7 +114,6 @@ import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/ro
import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route'
import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route'
import { Route as certManagerPoliciesPageRouteImport } from './pages/cert-manager/PoliciesPage/route'
import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route'
import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route'
import { Route as certManagerAlertingPageRouteImport } from './pages/cert-manager/AlertingPage/route'
import { Route as organizationAppConnectionsOauthCallbackPageRouteImport } from './pages/organization/AppConnections/OauthCallbackPage/route'
@@ -1203,13 +1202,6 @@ const certManagerPoliciesPageRouteRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
const certManagerCertificatesPageRouteRoute =
certManagerCertificatesPageRouteImport.update({
id: '/certificates',
path: '/certificates',
getParentRoute: () => certManagerLayoutRoute,
} as any)
const certManagerCertificateAuthoritiesPageRouteRoute =
certManagerCertificateAuthoritiesPageRouteImport.update({
id: '/certificate-authorities',
@@ -2786,13 +2778,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof certManagerCertificateAuthoritiesPageRouteImport
parentRoute: typeof certManagerLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': {
id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates'
path: '/certificates'
fullPath: '/projects/cert-management/$projectId/certificates'
preLoaderRoute: typeof certManagerCertificatesPageRouteImport
parentRoute: typeof certManagerLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': {
id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies'
path: '/policies'
@@ -4126,7 +4111,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdCertMa
interface certManagerLayoutRouteChildren {
certManagerAlertingPageRouteRoute: typeof certManagerAlertingPageRouteRoute
certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute
certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute
certManagerPoliciesPageRouteRoute: typeof certManagerPoliciesPageRouteRoute
certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute
projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute
@@ -4147,7 +4131,6 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = {
certManagerAlertingPageRouteRoute: certManagerAlertingPageRouteRoute,
certManagerCertificateAuthoritiesPageRouteRoute:
certManagerCertificateAuthoritiesPageRouteRoute,
certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute,
certManagerPoliciesPageRouteRoute: certManagerPoliciesPageRouteRoute,
certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute,
projectAccessControlPageRouteCertManagerRoute:
@@ -5061,7 +5044,6 @@ export interface FileRoutesByFullPath {
'/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute
'/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute
'/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute
'/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute
'/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
@@ -5294,7 +5276,6 @@ export interface FileRoutesByTo {
'/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute
'/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute
'/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute
'/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute
'/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
@@ -5538,7 +5519,6 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting': typeof certManagerAlertingPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': typeof certManagerCertificatesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': typeof certManagerPoliciesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute