Improve referenced secret deletion message component

This commit is contained in:
carlosmonastyrski
2025-04-10 10:24:02 -03:00
parent b121ec891f
commit 8ee2b54182
7 changed files with 174 additions and 152 deletions
@@ -581,6 +581,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
folders: z folders: z
.object({ .object({
folderName: z.string(), folderName: z.string(),
folderImported: z.boolean(),
secrets: z.array(z.string()).optional() secrets: z.array(z.string()).optional()
}) })
.array() .array()
@@ -856,6 +857,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId actorOrgId: req.permission.orgId
}); });
if (secrets?.length || secretRotations?.length) { if (secrets?.length || secretRotations?.length) {
const secretCount = const secretCount =
(secrets?.length ?? 0) + (secrets?.length ?? 0) +
@@ -231,6 +231,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
[folderName: string]: { [folderName: string]: {
secrets: string[]; secrets: string[];
folderId: string; folderId: string;
folderImported: boolean;
}; };
}; };
}; };
@@ -251,11 +252,13 @@ export const secretImportDALFactory = (db: TDbClient) => {
} }
if (!updatedAcc[env].folders[folder]) { if (!updatedAcc[env].folders[folder]) {
updatedAcc[env].folders[folder] = { secrets: [], folderId: item.folderId }; updatedAcc[env].folders[folder] = { secrets: [], folderId: item.folderId, folderImported: false };
} }
if ("secretId" in item && item.secretId) { if ("secretId" in item && item.secretId) {
updatedAcc[env].folders[folder].secrets = [...updatedAcc[env].folders[folder].secrets, item.secretId]; updatedAcc[env].folders[folder].secrets = [...updatedAcc[env].folders[folder].secrets, item.secretId];
} else {
updatedAcc[env].folders[folder].folderImported = true;
} }
return updatedAcc; return updatedAcc;
@@ -271,6 +274,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
return { return {
folderName, folderName,
folderId: folderData.folderId, folderId: folderData.folderId,
folderImported: folderData.folderImported,
...(hasSecrets && { secrets: folderData.secrets }) ...(hasSecrets && { secrets: folderData.secrets })
}; };
}); });
@@ -843,6 +843,7 @@ export const secretImportServiceFactory = ({
...el, ...el,
folders: el.folders.map((folderItem) => ({ folders: el.folders.map((folderItem) => ({
folderId: folderItem.folderId, folderId: folderItem.folderId,
folderImported: folderItem.folderImported,
secrets: folderItem.secrets, secrets: folderItem.secrets,
folderName: deepPaths.find((p) => p.folderId === folderItem.folderId)?.path || `...${folderItem.folderName}` folderName: deepPaths.find((p) => p.folderId === folderItem.folderId)?.path || `...${folderItem.folderName}`
})) }))
@@ -65,6 +65,7 @@ export type FolderInfo = {
folderName: string; folderName: string;
secrets?: string[]; secrets?: string[];
folderId: string; folderId: string;
folderImported: boolean;
envSlug?: string; envSlug?: string;
}; };
+4 -1
View File
@@ -41,7 +41,10 @@ export type DashboardProjectSecretsDetailsResponse = {
totalSecretCount?: number; totalSecretCount?: number;
totalSecretRotationCount?: number; totalSecretRotationCount?: number;
totalCount: number; totalCount: number;
importedBy?: { envName: string; folders: { folderName: string; secrets?: string[] }[] }[]; importedBy?: {
envName: string;
folders: { folderName: string; secrets?: string[]; folderImported: boolean }[];
}[];
}; };
export type DashboardProjectSecretsByKeys = { export type DashboardProjectSecretsByKeys = {
@@ -1,201 +1,209 @@
import React, { KeyboardEvent, useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
faChevronDown, faChevronLeft,
faChevronRight, faChevronRight,
faCodeBranch,
faFolder, faFolder,
faKey, faKey,
faWarning faWarning
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface Folder {
folderName: string;
secrets?: string[];
}
interface Environment {
envName: string;
folders: Folder[];
}
interface FlatItem { interface FlatItem {
type: "folder" | "secret" | "environment"; type: "folder" | "secret";
path: string; path: string;
secretKey?: string; secretKey?: string;
envName?: string; reference: string;
depth?: number;
id: string; id: string;
} }
interface CollapsibleSecretImportsProps { interface CollapsibleSecretImportsProps {
importedBy?: Environment[]; importedBy?: {
envName: string;
folders: { folderName: string; secrets?: string[]; folderImported: boolean }[];
}[];
} }
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
importedBy = [] importedBy = []
}) => { }) => {
const [expandedEnvs, setExpandedEnvs] = useState<Record<string, boolean>>( const [currentEnvIndex, setCurrentEnvIndex] = useState(0);
importedBy.reduce((acc, env) => ({ ...acc, [env.envName]: true }), {})
);
const toggleEnvironment = (envName: string) => { const truncatePath = (path: string, maxLength = 45): string =>
setExpandedEnvs((prev) => ({ path.length <= maxLength ? path : `...${path.slice(-(maxLength - 3))}`;
...prev,
[envName]: !prev[envName]
}));
};
const handleKeyDown = (envName: string, e: KeyboardEvent<HTMLButtonElement>) => { const groupedItems = useMemo(() => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleEnvironment(envName);
}
};
const truncatePath = (path: string, maxLength: number = 45) => {
if (path.length <= maxLength) return path;
return `...${path.slice(-(maxLength - 3))}`;
};
const processedEnvironments = useMemo(() => {
return importedBy.map((env) => { return importedBy.map((env) => {
const items: FlatItem[] = [ const envItems: FlatItem[] = [];
{
type: "environment",
path: env.envName,
envName: env.envName,
id: `env-${env.envName}`
}
];
env.folders.forEach((folder) => { env.folders.forEach((folder) => {
const folderId = `folder-${env.envName}-${folder.folderName}`; if (folder.folderImported) {
items.push({ envItems.push({
type: "folder", type: "folder",
path: folder.folderName, path: folder.folderName,
id: folderId id: `folder-${env.envName}-${folder.folderName}`,
}); reference: "Imported"
});
}
if (folder.secrets && folder.secrets.length > 0) { if (folder.secrets?.length) {
folder.secrets.forEach((secret) => { folder.secrets.forEach((secret) => {
const secretPath = const secretPath =
folder.folderName === "/" ? `/${secret}` : `${folder.folderName}/${secret}`; folder.folderName === "/" ? `/${secret}` : `${folder.folderName}/${secret}`;
const secretId = `secret-${env.envName}-${secretPath}`;
items.push({ envItems.push({
type: "secret", type: "secret",
path: secretPath, path: secretPath,
secretKey: secret, secretKey: secret,
id: secretId id: `secret-${env.envName}-${secretPath}`,
reference: "Referenced"
}); });
}); });
} }
}); });
const [envItem, ...rest] = items;
rest.sort((a, b) => {
const aSegments = a.path.split("/").filter(Boolean);
const bSegments = b.path.split("/").filter(Boolean);
for (let i = 0; i < Math.min(aSegments.length, bSegments.length); i += 1) {
if (aSegments[i] !== bSegments[i]) {
return aSegments[i].localeCompare(bSegments[i]);
}
}
return aSegments.length - bSegments.length;
});
return { return {
...env, envName: env.envName,
items: [envItem, ...rest] items: envItems.sort((a, b) => {
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 bSegments = bPath.split("/").filter(Boolean).length;
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-red-900/20 shadow-lg"> <div className="mt-4 overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-900 shadow-lg">
<div className="flex items-start gap-3 p-4"> <div className="p-4">
<div className="mt-0.5 flex-shrink-0 text-red-500"> <div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20">
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" /> <div className="flex items-start gap-3 p-4">
<div className="mt-0.5 flex-shrink-0 text-red-500">
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" />
</div>
<div className="w-full">
<p className="text-sm font-semibold text-red-500">
Warning: The following resources will be affected by this change
</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 className="w-full">
<p className="text-sm font-semibold text-red-500"> {totalEnvironments > 0 && (
Warning: The following resources will be affected by this change <div className="mb-2 px-1">
</p> <h3 className="text-md font-medium text-gray-200">
<ul className="mt-2 list-disc space-y-2 pl-5 text-xs text-gray-300"> Environment: {groupedItems[currentEnvIndex]?.envName}
<li>Deleting will remove it from all folders where it&apos;s imported</li> </h3>
<li> </div>
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}"} <div className="h-48 overflow-y-auto rounded-md border border-mineshaft-700">
</code> <table className="w-full">
) instead of actual values <thead className="border-b border-mineshaft-700 bg-mineshaft-800">
</li> <tr>
<li>Secrets referencing this will not be automatically deleted</li> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Type</th>
</ul> <th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Path</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Usage</th>
</tr>
</thead>
<tbody className="divide-y divide-mineshaft-700">
{currentItems.map((item) => (
<tr key={item.id} className="hover:bg-mineshaft-800">
<td className="px-4 py-3">
<FontAwesomeIcon
icon={item.type === "secret" ? faKey : faFolder}
className={`h-4 w-4 ${item.type === "secret" ? "text-gray-400" : "text-yellow-500"}`}
aria-hidden="true"
/>
</td>
<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>
</tr>
))}
{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> </div>
</div>
<div className="scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-gray-850 max-h-64 overflow-y-auto"> {totalEnvironments > 1 && (
<div className="w-full divide-y divide-gray-800"> <div className="mt-4 flex items-center justify-between px-4">
{processedEnvironments.map((env) => ( <div className="text-sm text-gray-400">
<div key={env.envName} className="w-full"> Environment {currentEnvIndex + 1} of {totalEnvironments}
<button </div>
type="button" <div className="flex space-x-2">
onClick={() => toggleEnvironment(env.envName)} {renderPaginationButton("prev", handlePrevEnvironment, currentEnvIndex === 0)}
onKeyDown={(e) => handleKeyDown(env.envName, e)} {renderPaginationButton(
className="flex w-full cursor-pointer items-center px-4 py-3 text-left text-gray-200 transition-colors hover:bg-red-900/40" "next",
aria-expanded={expandedEnvs[env.envName]} handleNextEnvironment,
aria-controls={`env-content-${env.envName}`} currentEnvIndex === totalEnvironments - 1
>
<div className="flex flex-1 items-center">
<FontAwesomeIcon
icon={faCodeBranch}
className="mr-3 h-3.5 w-3.5 text-red-500"
aria-hidden="true"
/>
<span className="font-medium">{env.envName}</span>
</div>
<div className="flex h-6 w-6 items-center justify-center rounded-full transition-colors">
<FontAwesomeIcon
icon={expandedEnvs[env.envName] ? faChevronDown : faChevronRight}
className="h-3 w-3 text-gray-400"
aria-hidden="true"
/>
</div>
</button>
{expandedEnvs[env.envName] && (
<div id={`env-content-${env.envName}`} className="bg-mineshaft-850">
{env.items.slice(1).map((item) => {
const isSecret = item.type === "secret";
return (
<div
key={item.id}
className="flex items-center px-6 py-2 text-gray-300 transition-colors"
role="listitem"
>
<div className="flex items-center">
<FontAwesomeIcon
icon={isSecret ? faKey : faFolder}
className={`mr-2.5 h-3 w-3 ${isSecret ? "text-red-400" : "text-red-500"}`}
aria-hidden="true"
/>
<span className="max-w-md truncate text-xs">
{truncatePath(item.path)}
</span>
</div>
</div>
);
})}
</div>
)} )}
</div> </div>
))} </div>
</div> )}
</div> </div>
</div> </div>
); );
@@ -29,7 +29,10 @@ type Props = {
tags?: WsTag[]; tags?: WsTag[];
isVisible?: boolean; isVisible?: boolean;
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
importedBy?: { envName: string; folders: { folderName: string; secrets?: string[] }[] }[]; importedBy?: {
envName: string;
folders: { folderName: string; secrets?: string[]; folderImported: boolean }[];
}[];
}; };
export const SecretListView = ({ export const SecretListView = ({