Merge pull request #3491 from Infisical/feat/improveSecretReferenceWarning

feat(secrets-ui): Add direct reference warning on secrets updates and add secret sync warning on deletion
This commit is contained in:
carlosmonastyrski
2025-04-30 08:17:55 -03:00
committed by GitHub
18 changed files with 493 additions and 124 deletions
+1
View File
@@ -1541,6 +1541,7 @@ export const registerRoutes = async (
const secretSyncService = secretSyncServiceFactory({ const secretSyncService = secretSyncServiceFactory({
secretSyncDAL, secretSyncDAL,
secretImportDAL,
permissionService, permissionService,
appConnectionService, appConnectionService,
folderDAL, folderDAL,
@@ -154,7 +154,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
secrets: z secrets: z
.object({ .object({
secretId: z.string(), secretId: z.string(),
referencedSecretKey: z.string() referencedSecretKey: z.string(),
referencedSecretEnv: z.string()
}) })
.array() .array()
.optional() .optional()
@@ -166,6 +167,16 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}) })
.array() .array()
.optional(), .optional(),
usedBySecretSyncs: z
.object({
name: z.string(),
destination: z.string(),
environment: z.string(),
id: z.string(),
path: z.string()
})
.array()
.optional(),
totalFolderCount: z.number().optional(), totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(), totalSecretCount: z.number().optional(),
@@ -500,6 +511,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
} }
} }
const usedBySecretSyncs: { name: string; destination: string; environment: string; id: string; path: string }[] =
[];
for await (const environment of environments) {
const secretSyncs = await server.services.secretSync.listSecretSyncsBySecretPath(
{ projectId, secretPath, environment },
req.permission
);
secretSyncs.forEach((sync) => {
usedBySecretSyncs.push({
name: sync.name,
destination: sync.destination,
environment,
id: sync.id,
path: sync.folder?.path || "/"
});
});
}
return { return {
folders, folders,
dynamicSecrets, dynamicSecrets,
@@ -512,6 +541,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalSecretCount, totalSecretCount,
totalSecretRotationCount, totalSecretRotationCount,
importedByEnvs, importedByEnvs,
usedBySecretSyncs,
totalCount: totalCount:
(totalFolderCount ?? 0) + (totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) + (totalDynamicSecretCount ?? 0) +
@@ -611,6 +641,16 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalFolderCount: z.number().optional(), totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(), totalSecretCount: z.number().optional(),
usedBySecretSyncs: z
.object({
name: z.string(),
destination: z.string(),
environment: z.string(),
id: z.string(),
path: z.string()
})
.array()
.optional(),
importedBy: z importedBy: z
.object({ .object({
environment: z.object({ environment: z.object({
@@ -624,7 +664,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
secrets: z secrets: z
.object({ .object({
secretId: z.string(), secretId: z.string(),
referencedSecretKey: z.string() referencedSecretKey: z.string(),
referencedSecretEnv: z.string()
}) })
.array() .array()
.optional() .optional()
@@ -904,6 +945,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
secrets secrets
}); });
const secretSyncs = await server.services.secretSync.listSecretSyncsBySecretPath(
{ projectId, secretPath, environment },
req.permission
);
const usedBySecretSyncs = secretSyncs.map((sync) => ({
name: sync.name,
destination: sync.destination,
environment: sync.environment?.name || environment,
id: sync.id,
path: sync.folder?.path || "/"
}));
if (secrets?.length || secretRotations?.length) { if (secrets?.length || secretRotations?.length) {
const secretCount = const secretCount =
(secrets?.length ?? 0) + (secrets?.length ?? 0) +
@@ -950,6 +1003,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
totalSecretCount, totalSecretCount,
totalSecretRotationCount, totalSecretRotationCount,
importedBy, importedBy,
usedBySecretSyncs,
totalCount: totalCount:
(totalImportCount ?? 0) + (totalImportCount ?? 0) +
(totalFolderCount ?? 0) + (totalFolderCount ?? 0) +
@@ -171,6 +171,19 @@ export const secretImportDALFactory = (db: TDbClient) => {
} }
}; };
const getFolderImports = async (secretPath: string, environmentId: string, tx?: Knex) => {
try {
const folderImports = await (tx || db.replicaNode())(TableName.SecretImport)
.where({ importPath: secretPath, importEnv: environmentId })
.join(TableName.SecretFolder, `${TableName.SecretImport}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.select(db.ref("id").withSchema(TableName.SecretFolder).as("folderId"));
return folderImports;
} catch (error) {
throw new DatabaseError({ error, name: "get secret imports" });
}
};
const getFolderIsImportedBy = async ( const getFolderIsImportedBy = async (
secretPath: string, secretPath: string,
environmentId: string, environmentId: string,
@@ -203,7 +216,8 @@ export const secretImportDALFactory = (db: TDbClient) => {
db.ref("name").withSchema(TableName.Environment).as("envName"), db.ref("name").withSchema(TableName.Environment).as("envName"),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"), db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
db.ref("id").withSchema(TableName.SecretFolder).as("folderId"), db.ref("id").withSchema(TableName.SecretFolder).as("folderId"),
db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey") db.ref("secretKey").withSchema(TableName.SecretReferenceV2).as("referencedSecretKey"),
db.ref("environment").withSchema(TableName.SecretReferenceV2).as("referencedSecretEnv")
); );
const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({ const folderResults = folderImports.map(({ envName, envSlug, folderName, folderId }) => ({
@@ -214,13 +228,14 @@ export const secretImportDALFactory = (db: TDbClient) => {
})); }));
const secretResults = secretReferences.map( const secretResults = secretReferences.map(
({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey }) => ({ ({ envName, envSlug, secretId, folderName, folderId, referencedSecretKey, referencedSecretEnv }) => ({
envName, envName,
envSlug, envSlug,
secretId, secretId,
folderName, folderName,
folderId, folderId,
referencedSecretKey referencedSecretKey,
referencedSecretEnv
}) })
); );
@@ -235,6 +250,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
secrets: { secrets: {
secretId: string; secretId: string;
referencedSecretKey: string; referencedSecretKey: string;
referencedSecretEnv: string;
}[]; }[];
folderId: string; folderId: string;
folderImported: boolean; folderImported: boolean;
@@ -264,7 +280,11 @@ export const secretImportDALFactory = (db: TDbClient) => {
if ("secretId" in item && item.secretId) { if ("secretId" in item && item.secretId) {
updatedAcc[env].folders[folder].secrets = [ updatedAcc[env].folders[folder].secrets = [
...updatedAcc[env].folders[folder].secrets, ...updatedAcc[env].folders[folder].secrets,
{ secretId: item.secretId, referencedSecretKey: item.referencedSecretKey } {
secretId: item.secretId,
referencedSecretKey: item.referencedSecretKey,
referencedSecretEnv: item.referencedSecretEnv
}
]; ];
} else { } else {
updatedAcc[env].folders[folder].folderImported = true; updatedAcc[env].folders[folder].folderImported = true;
@@ -309,6 +329,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
findLastImportPosition, findLastImportPosition,
updateAllPosition, updateAllPosition,
getProjectImportCount, getProjectImportCount,
getFolderIsImportedBy getFolderIsImportedBy,
getFolderImports
}; };
}; };
@@ -808,7 +808,7 @@ export const secretImportServiceFactory = ({
actorOrgId, actorOrgId,
secrets secrets
}: TGetSecretImportsDTO & { }: TGetSecretImportsDTO & {
secrets: { secretKey: string; secretValue: string }[] | undefined; secrets: { secretKey: string; secretValue: string; id: string }[] | undefined;
}) => { }) => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
actor, actor,
@@ -877,7 +877,8 @@ export const secretImportServiceFactory = ({
) )
.map((otherSecret) => ({ .map((otherSecret) => ({
secretId: secret.secretKey, secretId: secret.secretKey,
referencedSecretKey: otherSecret.secretKey referencedSecretKey: otherSecret.secretKey,
referencedSecretEnv: environment
})); }));
}) || []; }) || [];
if (locallyReferenced.length > 0) { if (locallyReferenced.length > 0) {
@@ -56,11 +56,12 @@ export type FolderResult = {
export type SecretResult = { export type SecretResult = {
secretId: string; secretId: string;
referencedSecretKey: string; referencedSecretKey: string;
referencedSecretEnv: string;
} & FolderResult; } & FolderResult;
export type FolderInfo = { export type FolderInfo = {
folderName: string; folderName: string;
secrets?: { secretId: string; referencedSecretKey: string }[]; secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
folderId: string; folderId: string;
folderImported: boolean; folderImported: boolean;
envSlug?: string; envSlug?: string;
@@ -23,6 +23,7 @@ import {
TDeleteSecretSyncDTO, TDeleteSecretSyncDTO,
TFindSecretSyncByIdDTO, TFindSecretSyncByIdDTO,
TFindSecretSyncByNameDTO, TFindSecretSyncByNameDTO,
TListSecretSyncsByFolderId,
TListSecretSyncsByProjectId, TListSecretSyncsByProjectId,
TSecretSync, TSecretSync,
TTriggerSecretSyncImportSecretsByIdDTO, TTriggerSecretSyncImportSecretsByIdDTO,
@@ -31,12 +32,14 @@ import {
TUpdateSecretSyncDTO TUpdateSecretSyncDTO
} from "@app/services/secret-sync/secret-sync-types"; } from "@app/services/secret-sync/secret-sync-types";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { TSecretSyncDALFactory } from "./secret-sync-dal"; import { TSecretSyncDALFactory } from "./secret-sync-dal";
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps"; import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
import { TSecretSyncQueueFactory } from "./secret-sync-queue"; import { TSecretSyncQueueFactory } from "./secret-sync-queue";
type TSecretSyncServiceFactoryDep = { type TSecretSyncServiceFactoryDep = {
secretSyncDAL: TSecretSyncDALFactory; secretSyncDAL: TSecretSyncDALFactory;
secretImportDAL: TSecretImportDALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">; appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">; projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
@@ -53,6 +56,7 @@ export type TSecretSyncServiceFactory = ReturnType<typeof secretSyncServiceFacto
export const secretSyncServiceFactory = ({ export const secretSyncServiceFactory = ({
secretSyncDAL, secretSyncDAL,
folderDAL, folderDAL,
secretImportDAL,
permissionService, permissionService,
appConnectionService, appConnectionService,
projectBotService, projectBotService,
@@ -85,6 +89,37 @@ export const secretSyncServiceFactory = ({
return secretSyncs as TSecretSync[]; return secretSyncs as TSecretSync[];
}; };
const listSecretSyncsBySecretPath = async (
{ projectId, secretPath, environment }: TListSecretSyncsByFolderId,
actor: OrgServiceActor
) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId
});
if (permission.cannot(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs)) {
return [];
}
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return [];
const folderImports = await secretImportDAL.getFolderImports(secretPath, folder.envId);
const secretSyncs = await secretSyncDAL.find({
$in: {
folderId: folderImports.map((folderImport) => folderImport.folderId).concat(folder.id)
}
});
return secretSyncs as TSecretSync[];
};
const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => { const findSecretSyncById = async ({ destination, syncId }: TFindSecretSyncByIdDTO, actor: OrgServiceActor) => {
const secretSync = await secretSyncDAL.findById(syncId); const secretSync = await secretSyncDAL.findById(syncId);
@@ -518,6 +553,7 @@ export const secretSyncServiceFactory = ({
return { return {
listSecretSyncOptions, listSecretSyncOptions,
listSecretSyncsByProjectId, listSecretSyncsByProjectId,
listSecretSyncsBySecretPath,
findSecretSyncById, findSecretSyncById,
findSecretSyncByName, findSecretSyncByName,
createSecretSync, createSecretSync,
@@ -144,6 +144,13 @@ export type TListSecretSyncsByProjectId = {
destination?: SecretSync; destination?: SecretSync;
}; };
export type TListSecretSyncsByFolderId = {
projectId: string;
secretPath: string;
environment: string;
destination?: SecretSync;
};
export type TFindSecretSyncByIdDTO = { export type TFindSecretSyncByIdDTO = {
syncId: string; syncId: string;
destination: SecretSync; destination: SecretSync;
@@ -19,6 +19,7 @@ type Props = {
formContent?: ReactNode; formContent?: ReactNode;
children?: ReactNode; children?: ReactNode;
deletionMessage?: ReactNode; deletionMessage?: ReactNode;
buttonColorSchema?: "danger" | "primary" | "secondary" | "gray" | null;
}; };
export const DeleteActionModal = ({ export const DeleteActionModal = ({
@@ -32,6 +33,7 @@ export const DeleteActionModal = ({
buttonText = "Delete", buttonText = "Delete",
formContent, formContent,
deletionMessage, deletionMessage,
buttonColorSchema = "danger",
children children
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [inputData, setInputData] = useState(""); const [inputData, setInputData] = useState("");
@@ -67,7 +69,7 @@ export const DeleteActionModal = ({
<div className="mx-2 flex items-center"> <div className="mx-2 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
colorSchema="danger" colorSchema={buttonColorSchema}
isDisabled={!(deleteKey === inputData) || isLoading} isDisabled={!(deleteKey === inputData) || isLoading}
onClick={onDelete} onClick={onDelete}
isLoading={isLoading} isLoading={isLoading}
+11 -1
View File
@@ -25,9 +25,18 @@ export type DashboardProjectSecretsOverviewResponse = {
totalUniqueFoldersInPage: number; totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number; totalUniqueSecretImportsInPage: number;
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]; importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
usedBySecretSyncs?: UsedBySecretSyncs[];
totalUniqueSecretRotationsInPage: number; totalUniqueSecretRotationsInPage: number;
}; };
export type UsedBySecretSyncs = {
name: string;
destination: string;
environment: string;
id: string;
path: string;
};
export type DashboardProjectSecretsDetailsResponse = { export type DashboardProjectSecretsDetailsResponse = {
imports?: TSecretImport[]; imports?: TSecretImport[];
folders?: TSecretFolder[]; folders?: TSecretFolder[];
@@ -43,13 +52,14 @@ export type DashboardProjectSecretsDetailsResponse = {
totalSecretRotationCount?: number; totalSecretRotationCount?: number;
totalCount: number; totalCount: number;
importedBy?: ProjectSecretsImportedBy[]; importedBy?: ProjectSecretsImportedBy[];
usedBySecretSyncs?: UsedBySecretSyncs[];
}; };
export type ProjectSecretsImportedBy = { export type ProjectSecretsImportedBy = {
environment: { name: string; slug: string }; environment: { name: string; slug: string };
folders: { folders: {
name: string; name: string;
secrets?: { secretId: string; referencedSecretKey: string }[]; secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean; isImported: boolean;
}[]; }[];
}; };
@@ -74,7 +74,7 @@ import {
useUpdateSecretV3 useUpdateSecretV3
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { DashboardSecretsOrderBy, ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
@@ -274,7 +274,8 @@ export const OverviewPage = () => {
totalUniqueSecretImportsInPage, totalUniqueSecretImportsInPage,
totalUniqueDynamicSecretsInPage, totalUniqueDynamicSecretsInPage,
totalUniqueSecretRotationsInPage, totalUniqueSecretRotationsInPage,
importedByEnvs importedByEnvs,
usedBySecretSyncs
} = overview ?? {}; } = overview ?? {};
const secretImportsShaped = secretImports const secretImportsShaped = secretImports
@@ -726,6 +727,98 @@ export const OverviewPage = () => {
} }
}, [routerSearch.search]); }, [routerSearch.search]);
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
const secretsToDeleteKeys = useMemo(() => {
return Object.values(selectedEntries.secret).flatMap((entries) =>
Object.values(entries).map((secret) => secret.key)
);
}, [selectedEntries]);
const filterAndMergeEnvironments = (
envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => {
const { slug } = item.environment;
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
groupedBySlug[slug].push(item);
});
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
const { environment } = group[0];
const allFolders = group.flatMap((item) => item.folders);
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
allFolders.forEach((folder) => {
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
foldersByName[folder.name].push(folder);
});
const mergedFolders = Object.entries(foldersByName).map(([name, foldersData]) => {
const isImported = foldersData.some((folder) => folder.isImported);
const allSecrets = foldersData.flatMap((folder) => folder.secrets || []);
const uniqueSecrets: {
secretId: string;
referencedSecretKey: string;
referencedSecretEnv: string;
}[] = [];
const secretIds = new Set<string>();
allSecrets
.filter(
(secret) =>
!secretsToDeleteKeys ||
secretsToDeleteKeys.length === 0 ||
secretsToDeleteKeys.includes(secret.referencedSecretKey)
)
.forEach((secret) => {
if (!secretIds.has(secret.secretId)) {
secretIds.add(secret.secretId);
uniqueSecrets.push(secret);
}
});
return {
name,
isImported,
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
};
});
return {
environment,
folders: mergedFolders.filter(
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
)
};
});
return mergedImportedBy;
};
const importedBy = useMemo(() => {
if (!importedByEnvs) return [];
if (selectedKeysCount === 0) {
return filterAndMergeEnvironments(
visibleEnvs.map(({ slug }) => slug),
importedByEnvs
);
}
return filterAndMergeEnvironments(
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
importedByEnvs
);
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) {
return ( return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -1044,7 +1137,9 @@ export const OverviewPage = () => {
secretPath={secretPath} secretPath={secretPath}
selectedEntries={selectedEntries} selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries} resetSelectedEntries={resetSelectedEntries}
importedByEnvs={importedByEnvs} importedBy={importedBy}
secretsToDeleteKeys={secretsToDeleteKeys}
usedBySecretSyncs={usedBySecretSyncs}
/> />
<div className="thin-scrollbar mt-4"> <div className="thin-scrollbar mt-4">
<TableContainer <TableContainer
@@ -1261,6 +1356,7 @@ export const OverviewPage = () => {
secretKey={key} secretKey={key}
getSecretByKey={getSecretByKey} getSecretByKey={getSecretByKey}
scrollOffset={debouncedScrollOffset} scrollOffset={debouncedScrollOffset}
importedBy={importedBy}
/> />
))} ))}
<SecretNoAccessOverviewTableRow <SecretNoAccessOverviewTableRow
@@ -29,9 +29,10 @@ import {
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { SecretType } from "@app/hooks/api/types"; import { SecretType } from "@app/hooks/api/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { CollapsibleSecretImports } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports";
type Props = { type Props = {
defaultValue?: string | null; defaultValue?: string | null;
@@ -54,6 +55,14 @@ type Props = {
) => Promise<void>; ) => Promise<void>;
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>; onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
isRotatedSecret?: boolean; isRotatedSecret?: boolean;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
}; };
export const SecretEditRow = ({ export const SecretEditRow = ({
@@ -70,8 +79,13 @@ export const SecretEditRow = ({
secretPath, secretPath,
isVisible, isVisible,
secretId, secretId,
isRotatedSecret isRotatedSecret,
importedBy
}: Props) => { }: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"editSecret"
] as const);
const { const {
handleSubmit, handleSubmit,
control, control,
@@ -115,6 +129,20 @@ export const SecretEditRow = ({
if (isCreatable) { if (isCreatable) {
await onSecretCreate(environment, secretName, value); await onSecretCreate(environment, secretName, value);
} else { } else {
if (
importedBy &&
importedBy.some(({ folders }) =>
folders?.some(({ secrets }) =>
secrets?.some(
({ referencedSecretKey, referencedSecretEnv }) =>
referencedSecretKey === secretName && referencedSecretEnv === environment
)
)
)
) {
handlePopUpOpen("editSecret", { secretValue: value });
return;
}
await onSecretUpdate( await onSecretUpdate(
environment, environment,
secretName, secretName,
@@ -133,6 +161,18 @@ export const SecretEditRow = ({
} }
}; };
const handleEditSecret = async ({ secretValue }: { secretValue: string }) => {
await onSecretUpdate(
environment,
secretName,
secretValue,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
reset({ value: secretValue });
handlePopUpClose("editSecret");
};
const canReadSecretValue = hasSecretReadValueOrDescribePermission( const canReadSecretValue = hasSecretReadValueOrDescribePermission(
permission, permission,
ProjectPermissionSecretActions.ReadValue ProjectPermissionSecretActions.ReadValue
@@ -325,6 +365,26 @@ export const SecretEditRow = ({
</> </>
)} )}
</div> </div>
<DeleteActionModal
isOpen={popUp.editSecret.isOpen}
deleteKey="confirm"
buttonColorSchema="secondary"
buttonText="Save"
subTitle=""
title="Do you want to edit this secret?"
onChange={(isOpen) => handlePopUpToggle("editSecret", isOpen)}
onDeleteApproved={() => handleEditSecret(popUp?.editSecret?.data)}
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={[secretName]}
onlyReferences
/>
)
}
/>
</div> </div>
); );
}; };
@@ -50,6 +50,14 @@ type Props = {
secretName: string secretName: string
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined; ) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
scrollOffset: number; scrollOffset: number;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
}; };
export const SecretOverviewTableRow = ({ export const SecretOverviewTableRow = ({
@@ -64,7 +72,8 @@ export const SecretOverviewTableRow = ({
getImportedSecretByKey, getImportedSecretByKey,
scrollOffset, scrollOffset,
onToggleSecretSelect, onToggleSecretSelect,
isSelected isSelected,
importedBy
}: Props) => { }: Props) => {
const [isFormExpanded, setIsFormExpanded] = useToggle(); const [isFormExpanded, setIsFormExpanded] = useToggle();
const totalCols = environments.length + 1; // secret key row const totalCols = environments.length + 1; // secret key row
@@ -266,6 +275,7 @@ export const SecretOverviewTableRow = ({
onSecretUpdate={onSecretUpdate} onSecretUpdate={onSecretUpdate}
environment={slug} environment={slug}
isRotatedSecret={secret?.isRotatedSecret} isRotatedSecret={secret?.isRotatedSecret}
importedBy={importedBy}
/> />
</td> </td>
</tr> </tr>
@@ -15,7 +15,7 @@ import {
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types"; import { ProjectSecretsImportedBy, UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { import {
SecretType, SecretType,
SecretV3RawSanitized, SecretV3RawSanitized,
@@ -37,14 +37,18 @@ type Props = {
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>; [EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>; [EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
}; };
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]; importedBy?: ProjectSecretsImportedBy[] | null;
usedBySecretSyncs?: UsedBySecretSyncs[];
secretsToDeleteKeys: string[];
}; };
export const SelectionPanel = ({ export const SelectionPanel = ({
secretPath, secretPath,
resetSelectedEntries, resetSelectedEntries,
selectedEntries, selectedEntries,
importedByEnvs importedBy,
secretsToDeleteKeys,
usedBySecretSyncs = []
}: Props) => { }: Props) => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
@@ -81,80 +85,11 @@ export const SelectionPanel = ({
) )
); );
const secretsToDeleteKeys = useMemo(() => { const usedBySecretSyncsFiltered = useMemo(() => {
return Object.values(selectedEntries.secret).flatMap((entries) => if (selectedKeysCount === 0 || usedBySecretSyncs.length === 0) return null;
Object.values(entries).map((secret) => secret.key) const envs = Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries));
); return usedBySecretSyncs.filter((syncItem) => envs.includes(syncItem.environment));
}, [selectedEntries]); }, [selectedEntries, usedBySecretSyncs, selectedKeysCount]);
const filterAndMergeEnvironments = (
envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => {
const { slug } = item.environment;
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
groupedBySlug[slug].push(item);
});
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
const { environment } = group[0];
const allFolders = group.flatMap((item) => item.folders);
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
allFolders.forEach((folder) => {
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
foldersByName[folder.name].push(folder);
});
const mergedFolders = Object.entries(foldersByName).map(([name, folders]) => {
const isImported = folders.some((folder) => folder.isImported);
const allSecrets = folders.flatMap((folder) => folder.secrets || []);
const uniqueSecrets: { secretId: string; referencedSecretKey: string }[] = [];
const secretIds = new Set<string>();
allSecrets
.filter((secret) => secretsToDeleteKeys.includes(secret.referencedSecretKey))
.forEach((secret) => {
if (!secretIds.has(secret.secretId)) {
secretIds.add(secret.secretId);
uniqueSecrets.push(secret);
}
});
return {
name,
isImported,
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
};
});
return {
environment,
folders: mergedFolders.filter(
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
)
};
});
return mergedImportedBy;
};
const importedBy = useMemo(() => {
if (selectedKeysCount === 0 || !importedByEnvs) return null;
return filterAndMergeEnvironments(
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
importedByEnvs
);
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
const getDeleteModalTitle = () => { const getDeleteModalTitle = () => {
if (selectedFolderCount > 0 && selectedKeysCount > 0) { if (selectedFolderCount > 0 && selectedKeysCount > 0) {
@@ -326,11 +261,12 @@ export const SelectionPanel = ({
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)} onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete} onDeleteApproved={handleBulkDelete}
formContent={ formContent={
importedBy && ((usedBySecretSyncsFiltered && usedBySecretSyncsFiltered.length > 0) ||
importedBy.some((element) => element.folders.length > 0) && ( (importedBy && importedBy.some((element) => element.folders.length > 0))) && (
<CollapsibleSecretImports <CollapsibleSecretImports
importedBy={importedBy} importedBy={importedBy || []}
secretsToDelete={secretsToDeleteKeys} secretsToDelete={secretsToDeleteKeys}
usedBySecretSyncs={usedBySecretSyncsFiltered}
/> />
) )
} }
@@ -220,6 +220,7 @@ const Page = () => {
totalSecretCount = 0, totalSecretCount = 0,
totalCount = 0, totalCount = 0,
importedBy, importedBy,
usedBySecretSyncs,
totalSecretRotationCount = 0 totalSecretRotationCount = 0
} = data ?? {}; } = data ?? {};
@@ -441,6 +442,7 @@ const Page = () => {
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)} onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
protectedBranchPolicyName={boardPolicy?.name} protectedBranchPolicyName={boardPolicy?.name}
importedBy={importedBy} importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
/> />
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300"> <div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard"> <div className="flex flex-col" id="dashboard">
@@ -530,6 +532,7 @@ const Page = () => {
secretPath={secretPath} secretPath={secretPath}
isProtectedBranch={isProtectedBranch} isProtectedBranch={isProtectedBranch}
importedBy={importedBy} importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
/> />
)} )}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />} {noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
@@ -69,6 +69,7 @@ import {
dashboardKeys, dashboardKeys,
fetchDashboardProjectSecretsByKeys fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries"; } from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types"; import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
@@ -113,11 +114,12 @@ type Props = {
onVisibilityToggle: () => void; onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void; onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void; onClickRollbackMode: () => void;
usedBySecretSyncs?: UsedBySecretSyncs[];
importedBy?: { importedBy?: {
environment: { name: string; slug: string }; environment: { name: string; slug: string };
folders: { folders: {
name: string; name: string;
secrets?: { secretId: string; referencedSecretKey: string }[]; secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean; isImported: boolean;
}[]; }[];
}[]; }[];
@@ -139,7 +141,8 @@ export const ActionBar = ({
onClickRollbackMode, onClickRollbackMode,
onToggleRowType, onToggleRowType,
protectedBranchPolicyName, protectedBranchPolicyName,
importedBy importedBy,
usedBySecretSyncs
}: Props) => { }: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder", "addFolder",
@@ -1071,11 +1074,12 @@ export const ActionBar = ({
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)} onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
onDeleteApproved={handleSecretBulkDelete} onDeleteApproved={handleSecretBulkDelete}
formContent={ formContent={
importedBy && ((importedBy && importedBy.length > 0) ||
importedBy.length > 0 && ( (usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && (
<CollapsibleSecretImports <CollapsibleSecretImports
importedBy={importedBy} importedBy={importedBy}
secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)} secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)}
usedBySecretSyncs={usedBySecretSyncs}
/> />
) )
} }
@@ -1,13 +1,16 @@
/* eslint-disable no-nested-ternary */
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons"; import { faFileImport, faKey, faSync, 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 { Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
enum ItemType { enum ItemType {
Folder = "Folder", Folder = "Folder",
Secret = "Secret" Secret = "Secret",
SecretSync = "SecretSync"
} }
interface FlatItem { interface FlatItem {
@@ -17,6 +20,8 @@ interface FlatItem {
reference: string; reference: string;
id: string; id: string;
environment: { name: string; slug: string }; environment: { name: string; slug: string };
tooltipText?: string;
destination?: string;
} }
interface CollapsibleSecretImportsProps { interface CollapsibleSecretImportsProps {
@@ -24,16 +29,20 @@ interface CollapsibleSecretImportsProps {
environment: { name: string; slug: string }; environment: { name: string; slug: string };
folders: { folders: {
name: string; name: string;
secrets?: { secretId: string; referencedSecretKey: string }[]; secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean; isImported: boolean;
}[]; }[];
}[]; }[];
usedBySecretSyncs?: UsedBySecretSyncs[] | null;
secretsToDelete: string[]; secretsToDelete: string[];
onlyReferences?: boolean;
} }
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
importedBy = [], importedBy = [],
secretsToDelete usedBySecretSyncs = [],
secretsToDelete,
onlyReferences
}) => { }) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
@@ -51,6 +60,15 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
}; };
const handlePathClick = (item: FlatItem) => { const handlePathClick = (item: FlatItem) => {
if (item.type === ItemType.SecretSync) {
window.open(
`/secret-manager/${currentWorkspace.id}/integrations/secret-syncs/${item.destination}/${item.id}`,
"_blank",
"noopener,noreferrer"
);
return;
}
let pathToNavigate; let pathToNavigate;
if (item.type === ItemType.Folder) { if (item.type === ItemType.Folder) {
pathToNavigate = item.path; pathToNavigate = item.path;
@@ -70,7 +88,7 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
importedBy.forEach((env) => { importedBy.forEach((env) => {
env.folders.forEach((folder) => { env.folders.forEach((folder) => {
if (folder.isImported) { if (folder.isImported && !onlyReferences) {
items.push({ items.push({
type: ItemType.Folder, type: ItemType.Folder,
path: folder.name, path: folder.name,
@@ -103,7 +121,26 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
}); });
}); });
// Add secret sync items
usedBySecretSyncs?.forEach((syncItem) => {
items.push({
type: ItemType.SecretSync,
destination: syncItem.destination,
path: syncItem.path,
id: syncItem.id,
reference: "Secret Sync",
environment: { name: syncItem.environment, slug: "" },
tooltipText: `Currently used by Secret Sync: ${syncItem.name}`
});
});
return items.sort((a, b) => { return items.sort((a, b) => {
if (a.type === ItemType.SecretSync && b.type !== ItemType.SecretSync) return 1;
if (a.type !== ItemType.SecretSync && b.type === ItemType.SecretSync) return -1;
if (a.type === ItemType.SecretSync && b.type === ItemType.SecretSync) {
return a.path.localeCompare(b.path);
}
const envCompare = a.environment.name.localeCompare(b.environment.name); const envCompare = a.environment.name.localeCompare(b.environment.name);
if (envCompare !== 0) return envCompare; if (envCompare !== 0) return envCompare;
@@ -119,7 +156,7 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
return aPath.localeCompare(bPath); return aPath.localeCompare(bPath);
}); });
}, [importedBy]); }, [importedBy, usedBySecretSyncs, secretsToDelete, onlyReferences]);
const hasImportedItems = importedBy.some((element) => { const hasImportedItems = importedBy.some((element) => {
if (element.folders && element.folders.length > 0) { if (element.folders && element.folders.length > 0) {
@@ -135,19 +172,33 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
return false; return false;
}); });
if (!hasImportedItems) { const hasSecretSyncItems = usedBySecretSyncs && usedBySecretSyncs.length > 0;
if (!hasImportedItems && !hasSecretSyncItems) {
return null; return null;
} }
const alertColors = onlyReferences
? {
border: "border-yellow-700/30",
bg: "bg-yellow-900/20",
text: "text-yellow-500"
}
: {
border: "border-red-700/30",
bg: "bg-red-900/20",
text: "text-red-500"
};
return ( return (
<div className="mb-4 w-full"> <div className="mb-4 w-full">
<div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20"> <div className={`mb-4 rounded-md border ${alertColors.border} ${alertColors.bg}`}>
<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 ${alertColors.text}`}>
<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 ${alertColors.text}`}>
The following resources will be affected by this change The following resources will be affected by this change
</p> </p>
</div> </div>
@@ -168,14 +219,36 @@ export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> =
key={item.id} key={item.id}
onClick={() => handlePathClick(item)} onClick={() => handlePathClick(item)}
className="cursor-pointer hover:bg-mineshaft-700" className="cursor-pointer hover:bg-mineshaft-700"
title={`Navigate to ${item.path}`} title={
item.type === ItemType.SecretSync
? "Navigate to Secret Sync"
: `Navigate to ${item.path}`
}
> >
<Td> <Td>
<FontAwesomeIcon <Tooltip
icon={item.type === ItemType.Secret ? faKey : faFileImport} className="max-w-md"
className={`h-4 w-4 ${item.type === ItemType.Secret ? "text-gray-400" : "text-green-700"}`} content={item.tooltipText}
aria-hidden="true" isDisabled={!item.tooltipText}
/> >
<FontAwesomeIcon
icon={
item.type === ItemType.Secret
? faKey
: item.type === ItemType.Folder
? faFileImport
: faSync
}
className={`h-4 w-4 ${
item.type === ItemType.Secret
? "text-gray-400"
: item.type === ItemType.Folder
? "text-green-700"
: "text-mineshaft-300"
}`}
aria-hidden="true"
/>
</Tooltip>
</Td> </Td>
<Td className="px-4">{item.environment.name}</Td> <Td className="px-4">{item.environment.name}</Td>
<Td className="truncate px-4">{truncatePath(item.path)}</Td> <Td className="truncate px-4">{truncatePath(item.path)}</Td>
@@ -4,6 +4,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
Checkbox, Checkbox,
DeleteActionModal,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
@@ -31,7 +32,7 @@ import {
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
@@ -55,6 +56,7 @@ import {
SecretActionType, SecretActionType,
TFormSchema TFormSchema
} from "./SecretListView.utils"; } from "./SecretListView.utils";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
const hiddenValue = "******"; const hiddenValue = "******";
@@ -75,6 +77,14 @@ type Props = {
environment: string; environment: string;
secretPath: string; secretPath: string;
handleSecretShare: () => void; handleSecretShare: () => void;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean;
}[];
}[];
}; };
export const SecretItem = memo( export const SecretItem = memo(
@@ -90,8 +100,12 @@ export const SecretItem = memo(
onToggleSecretSelect, onToggleSecretSelect,
environment, environment,
secretPath, secretPath,
handleSecretShare handleSecretShare,
importedBy
}: Props) => { }: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"editSecret"
] as const);
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { isRotatedSecret } = secret; const { isRotatedSecret } = secret;
@@ -213,9 +227,24 @@ export const SecretItem = memo(
}; };
const handleFormSubmit = async (data: TFormSchema) => { const handleFormSubmit = async (data: TFormSchema) => {
const hasDirectReferences = importedBy?.some(({ folders }) =>
folders?.some(({ secrets }) =>
secrets?.some(({ referencedSecretKey }) => referencedSecretKey === secret.key)
)
);
if (hasDirectReferences) {
handlePopUpOpen("editSecret", data);
return;
}
await onSaveSecret(secret, { ...secret, ...data }, () => reset()); await onSaveSecret(secret, { ...secret, ...data }, () => reset());
}; };
const handleEditSecret = async (data: TFormSchema) => {
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
handlePopUpClose("editSecret");
};
const handleTagSelect = (tag: WsTag) => { const handleTagSelect = (tag: WsTag) => {
if (selectedTagsGroupById?.[tag.id]) { if (selectedTagsGroupById?.[tag.id]) {
const tagPos = selectedTags.findIndex(({ id }) => id === tag.id); const tagPos = selectedTags.findIndex(({ id }) => id === tag.id);
@@ -706,6 +735,26 @@ export const SecretItem = memo(
</AnimatePresence> </AnimatePresence>
</div> </div>
</div> </div>
<DeleteActionModal
isOpen={popUp.editSecret.isOpen}
deleteKey="confirm"
buttonColorSchema="secondary"
buttonText="Save"
subTitle=""
title="Do you want to edit this secret?"
onChange={(isOpen) => handlePopUpToggle("editSecret", isOpen)}
onDeleteApproved={() => handleEditSecret(popUp?.editSecret?.data)}
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={[secret.key]}
onlyReferences
/>
)
}
/>
</form> </form>
); );
} }
@@ -8,6 +8,7 @@ import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
@@ -29,11 +30,12 @@ type Props = {
tags?: WsTag[]; tags?: WsTag[];
isVisible?: boolean; isVisible?: boolean;
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
usedBySecretSyncs?: UsedBySecretSyncs[];
importedBy?: { importedBy?: {
environment: { name: string; slug: string }; environment: { name: string; slug: string };
folders: { folders: {
name: string; name: string;
secrets?: { secretId: string; referencedSecretKey: string }[]; secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[];
isImported: boolean; isImported: boolean;
}[]; }[];
}[]; }[];
@@ -47,6 +49,7 @@ export const SecretListView = ({
tags: wsTags = [], tags: wsTags = [],
isVisible, isVisible,
isProtectedBranch = false, isProtectedBranch = false,
usedBySecretSyncs,
importedBy importedBy
}: Props) => { }: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -366,6 +369,7 @@ export const SecretListView = ({
onSaveSecret={handleSaveSecret} onSaveSecret={handleSaveSecret}
onDeleteSecret={onDeleteSecret} onDeleteSecret={onDeleteSecret}
onDetailViewSecret={onDetailViewSecret} onDetailViewSecret={onDetailViewSecret}
importedBy={importedBy}
onCreateTag={onCreateTag} onCreateTag={onCreateTag}
handleSecretShare={() => handleSecretShare={() =>
handlePopUpOpen("createSharedSecret", { handlePopUpOpen("createSharedSecret", {
@@ -382,10 +386,11 @@ export const SecretListView = ({
onDeleteApproved={handleSecretDelete} onDeleteApproved={handleSecretDelete}
buttonText="Delete Secret" buttonText="Delete Secret"
formContent={ formContent={
importedBy && ((importedBy && importedBy.length > 0) ||
importedBy.length > 0 && ( (usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && (
<CollapsibleSecretImports <CollapsibleSecretImports
importedBy={importedBy} importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
secretsToDelete={[(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key || ""]} secretsToDelete={[(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key || ""]}
/> />
) )