mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-05 21:26:40 +00:00
UI improvements on secret deletion warning
This commit is contained in:
@@ -474,7 +474,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
|||||||
importedBy: z
|
importedBy: z
|
||||||
.object({
|
.object({
|
||||||
envName: z.string(),
|
envName: z.string(),
|
||||||
folderName: z.string()
|
folders: z
|
||||||
|
.object({
|
||||||
|
folderName: z.string(),
|
||||||
|
secrets: z.array(z.string()).optional()
|
||||||
|
})
|
||||||
|
.array()
|
||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { TableName, TSecretImports } from "@app/db/schemas";
|
|||||||
import { DatabaseError } from "@app/lib/errors";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify } from "@app/lib/knex";
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
import { EnvInfo, FolderInfo, FolderResult, SecretResult } from "./secret-import-types";
|
||||||
|
|
||||||
export type TSecretImportDALFactory = ReturnType<typeof secretImportDALFactory>;
|
export type TSecretImportDALFactory = ReturnType<typeof secretImportDALFactory>;
|
||||||
|
|
||||||
export const secretImportDALFactory = (db: TDbClient) => {
|
export const secretImportDALFactory = (db: TDbClient) => {
|
||||||
@@ -169,24 +171,104 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFolderIsImportedBy = async (secretPath: string, environment: string, tx?: Knex) => {
|
const getFolderIsImportedBy = async (
|
||||||
|
secretPath: string,
|
||||||
|
environmentId: string,
|
||||||
|
environment: string,
|
||||||
|
projectId: string,
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
|
const knexInstance = tx || db.replicaNode();
|
||||||
.where({ importPath: secretPath, importEnv: environment })
|
|
||||||
|
const folderImports = await knexInstance(TableName.SecretImport)
|
||||||
|
.where({ importPath: secretPath, importEnv: environmentId })
|
||||||
.join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`)
|
.join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`)
|
||||||
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||||
.select(
|
.select(
|
||||||
db.ref("name").withSchema(TableName.Environment).as("envName"),
|
knexInstance.ref("name").withSchema(TableName.Environment).as("envName"),
|
||||||
db.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
|
knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
|
||||||
db.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId")
|
knexInstance.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId")
|
||||||
);
|
);
|
||||||
|
|
||||||
return docs.map(({ envName, folderName, parentFolderId }) => ({
|
const secretReferences = await knexInstance(TableName.SecretReferenceV2)
|
||||||
|
.where({ secretPath, environment })
|
||||||
|
.join(TableName.SecretV2, `${TableName.SecretReferenceV2}.secretId`, `${TableName.SecretV2}.id`)
|
||||||
|
.join(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`)
|
||||||
|
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.where(`${TableName.Environment}.projectId`, projectId)
|
||||||
|
.select(
|
||||||
|
knexInstance.ref("key").withSchema(TableName.SecretV2).as("secretId"),
|
||||||
|
knexInstance.ref("name").withSchema(TableName.SecretFolder).as("folderName"),
|
||||||
|
knexInstance.ref("name").withSchema(TableName.Environment).as("envName"),
|
||||||
|
knexInstance.ref("parentId").withSchema(TableName.SecretFolder).as("parentFolderId")
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderResults = folderImports.map(({ envName, folderName, parentFolderId }) => ({
|
||||||
envName,
|
envName,
|
||||||
folderName: parentFolderId ? folderName : "Project Root Folder"
|
folderName: parentFolderId ? folderName : "/"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const secretResults = secretReferences.map(({ envName, secretId, folderName, parentFolderId }) => ({
|
||||||
|
envName,
|
||||||
|
secretId,
|
||||||
|
folderName: parentFolderId ? folderName : "/"
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ResultItem = FolderResult | SecretResult;
|
||||||
|
const allResults: ResultItem[] = [...folderResults, ...secretResults];
|
||||||
|
|
||||||
|
type EnvFolderMap = {
|
||||||
|
[envName: string]: {
|
||||||
|
[folderName: string]: {
|
||||||
|
secrets: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedByEnv = allResults.reduce<EnvFolderMap>((acc, item) => {
|
||||||
|
const env = item.envName;
|
||||||
|
const folder = item.folderName;
|
||||||
|
|
||||||
|
const updatedAcc = { ...acc };
|
||||||
|
|
||||||
|
if (!updatedAcc[env]) {
|
||||||
|
updatedAcc[env] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedAcc[env][folder]) {
|
||||||
|
updatedAcc[env][folder] = { secrets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("secretId" in item && item.secretId) {
|
||||||
|
updatedAcc[env][folder].secrets = [...updatedAcc[env][folder].secrets, item.secretId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedAcc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const formattedResult: EnvInfo[] = Object.keys(groupedByEnv).map((envName) => {
|
||||||
|
const envData = groupedByEnv[envName];
|
||||||
|
|
||||||
|
const folders: FolderInfo[] = Object.keys(envData).map((folderName) => {
|
||||||
|
const folderData = envData[folderName];
|
||||||
|
const hasSecrets = folderData.secrets.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
folderName,
|
||||||
|
...(hasSecrets && { secrets: folderData.secrets })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
envName,
|
||||||
|
folders
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({ error, name: "get secret imports count" });
|
throw new DatabaseError({ error, name: "get secret imports and references" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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;
|
||||||
@@ -821,8 +822,8 @@ export const secretImportServiceFactory = ({
|
|||||||
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`
|
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`
|
||||||
});
|
});
|
||||||
|
|
||||||
const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId);
|
const importedBy = await secretImportDAL.getFolderIsImportedBy(secretPath, folder.envId, environment, projectId);
|
||||||
|
logger.info("importedBy", JSON.stringify(importedBy));
|
||||||
return importedBy;
|
return importedBy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,24 @@ export type TGetSecretsFromImportDTO = {
|
|||||||
environment: string;
|
environment: string;
|
||||||
path: string;
|
path: string;
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
export type FolderResult = {
|
||||||
|
envName: string;
|
||||||
|
folderName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecretResult = {
|
||||||
|
envName: string;
|
||||||
|
secretId: string;
|
||||||
|
folderName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderInfo = {
|
||||||
|
folderName: string;
|
||||||
|
secrets?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnvInfo = {
|
||||||
|
envName: string;
|
||||||
|
folders: FolderInfo[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export type DashboardProjectSecretsDetailsResponse = {
|
|||||||
totalDynamicSecretCount?: number;
|
totalDynamicSecretCount?: number;
|
||||||
totalSecretCount?: number;
|
totalSecretCount?: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
importedBy?: { envName: string; folderName: string }[];
|
importedBy?: { envName: string; folders: { folderName: string; secrets?: string[] }[] }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardProjectSecretsByKeys = {
|
export type DashboardProjectSecretsByKeys = {
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { faFolder, faKey, faServer, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
interface Folder {
|
||||||
|
folderName: string;
|
||||||
|
secrets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportedByEnvironment {
|
||||||
|
envName: string;
|
||||||
|
folders: Folder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretDeletionImpactProps {
|
||||||
|
importedBy?: ImportedByEnvironment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecretDeletionImpact: React.FC<SecretDeletionImpactProps> = ({ importedBy = [] }) => {
|
||||||
|
const truncateId = (id: string): string => {
|
||||||
|
if (id.length <= 16) return id;
|
||||||
|
return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-red-300">
|
||||||
|
Warning: This secret is currently being imported by another folder, so deletion will
|
||||||
|
affect both locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{importedBy.map((envData, envIndex) => (
|
||||||
|
<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 className="grid grid-cols-2 gap-2 border-l-2 border-mineshaft-400 pl-2">
|
||||||
|
{(folder.secrets || []).map((secret) => (
|
||||||
|
<div
|
||||||
|
key={`secret-${secret}-${envData.envName}`}
|
||||||
|
className="flex items-center rounded-md border border-mineshaft-400 px-3 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faKey}
|
||||||
|
className="mr-2 h-4 w-4 text-mineshaft-400"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="block font-mono text-xs font-medium">
|
||||||
|
{truncateId(secret)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+3
-46
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPa
|
|||||||
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
||||||
import { SecretItem } from "./SecretItem";
|
import { SecretItem } from "./SecretItem";
|
||||||
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
|
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
|
||||||
|
import { SecretDeletionImpact } from "./SecretDeletionImpact";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
secrets?: SecretV3RawSanitized[];
|
secrets?: SecretV3RawSanitized[];
|
||||||
@@ -29,11 +29,9 @@ type Props = {
|
|||||||
tags?: WsTag[];
|
tags?: WsTag[];
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isProtectedBranch?: boolean;
|
isProtectedBranch?: boolean;
|
||||||
importedBy?: { envName: string; folderName: string }[];
|
importedBy?: { envName: string; folders: { folderName: string; secrets?: string[] }[] }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type GroupedFolders = Record<string, string[]>;
|
|
||||||
|
|
||||||
export const SecretListView = ({
|
export const SecretListView = ({
|
||||||
secrets = [],
|
secrets = [],
|
||||||
environment,
|
environment,
|
||||||
@@ -358,48 +356,7 @@ export const SecretListView = ({
|
|||||||
onDeleteApproved={handleSecretDelete}
|
onDeleteApproved={handleSecretDelete}
|
||||||
buttonText="Delete Secret"
|
buttonText="Delete Secret"
|
||||||
>
|
>
|
||||||
{importedBy && importedBy.length > 0 && (
|
{importedBy && importedBy.length > 0 && <SecretDeletionImpact importedBy={importedBy} />}
|
||||||
<div className="mb-4 mt-4 rounded-md border border-red-500/50 bg-red-900/30 p-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="mt-0.5 flex-shrink-0 text-red-500">
|
|
||||||
<FontAwesomeIcon icon={faWarning} />
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="mb-3 text-sm text-red-400">
|
|
||||||
Warning: This secret is currently being imported by another folder, so deletion
|
|
||||||
will affect both locations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex w-full">
|
|
||||||
{Object.entries(
|
|
||||||
importedBy.reduce<GroupedFolders>((acc, { envName, folderName }) => {
|
|
||||||
if (!acc[envName]) acc[envName] = [];
|
|
||||||
acc[envName].push(folderName);
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
).map(([envName, folders], index, array) => (
|
|
||||||
<div
|
|
||||||
key={envName}
|
|
||||||
className={`${index !== array.length - 1 ? "mr-16" : ""} flex-1`}
|
|
||||||
>
|
|
||||||
<div className="mb-1 text-sm font-medium text-red-300">{envName}</div>
|
|
||||||
<div className="border-l border-red-500/30 pl-2">
|
|
||||||
{folders.map((folderName, idx) => (
|
|
||||||
<div
|
|
||||||
key={`imported-folder-${idx + 1}`}
|
|
||||||
className="truncate py-0.5 text-xs text-red-300"
|
|
||||||
>
|
|
||||||
{folderName}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DeleteActionModal>
|
</DeleteActionModal>
|
||||||
<SecretDetailSidebar
|
<SecretDetailSidebar
|
||||||
environment={environment}
|
environment={environment}
|
||||||
|
|||||||
Reference in New Issue
Block a user