Improve referenced secret deletion message component

This commit is contained in:
carlosmonastyrski
2025-04-10 13:08:52 -03:00
parent 8ee2b54182
commit f15b30ff85
6 changed files with 129 additions and 158 deletions
@@ -578,6 +578,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
importedBy: z importedBy: z
.object({ .object({
envName: z.string(), envName: z.string(),
envSlug: z.string(),
folders: z folders: z
.object({ .object({
folderName: z.string(), folderName: z.string(),
@@ -823,7 +823,7 @@ export const secretImportServiceFactory = ({
}); });
const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId); const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId);
console.log({ importedBy });
const deepPaths: { path: string; folderId: string }[] = []; const deepPaths: { path: string; folderId: string }[] = [];
await Promise.all( await Promise.all(
@@ -16,7 +16,9 @@ type Props = {
subTitle?: string; subTitle?: string;
onDeleteApproved: () => Promise<void>; onDeleteApproved: () => Promise<void>;
buttonText?: string; buttonText?: string;
formContent?: ReactNode;
children?: ReactNode; children?: ReactNode;
deletionMessage?: ReactNode;
}; };
export const DeleteActionModal = ({ export const DeleteActionModal = ({
@@ -28,6 +30,8 @@ export const DeleteActionModal = ({
title, title,
subTitle = "This action is irreversible.", subTitle = "This action is irreversible.",
buttonText = "Delete", buttonText = "Delete",
formContent,
deletionMessage,
children children
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [inputData, setInputData] = useState(""); const [inputData, setInputData] = useState("");
@@ -79,6 +83,7 @@ export const DeleteActionModal = ({
} }
onClose={onClose} onClose={onClose}
> >
{formContent}
<form <form
onSubmit={(evt) => { onSubmit={(evt) => {
evt.preventDefault(); evt.preventDefault();
@@ -88,7 +93,11 @@ export const DeleteActionModal = ({
<FormControl <FormControl
label={ label={
<div className="break-words pb-2 text-sm"> <div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to perform this action {deletionMessage || (
<>
Type <span className="font-bold">{deleteKey}</span> to perform this action
</>
)}
</div> </div>
} }
className="mb-0" className="mb-0"
@@ -43,6 +43,7 @@ export type DashboardProjectSecretsDetailsResponse = {
totalCount: number; totalCount: number;
importedBy?: { importedBy?: {
envName: string; envName: string;
envSlug: string;
folders: { folderName: string; secrets?: string[]; folderImported: boolean }[]; folders: { folderName: string; secrets?: string[]; folderImported: boolean }[];
}[]; }[];
}; };
@@ -1,24 +1,24 @@
import React, { useMemo, useState } from "react"; import React, { useMemo } from "react";
import { import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons";
faChevronLeft,
faChevronRight,
faFolder,
faKey,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Table, TBody, Td, Th, THead, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
interface FlatItem { interface FlatItem {
type: "folder" | "secret"; type: "folder" | "secret";
path: string; path: string;
secretKey?: string; secretKey?: string;
reference: string; reference: string;
id: string; id: string;
envName: string;
envSlug: string;
} }
interface CollapsibleSecretImportsProps { interface CollapsibleSecretImportsProps {
importedBy?: { importedBy?: {
envName: string; envName: string;
envSlug: string;
folders: { folderName: string; secrets?: string[]; folderImported: boolean }[]; folders: { folderName: string; secrets?: string[]; folderImported: boolean }[];
}[]; }[];
} }
@@ -26,22 +26,49 @@ interface CollapsibleSecretImportsProps {
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
importedBy = [] importedBy = []
}) => { }) => {
const [currentEnvIndex, setCurrentEnvIndex] = useState(0); const { currentWorkspace } = useWorkspace();
const truncatePath = (path: string, maxLength = 45): string => const truncatePath = (path: string, maxLength = 24): string => {
path.length <= maxLength ? path : `...${path.slice(-(maxLength - 3))}`; if (path.length <= maxLength) return path;
const groupedItems = useMemo(() => { const endPortion = path.slice(-(maxLength - 3));
return importedBy.map((env) => { const slashIndex = endPortion.indexOf("/");
const envItems: FlatItem[] = [];
if (slashIndex !== -1) {
return `...${endPortion.slice(slashIndex)}`;
}
return `...${endPortion}`;
};
const handlePathClick = (item: FlatItem) => {
let pathToNavigate;
if (item.type === "folder") {
pathToNavigate = item.path;
} else {
const lastSlashIndex = item.path.lastIndexOf("/");
pathToNavigate = lastSlashIndex > 0 ? item.path.substring(0, lastSlashIndex) : "/";
}
const encodedPath = encodeURIComponent(pathToNavigate);
window.open(
`/secret-manager/${currentWorkspace.id}/secrets/${item.envSlug}?secretPath=${encodedPath}`,
"_blank"
);
};
const flattenedItems = useMemo(() => {
const items: FlatItem[] = [];
importedBy.forEach((env) => {
env.folders.forEach((folder) => { env.folders.forEach((folder) => {
if (folder.folderImported) { if (folder.folderImported) {
envItems.push({ items.push({
type: "folder", type: "folder",
path: folder.folderName, path: folder.folderName,
id: `folder-${env.envName}-${folder.folderName}`, id: `folder-${env.envName}-${folder.folderName}`,
reference: "Imported" reference: "Imported",
envName: env.envName,
envSlug: env.envSlug
}); });
} }
@@ -50,160 +77,83 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
const secretPath = const secretPath =
folder.folderName === "/" ? `/${secret}` : `${folder.folderName}/${secret}`; folder.folderName === "/" ? `/${secret}` : `${folder.folderName}/${secret}`;
envItems.push({ items.push({
type: "secret", type: "secret",
path: secretPath, path: secretPath,
secretKey: secret, secretKey: secret,
id: `secret-${env.envName}-${secretPath}`, id: `secret-${env.envName}-${secretPath}`,
reference: "Referenced" reference: "Referenced",
envName: env.envName,
envSlug: env.envSlug
}); });
}); });
} }
}); });
});
return { return items.sort((a, b) => {
envName: env.envName, const envCompare = a.envName.localeCompare(b.envName);
items: envItems.sort((a, b) => { if (envCompare !== 0) return envCompare;
const aPath = a.path.startsWith("/") ? a.path : `/${a.path}`;
const bPath = b.path.startsWith("/") ? b.path : `/${b.path}`;
const aSegments = aPath.split("/").filter(Boolean).length; const aPath = a.path.startsWith("/") ? a.path : `/${a.path}`;
const bSegments = bPath.split("/").filter(Boolean).length; const bPath = b.path.startsWith("/") ? b.path : `/${b.path}`;
if (aSegments !== bSegments) { const aSegments = aPath.split("/").filter(Boolean).length;
return aSegments - bSegments; const bSegments = bPath.split("/").filter(Boolean).length;
}
return aPath.localeCompare(bPath); if (aSegments !== bSegments) {
}) return aSegments - bSegments;
}; }
return aPath.localeCompare(bPath);
}); });
}, [importedBy]); }, [importedBy]);
const totalEnvironments = groupedItems.length;
const currentItems = totalEnvironments > 0 ? groupedItems[currentEnvIndex]?.items || [] : [];
const handlePrevEnvironment = () => {
if (currentEnvIndex > 0) {
setCurrentEnvIndex(currentEnvIndex - 1);
}
};
const handleNextEnvironment = () => {
if (currentEnvIndex < totalEnvironments - 1) {
setCurrentEnvIndex(currentEnvIndex + 1);
}
};
const renderPaginationButton = (
direction: "prev" | "next",
onClick: () => void,
disabled: boolean
) => {
const icon = direction === "prev" ? faChevronLeft : faChevronRight;
const label = direction === "prev" ? "Previous environment" : "Next environment";
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-md border p-2 ${
disabled
? "border-mineshaft-700 text-gray-600"
: "border-mineshaft-600 text-gray-400 hover:bg-mineshaft-800"
}`}
aria-label={label}
>
<FontAwesomeIcon icon={icon} className="h-4 w-4" aria-hidden="true" />
</button>
);
};
return ( return (
<div className="mt-4 overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-900 shadow-lg"> <div className="mb-4 w-full">
<div className="p-4"> <div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20">
<div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20"> <div className="flex items-start gap-3 p-4">
<div className="flex items-start gap-3 p-4"> <div className="mt-0.5 flex-shrink-0 text-red-500">
<div className="mt-0.5 flex-shrink-0 text-red-500"> <FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" />
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" /> </div>
</div> <div className="w-full">
<div className="w-full"> <p className="text-sm font-semibold text-red-500">
<p className="text-sm font-semibold text-red-500"> The following resources will be affected by this change
Warning: The following resources will be affected by this change </p>
</p>
<ul className="mt-2 list-disc space-y-2 pl-5 text-xs text-gray-300">
<li>Deleting will remove it from all folders where it&apos;s imported</li>
<li>
Any secrets referencing this will display their reference syntax (like{" "}
<code className="rounded px-1 py-0.5 font-mono text-red-500">
{"{env.secretPath.key}"}
</code>
) instead of actual values
</li>
<li>Secrets referencing this will not be automatically deleted</li>
</ul>
</div>
</div> </div>
</div> </div>
</div>
{totalEnvironments > 0 && ( <div className="max-h-64 overflow-y-auto rounded-md border border-mineshaft-700">
<div className="mb-2 px-1"> <Table>
<h3 className="text-md font-medium text-gray-200"> <THead className="sticky -top-1 bg-bunker-800">
Environment: {groupedItems[currentEnvIndex]?.envName} <Th className="px-4">Type</Th>
</h3> <Th className="px-4">Environment</Th>
</div> <Th className="truncate px-4">Path</Th>
)} <Th className="px-4">Usage</Th>
</THead>
<div className="h-48 overflow-y-auto rounded-md border border-mineshaft-700"> <TBody>
<table className="w-full"> {flattenedItems.map((item) => (
<thead className="border-b border-mineshaft-700 bg-mineshaft-800"> <Tr
<tr> key={item.id}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Type</th> onClick={() => handlePathClick(item)}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Path</th> className="cursor-pointer hover:bg-mineshaft-700"
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Usage</th> title={`Navigate to ${item.path}`}
</tr> >
</thead> <Td>
<tbody className="divide-y divide-mineshaft-700"> <FontAwesomeIcon
{currentItems.map((item) => ( icon={item.type === "secret" ? faKey : faFileImport}
<tr key={item.id} className="hover:bg-mineshaft-800"> className={`h-4 w-4 ${item.type === "secret" ? "text-gray-400" : "text-green-700"}`}
<td className="px-4 py-3"> aria-hidden="true"
<FontAwesomeIcon />
icon={item.type === "secret" ? faKey : faFolder} </Td>
className={`h-4 w-4 ${item.type === "secret" ? "text-gray-400" : "text-yellow-500"}`} <Td className="px-4">{item.envName}</Td>
aria-hidden="true" <Td className="truncate px-4">{truncatePath(item.path)}</Td>
/> <Td className="px-4">{item.reference}</Td>
</td> </Tr>
<td className="px-4 py-3 text-sm text-gray-300">{truncatePath(item.path)}</td> ))}
<td className="px-4 py-3 text-sm text-gray-400">{item.reference}</td> </TBody>
</tr> </Table>
))}
{currentItems.length === 0 && (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-sm text-gray-500">
No items in this environment
</td>
</tr>
)}
</tbody>
</table>
</div>
{totalEnvironments > 1 && (
<div className="mt-4 flex items-center justify-between px-4">
<div className="text-sm text-gray-400">
Environment {currentEnvIndex + 1} of {totalEnvironments}
</div>
<div className="flex space-x-2">
{renderPaginationButton("prev", handlePrevEnvironment, currentEnvIndex === 0)}
{renderPaginationButton(
"next",
handleNextEnvironment,
currentEnvIndex === totalEnvironments - 1
)}
</div>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -31,6 +31,7 @@ type Props = {
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
importedBy?: { importedBy?: {
envName: string; envName: string;
envSlug: string;
folders: { folderName: string; secrets?: string[]; folderImported: boolean }[]; folders: { folderName: string; secrets?: string[]; folderImported: boolean }[];
}[]; }[];
}; };
@@ -363,11 +364,20 @@ export const SecretListView = ({
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)} onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
onDeleteApproved={handleSecretDelete} onDeleteApproved={handleSecretDelete}
buttonText="Delete Secret" buttonText="Delete Secret"
> formContent={
{importedBy && importedBy.length > 0 && ( importedBy &&
<CollapsibleSecretImports importedBy={importedBy} /> importedBy.length > 0 && <CollapsibleSecretImports importedBy={importedBy} />
)} }
</DeleteActionModal> deletionMessage={
<>
Type the secret key{" "}
<span className="font-bold">
&quot;{(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}&quot;
</span>{" "}
below to perform this action
</>
}
/>
<SecretDetailSidebar <SecretDetailSidebar
environment={environment} environment={environment}
secretPath={secretPath} secretPath={secretPath}