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

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;