UI improvements on secret deletion warning

This commit is contained in:
carlosmonastyrski
2025-04-03 17:40:08 -03:00
parent a0bf03b2ae
commit 15130a433c
5 changed files with 204 additions and 121 deletions
@@ -187,8 +187,9 @@ export const secretImportDALFactory = (db: TDbClient) => {
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select( .select(
knexInstance.ref("name").withSchema(TableName.Environment).as("envName"), knexInstance.ref("name").withSchema(TableName.Environment).as("envName"),
knexInstance.ref("slug").withSchema(TableName.Environment).as("envSlug"),
knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"), knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
knexInstance.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId") knexInstance.ref("id").withSchema(TableName.SecretFolder).as("folderId")
); );
const secretReferences = await knexInstance(TableName.SecretReferenceV2) const secretReferences = await knexInstance(TableName.SecretReferenceV2)
@@ -201,18 +202,23 @@ export const secretImportDALFactory = (db: TDbClient) => {
knexInstance.ref("key").withSchema(TableName.SecretV2).as("secretId"), knexInstance.ref("key").withSchema(TableName.SecretV2).as("secretId"),
knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"), knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
knexInstance.ref("name").withSchema(TableName.Environment).as("envName"), knexInstance.ref("name").withSchema(TableName.Environment).as("envName"),
knexInstance.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId") knexInstance.ref("slug").withSchema(TableName.Environment).as("envSlug"),
knexInstance.ref("id").withSchema(TableName.SecretFolder).as("folderId")
); );
const folderResults = folderImports.map(({ envName, folderName, parentFolderId }) => ({ const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({
envName, envName,
folderName: parentFolderId ? folderName : "/" envSlug,
folderName,
folderId
})); }));
const secretResults = secretReferences.map(({ envName, secretId, folderName, parentFolderId }) => ({ const secretResults = secretReferences.map(({ envName, envSlug, secretId, folderName, folderId }) => ({
envName, envName,
envSlug,
secretId, secretId,
folderName: parentFolderId ? folderName : "/" folderName,
folderId
})); }));
type ResultItem = FolderResult | SecretResult; type ResultItem = FolderResult | SecretResult;
@@ -220,8 +226,12 @@ export const secretImportDALFactory = (db: TDbClient) => {
type EnvFolderMap = { type EnvFolderMap = {
[envName: string]: { [envName: string]: {
envSlug: string;
folders: {
[folderName: string]: { [folderName: string]: {
secrets: string[]; secrets: string[];
folderId: string;
};
}; };
}; };
}; };
@@ -229,19 +239,23 @@ export const secretImportDALFactory = (db: TDbClient) => {
const groupedByEnv = allResults.reduce<EnvFolderMap>((acc, item) => { const groupedByEnv = allResults.reduce<EnvFolderMap>((acc, item) => {
const env = item.envName; const env = item.envName;
const folder = item.folderName; const folder = item.folderName;
const { envSlug } = item;
const updatedAcc = { ...acc }; const updatedAcc = { ...acc };
if (!updatedAcc[env]) { if (!updatedAcc[env]) {
updatedAcc[env] = {}; updatedAcc[env] = {
envSlug,
folders: {}
};
} }
if (!updatedAcc[env][folder]) { if (!updatedAcc[env].folders[folder]) {
updatedAcc[env][folder] = { secrets: [] }; updatedAcc[env].folders[folder] = { secrets: [], folderId: item.folderId };
} }
if ("secretId" in item && item.secretId) { if ("secretId" in item && item.secretId) {
updatedAcc[env][folder].secrets = [...updatedAcc[env][folder].secrets, item.secretId]; updatedAcc[env].folders[folder].secrets = [...updatedAcc[env].folders[folder].secrets, item.secretId];
} }
return updatedAcc; return updatedAcc;
@@ -250,18 +264,20 @@ export const secretImportDALFactory = (db: TDbClient) => {
const formattedResult: EnvInfo[] = Object.keys(groupedByEnv).map((envName) => { const formattedResult: EnvInfo[] = Object.keys(groupedByEnv).map((envName) => {
const envData = groupedByEnv[envName]; const envData = groupedByEnv[envName];
const folders: FolderInfo[] = Object.keys(envData).map((folderName) => { const folders: FolderInfo[] = Object.keys(envData.folders).map((folderName) => {
const folderData = envData[folderName]; const folderData = envData.folders[folderName];
const hasSecrets = folderData.secrets.length > 0; const hasSecrets = folderData.secrets.length > 0;
return { return {
folderName, folderName,
folderId: folderData.folderId,
...(hasSecrets && { secrets: folderData.secrets }) ...(hasSecrets && { secrets: folderData.secrets })
}; };
}); });
return { return {
envName, envName,
envSlug: envData.envSlug,
folders folders
}; };
}); });
@@ -27,6 +27,7 @@ import { decryptSecretRaw } from "../secret/secret-fns";
import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretQueueFactory } from "../secret/secret-queue";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { recursivelyGetSecretPaths } from "../secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretImportDALFactory } from "./secret-import-dal"; import { TSecretImportDALFactory } from "./secret-import-dal";
import { fnSecretsFromImports, fnSecretsV2FromImports } from "./secret-import-fns"; import { fnSecretsFromImports, fnSecretsV2FromImports } from "./secret-import-fns";
import { import {
@@ -38,7 +39,6 @@ import {
TResyncSecretImportReplicationDTO, TResyncSecretImportReplicationDTO,
TUpdateSecretImportDTO TUpdateSecretImportDTO
} from "./secret-import-types"; } from "./secret-import-types";
import { logger } from "@app/lib/logger";
type TSecretImportServiceFactoryDep = { type TSecretImportServiceFactoryDep = {
secretImportDAL: TSecretImportDALFactory; secretImportDAL: TSecretImportDALFactory;
@@ -823,8 +823,30 @@ export const secretImportServiceFactory = ({
}); });
const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId); const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId);
logger.info("importedBy", JSON.stringify(importedBy));
return importedBy; const deepPaths: { path: string; folderId: string }[] = [];
await Promise.all(
importedBy.map(async (el) => {
const envDeepPaths = await recursivelyGetSecretPaths({
folderDAL,
projectEnvDAL,
projectId,
environment: el.envSlug,
currentPath: "/"
});
deepPaths.push(...envDeepPaths);
})
);
return importedBy.map((el) => ({
...el,
folders: el.folders.map((folderItem) => ({
folderId: folderItem.folderId,
secrets: folderItem.secrets,
folderName: deepPaths.find((p) => p.folderId === folderItem.folderId)?.path || `...${folderItem.folderName}`
}))
}));
}; };
return { return {
@@ -49,20 +49,27 @@ export type TGetSecretsFromImportDTO = {
export type FolderResult = { export type FolderResult = {
envName: string; envName: string;
folderName: string; folderName: string;
folderId: string;
envSlug: string;
}; };
export type SecretResult = { export type SecretResult = {
envName: string; envName: string;
secretId: string; secretId: string;
folderName: string; folderName: string;
folderId: string;
envSlug: string;
}; };
export type FolderInfo = { export type FolderInfo = {
folderName: string; folderName: string;
secrets?: string[]; secrets?: string[];
folderId: string;
envSlug?: string;
}; };
export type EnvInfo = { export type EnvInfo = {
envName: string; envName: string;
envSlug: string;
folders: FolderInfo[]; folders: FolderInfo[];
}; };
@@ -41,6 +41,7 @@ interface FolderProps {
isExpanded?: boolean; isExpanded?: boolean;
level: number; level: number;
basePath?: string; basePath?: string;
isDisabled?: boolean;
} }
interface TreeViewProps { interface TreeViewProps {
@@ -48,6 +49,7 @@ interface TreeViewProps {
basePath?: string; basePath?: string;
className?: string; className?: string;
onChange: (items: SecretItem[]) => void; onChange: (items: SecretItem[]) => void;
isDisabled?: boolean;
} }
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => { const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
@@ -81,7 +83,8 @@ const Folder: React.FC<FolderProps> = ({
onFolderSelect, onFolderSelect,
isExpanded = false, isExpanded = false,
level, level,
basePath basePath,
isDisabled = false
}) => { }) => {
const [open, setOpen] = useState(isExpanded); const [open, setOpen] = useState(isExpanded);
const displayName = useMemo(() => getDisplayName(name), [name]); const displayName = useMemo(() => getDisplayName(name), [name]);
@@ -127,13 +130,16 @@ const Folder: React.FC<FolderProps> = ({
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`} className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
/> />
</div> </div>
{!isDisabled && (
<Checkbox <Checkbox
id="folder-root" id="folder-root"
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary" className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={allSelected || someSelected} isChecked={allSelected || someSelected}
onCheckedChange={handleFolderSelect} onCheckedChange={handleFolderSelect}
isIndeterminate={someSelected && !allSelected} isIndeterminate={someSelected && !allSelected}
isDisabled={isDisabled}
/> />
)}
<label <label
htmlFor={`folder-${path}`} htmlFor={`folder-${path}`}
@@ -158,12 +164,15 @@ const Folder: React.FC<FolderProps> = ({
<div className="ml-6 mr-2"> <div className="ml-6 mr-2">
<FontAwesomeIcon icon={faKey} className="h-3 w-3" /> <FontAwesomeIcon icon={faKey} className="h-3 w-3" />
</div> </div>
{!isDisabled && (
<Checkbox <Checkbox
id={`folder-${item.id}`} id={`folder-${item.id}`}
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary" className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={selectedItemIds.includes(item.id)} isChecked={selectedItemIds.includes(item.id)}
onCheckedChange={(checked) => onItemSelect(item, !!checked)} onCheckedChange={(checked) => onItemSelect(item, !!checked)}
isDisabled={isDisabled}
/> />
)}
<label <label
htmlFor={item.id} htmlFor={item.id}
className="ml-2 flex-1 cursor-pointer truncate" className="ml-2 flex-1 cursor-pointer truncate"
@@ -184,6 +193,7 @@ const Folder: React.FC<FolderProps> = ({
onItemSelect={onItemSelect} onItemSelect={onItemSelect}
onFolderSelect={onFolderSelect} onFolderSelect={onFolderSelect}
level={level + 1} level={level + 1}
isDisabled={isDisabled}
/> />
))} ))}
</div> </div>
@@ -197,7 +207,8 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
data, data,
basePath = "/", basePath = "/",
className = "", className = "",
onChange onChange,
isDisabled = false
}) => { }) => {
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]); const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
const rootPath = "/"; const rootPath = "/";
@@ -305,16 +316,19 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
onFolderSelect={handleFolderSelect} onFolderSelect={handleFolderSelect}
isExpanded isExpanded
level={0} level={0}
isDisabled={isDisabled}
/> />
)) ))
)} )}
</div> </div>
{!isDisabled && (
<div className="flex justify-end pb-2 pr-2 pt-2"> <div className="flex justify-end pb-2 pr-2 pt-2">
<h3 className="flex items-center text-mineshaft-400"> <h3 className="flex items-center text-mineshaft-400">
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected {selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
</h3> </h3>
</div> </div>
)}
</div> </div>
</div> </div>
); );
@@ -1,29 +1,111 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { faFolder, faKey, faServer, faWarning } from "@fortawesome/free-solid-svg-icons"; import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Select, SelectItem } from "@app/components/v2";
import { SecretTreeView } from "../ActionBar/ReplicateFolderFromBoard/SecretTreeView";
interface Folder { interface Folder {
folderName: string; folderName: string;
secrets?: string[]; secrets?: string[];
} }
interface ImportedByEnvironment { interface Environment {
envName: string; envName: string;
folders: Folder[]; folders: Folder[];
} }
interface SecretDeletionImpactProps { interface SecretDeletionImpactProps {
importedBy?: ImportedByEnvironment[]; importedBy?: Environment[];
}
interface SecretItem {
id: string;
secretKey: string;
secretValue?: string;
secretPath?: string;
}
interface FolderStructure {
items: SecretItem[];
subFolders: {
[key: string]: FolderStructure;
};
} }
export const SecretDeletionImpact: React.FC<SecretDeletionImpactProps> = ({ importedBy = [] }) => { export const SecretDeletionImpact: React.FC<SecretDeletionImpactProps> = ({ importedBy = [] }) => {
const truncateId = (id: string): string => { const [treeData, setTreeData] = useState<FolderStructure | null>(null);
if (id.length <= 16) return id; const [selectedEnv, setSelectedEnv] = useState<string>(importedBy[0]?.envName || "");
return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
const handleEnvironmentChange = (value: string) => {
setSelectedEnv(value);
}; };
function transformImportedDataToTreeView(
environments: Environment[],
selectedEnvironment: string
): FolderStructure | null {
const environment = environments.find((env) => env.envName === selectedEnvironment);
if (!environment) return null;
const rootStructure: FolderStructure = {
items: [],
subFolders: {}
};
environment.folders.forEach((folder) => {
const path = folder.folderName;
const pathParts = path.split("/").filter((part) => part !== "");
if (pathParts.length === 0) {
if (folder.secrets && folder.secrets.length > 0) {
folder.secrets.forEach((secret) => {
rootStructure.items.push({
id: `${selectedEnvironment}:${path}:${secret}`,
secretKey: secret,
secretPath: path
});
});
}
return;
}
let current = rootStructure;
pathParts.forEach((part, index) => {
if (!current.subFolders[part]) {
current.subFolders[part] = {
items: [],
subFolders: {}
};
}
current = current.subFolders[part];
const isLastPart = index === pathParts.length - 1;
if (isLastPart && folder.secrets && folder.secrets.length > 0) {
folder.secrets.forEach((secret) => {
current.items.push({
id: `${selectedEnvironment}:${path}:${secret}`,
secretKey: secret,
secretPath: path
});
});
}
});
});
return rootStructure;
}
useEffect(() => {
const transformedData = transformImportedDataToTreeView(importedBy, selectedEnv);
setTreeData(transformedData);
}, [selectedEnv, importedBy]);
return ( return (
<div className="mt-4 max-h-[40vh] overflow-y-auto rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-5 shadow-lg"> <div className="mt-4 max-h-[50vh] overflow-y-auto rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-5 shadow-lg">
<div className="mb-4 flex items-start gap-3 rounded-md border border-red-800 bg-red-900/20 p-3"> <div className="mb-4 flex items-start gap-3 rounded-md border border-red-800 bg-red-900/20 p-3">
<div className="flex-shrink-0 text-red-400"> <div className="flex-shrink-0 text-red-400">
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" /> <FontAwesomeIcon icon={faWarning} className="h-5 w-5" />
@@ -35,83 +117,25 @@ export const SecretDeletionImpact: React.FC<SecretDeletionImpactProps> = ({ impo
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{importedBy.map((envData, envIndex) => ( <SecretTreeView data={treeData} basePath="/" onChange={() => {}} isDisabled />
<div
key={`${envData.envName}-${envIndex + 1}`}
className="rounded-lg border border-mineshaft-600 bg-mineshaft-700 p-4 shadow-sm"
>
<h5 className="mb-3 flex items-center text-sm font-semibold text-gray-300">
<FontAwesomeIcon icon={faServer} className="mr-2 h-4 w-4 text-mineshaft-400" />
{envData.envName} Environment
</h5>
{envData.folders.length > 0 && (
<div className="mb-5">
<div className="mb-2 text-xs font-medium uppercase tracking-wider text-gray-400">
Affected Folders
</div>
<div className="grid grid-cols-2 gap-2">
{envData.folders
.filter((folder) => !folder.secrets || folder.secrets.length === 0)
.map((folder, folderIndex) => (
<div
key={`folder-${folderIndex + 1}-${envData.envName}`}
className="flex items-center rounded-md border border-mineshaft-500 px-3 py-2 transition-colors"
>
<FontAwesomeIcon icon={faFolder} className="mr-2 h-4 w-4 text-yellow-700" />
<span className="text-sm">{folder.folderName}</span>
</div>
))}
</div>
</div>
)}
{envData.folders.some((folder) => folder.secrets && folder.secrets.length > 0) && (
<div>
<div className="mb-2 text-xs font-medium uppercase tracking-wider text-gray-400">
Affected Secrets
</div>
<div className="space-y-4">
{envData.folders
.filter((folder) => folder.secrets && folder.secrets.length > 0)
.map((folder) => (
<div
key={`folder-secrets-${folder.folderName}-${envData.envName}`}
className="rounded-md bg-mineshaft-600/70 p-3"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon
icon={faFolder}
className="mr-2 h-3.5 w-3.5 text-yellow-700"
/>
<span className="text-xs font-medium">{folder.folderName}</span>
</div> </div>
<div className="grid grid-cols-2 gap-2 border-l-2 border-mineshaft-400 pl-2"> <div className="mt-4 flex justify-end">
{(folder.secrets || []).map((secret) => ( <Select
<div className="w-44 rounded-md border border-mineshaft-600 bg-mineshaft-700 text-gray-200"
key={`secret-${secret}-${envData.envName}`} onValueChange={handleEnvironmentChange}
className="flex items-center rounded-md border border-mineshaft-400 px-3 py-2 transition-colors" defaultValue={selectedEnv}
> >
<FontAwesomeIcon {importedBy.map((env) => (
icon={faKey} <SelectItem
className="mr-2 h-4 w-4 text-mineshaft-400" value={env.envName}
/> key={env.envName}
<div> className="data-[highlighted]:bg-mineshaft-600"
<span className="block font-mono text-xs font-medium"> >
{truncateId(secret)} <div className="flex items-center gap-2 text-gray-200">{env.envName}</div>
</span> </SelectItem>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
))} ))}
</Select>
</div> </div>
</div> </div>
); );