From ad110f490c38cda0234eceec497c10346bbb5e67 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Mon, 17 Mar 2025 19:13:06 -0300 Subject: [PATCH 1/6] Show when folders have imports in secret overview page --- .../src/hooks/api/secretImports/queries.tsx | 1 + frontend/src/hooks/api/secretImports/types.ts | 21 +++ frontend/src/hooks/utils/secrets-overview.tsx | 39 +++++ .../OverviewPage/OverviewPage.tsx | 41 ++++- .../SecretOverviewImportListView.tsx | 160 ++++++++++++++++++ .../SecretOverviewImportListView/index.tsx | 1 + 6 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx create mode 100644 frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/index.tsx diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index dfc8c6497..828bd52e1 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -167,6 +167,7 @@ export const useGetImportedSecretsAllEnvs = ({ select: useCallback( (data: Awaited>) => data.map((el) => ({ + currentEnv: env, environment: el.environment, secretPath: el.secretPath, environmentInfo: el.environmentInfo, diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 1bf60bd4e..83abd300d 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -1,4 +1,5 @@ import { SecretV3Raw } from "../secrets/types"; +import { WsTag } from "../types"; import { WorkspaceEnv } from "../workspace/types"; export type TSecretImport = { @@ -90,3 +91,23 @@ export type TDeleteSecretImportDTO = { environment: string; path?: string; }; + +export type SecretImportData = { + currentEnv: string; + environment: string; + secretPath: string; + environmentInfo: WorkspaceEnv; + folderId: string; + secrets: { + id: string; + env: string; + key: string; + value: string; + secretValueHidden: boolean; + tags?: WsTag[]; + comment?: string; + createdAt: string; + updatedAt: string; + version: number; + }[]; +}; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index 89fc679c4..4076cb5c0 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -2,6 +2,8 @@ import { useCallback, useMemo } from "react"; import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types"; +import { SecretImportData } from "../api/secretImports/types"; + type FolderNameAndDescription = { name: string; description?: string; @@ -96,3 +98,40 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr return { secKeys, getSecretByKey, getEnvSecretKeyCount }; }; + +export const useSecretImportOverview = (secretImports: SecretImportData[][] | undefined) => { + const uniqueEnvSecretPaths = useMemo(() => { + const uniqueMap: Record = {}; + secretImports?.forEach((importData) => { + importData?.forEach((envImport) => { + if (envImport) { + const key = `${envImport.environment}-${envImport.secretPath}`; + const existing = uniqueMap[key]; + uniqueMap[key] = existing ? [...existing, envImport] : [envImport]; + } + }); + }); + return uniqueMap; + }, [secretImports]); + + const isSecretImportPresent = useCallback( + (sourceEnv: string, targetEnv: string, secretPath: string) => { + return ( + secretImports?.some((importData) => + importData?.some( + (envImport) => + envImport?.currentEnv === sourceEnv && + envImport?.environment === targetEnv && + envImport?.secretPath === secretPath + ) + ) ?? false + ); + }, + [secretImports] + ); + + return { + uniqueEnvSecretPaths, + isSecretImportPresent + }; +}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 78a2380d0..4c8a93279 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -67,15 +67,22 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; +import { SecretImportData } from "@app/hooks/api/secretImports/types"; import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types"; import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; -import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; +import { + useDynamicSecretOverview, + useFolderOverview, + useSecretImportOverview, + useSecretOverview +} from "@app/hooks/utils"; import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; +import { SecretOverviewImportListView } from "./components/SecretOverviewImportListView"; import { SecretNoAccessOverviewTableRow, SecretOverviewTableRow @@ -185,12 +192,22 @@ export const OverviewPage = () => { setVisibleEnvs(userAvailableEnvs); }, [userAvailableEnvs]); - const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } = - useGetImportedSecretsAllEnvs({ - projectId: workspaceId, - path: secretPath, - environments: (userAvailableEnvs || []).map(({ slug }) => slug) - }); + const { + secretImports, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + getEnvImportedSecretKeyCount + } = useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (userAvailableEnvs || []).map(({ slug }) => slug) + }); + const secretImportsData = useMemo( + () => secretImports?.map((s) => s.data as SecretImportData[]) ?? [], + [secretImports] + ); + const { uniqueEnvSecretPaths, isSecretImportPresent } = + useSecretImportOverview(secretImportsData); const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( { @@ -1054,6 +1071,16 @@ export const OverviewPage = () => { key={`overview-${dynamicSecretName}-${index + 1}`} /> ))} + {Object.entries(uniqueEnvSecretPaths).map(([key, secretImportsAllEnvs]) => ( + + ))} {secKeys.map((key, index) => ( boolean; + scrollOffset: number; + allSecretImports: SecretImportData[][] | undefined; +}; + +export const SecretOverviewImportListView = ({ + secretImport, + environments = [], + isImportedSecretPresentInEnv, + scrollOffset, + allSecretImports +}: Props) => { + const [isFormExpanded, setIsFormExpanded] = useToggle(); + const { permission } = useProjectPermission(); + const environmentImportDetails = secretImport.environmentInfo; + const totalCols = environments.length + 1; + + const canReadSecretImports = permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { + environment: environmentImportDetails.slug, + secretPath: secretImport.secretPath + }) + ); + + const computeImportedSecrets = + canReadSecretImports && allSecretImports + ? computeImportedSecretRows( + environmentImportDetails.slug, + secretImport.secretPath, + (allSecretImports?.flatMap((s) => s ?? []) ?? []).filter(Boolean) as SecretImportData[] + ) + : []; + return ( + <> + canReadSecretImports && setIsFormExpanded.toggle()} + className={`group ${isFormExpanded ? "border-t-2 border-mineshaft-500" : ""}`} + > + +
+
+ +
+
+ +
+
+ + {environments.map(({ slug }, i) => { + const isPresent = isImportedSecretPresentInEnv( + slug, + secretImport.environment, + secretImport.secretPath + ); + + return ( + +
+
+ +
+
+ + ); + })} + + {canReadSecretImports && isFormExpanded && ( + + +
+ + + + + + + {/* */} + + + + {computeImportedSecrets?.length === 0 && ( + + + + )} + {computeImportedSecrets.map(({ key, value }, index) => ( + + + + + ))} + +
KeyValueOverride
+ +
+ {key} + + +
+
+
+ + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/index.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/index.tsx new file mode 100644 index 000000000..daf7f71b4 --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/index.tsx @@ -0,0 +1 @@ +export { SecretOverviewImportListView } from "./SecretOverviewImportListView"; From e67a8f9c05e44dcd929001d37e3df28b6720c266 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Mon, 17 Mar 2025 22:23:31 -0300 Subject: [PATCH 2/6] Add filter imports and minor improvements to allSecretImports logic --- frontend/src/hooks/api/secretImports/types.ts | 13 ++++- frontend/src/hooks/utils/secrets-overview.tsx | 32 +++++----- .../OverviewPage/OverviewPage.tsx | 58 ++++++++++++++----- .../SecretOverviewImportListView.tsx | 27 +++------ 4 files changed, 76 insertions(+), 54 deletions(-) diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 83abd300d..7592657c0 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -1,5 +1,4 @@ import { SecretV3Raw } from "../secrets/types"; -import { WsTag } from "../types"; import { WorkspaceEnv } from "../workspace/types"; export type TSecretImport = { @@ -92,7 +91,7 @@ export type TDeleteSecretImportDTO = { path?: string; }; -export type SecretImportData = { +export type TSecretImportMultiEnvData = { currentEnv: string; environment: string; secretPath: string; @@ -104,7 +103,15 @@ export type SecretImportData = { key: string; value: string; secretValueHidden: boolean; - tags?: WsTag[]; + tags?: { + id: string; + slug: string; + color?: string; + projectId: string; + createdAt: string; + updatedAt: string; + __v: number; + }[]; comment?: string; createdAt: string; updatedAt: string; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index 4076cb5c0..155893d4b 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -1,8 +1,7 @@ import { useCallback, useMemo } from "react"; import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types"; - -import { SecretImportData } from "../api/secretImports/types"; +import { TSecretImportMultiEnvData } from "@app/hooks/api/secretImports/types"; type FolderNameAndDescription = { name: string; @@ -99,31 +98,28 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr return { secKeys, getSecretByKey, getEnvSecretKeyCount }; }; -export const useSecretImportOverview = (secretImports: SecretImportData[][] | undefined) => { +export const useSecretImportOverview = (secretImports: TSecretImportMultiEnvData[] | undefined) => { const uniqueEnvSecretPaths = useMemo(() => { - const uniqueMap: Record = {}; + const uniqueMap = new Map(); secretImports?.forEach((importData) => { - importData?.forEach((envImport) => { - if (envImport) { - const key = `${envImport.environment}-${envImport.secretPath}`; - const existing = uniqueMap[key]; - uniqueMap[key] = existing ? [...existing, envImport] : [envImport]; + if (importData) { + const key = `${importData.environment}-${importData.secretPath}`; + if (!uniqueMap.has(key)) { + uniqueMap.set(key, importData); } - }); + } }); - return uniqueMap; + return Array.from(uniqueMap.values()); }, [secretImports]); const isSecretImportPresent = useCallback( (sourceEnv: string, targetEnv: string, secretPath: string) => { return ( - secretImports?.some((importData) => - importData?.some( - (envImport) => - envImport?.currentEnv === sourceEnv && - envImport?.environment === targetEnv && - envImport?.secretPath === secretPath - ) + secretImports?.some( + (importData) => + importData?.currentEnv === sourceEnv && + importData?.environment === targetEnv && + importData?.secretPath === secretPath ) ?? false ); }, diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 4c8a93279..29acae74c 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -7,6 +7,7 @@ import { faAngleDown, faArrowDown, faArrowUp, + faFileImport, faFingerprint, faFolder, faFolderBlank, @@ -67,7 +68,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; -import { SecretImportData } from "@app/hooks/api/secretImports/types"; +import { TSecretImportMultiEnvData } from "@app/hooks/api/secretImports/types"; import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types"; import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; import { @@ -100,7 +101,8 @@ export enum EntryType { enum RowType { Folder = "folder", DynamicSecret = "dynamic", - Secret = "secret" + Secret = "secret", + Import = "import" } type Filter = { @@ -110,7 +112,8 @@ type Filter = { const DEFAULT_FILTER_STATE = { [RowType.Folder]: true, [RowType.DynamicSecret]: true, - [RowType.Secret]: true + [RowType.Secret]: true, + [RowType.Import]: true }; export const OverviewPage = () => { @@ -203,7 +206,18 @@ export const OverviewPage = () => { environments: (userAvailableEnvs || []).map(({ slug }) => slug) }); const secretImportsData = useMemo( - () => secretImports?.map((s) => s.data as SecretImportData[]) ?? [], + () => + (secretImports?.map((s) => s.data as TSecretImportMultiEnvData[]) ?? []) + ?.flatMap((s) => s ?? []) + ?.filter((secretImport) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { + environment: secretImport.currentEnv, + secretPath + }) + ) + ), [secretImports] ); const { uniqueEnvSecretPaths, isSecretImportPresent } = @@ -768,6 +782,19 @@ export const OverviewPage = () => { */} Filter project resources + { + e.preventDefault(); + handleToggleRowType(RowType.Import); + }} + icon={filter[RowType.Import] && } + iconPos="right" + > +
+ + Imports +
+
{ e.preventDefault(); @@ -1071,16 +1098,19 @@ export const OverviewPage = () => { key={`overview-${dynamicSecretName}-${index + 1}`} /> ))} - {Object.entries(uniqueEnvSecretPaths).map(([key, secretImportsAllEnvs]) => ( - - ))} + {filter.import && + Object.entries(uniqueEnvSecretPaths).map(([key, secretImportsAllEnvs]) => ( + s ?? []) ?? [] + ).filter(Boolean)} + /> + ))} {secKeys.map((key, index) => ( boolean; scrollOffset: number; - allSecretImports: SecretImportData[][] | undefined; + allSecretImports: TSecretImportMultiEnvData[]; }; export const SecretOverviewImportListView = ({ @@ -27,27 +25,18 @@ export const SecretOverviewImportListView = ({ environments = [], isImportedSecretPresentInEnv, scrollOffset, - allSecretImports + allSecretImports = [] }: Props) => { const [isFormExpanded, setIsFormExpanded] = useToggle(); - const { permission } = useProjectPermission(); const environmentImportDetails = secretImport.environmentInfo; const totalCols = environments.length + 1; - const canReadSecretImports = permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.SecretImports, { - environment: environmentImportDetails.slug, - secretPath: secretImport.secretPath - }) - ); - const computeImportedSecrets = - canReadSecretImports && allSecretImports + allSecretImports.length > 0 ? computeImportedSecretRows( environmentImportDetails.slug, secretImport.secretPath, - (allSecretImports?.flatMap((s) => s ?? []) ?? []).filter(Boolean) as SecretImportData[] + allSecretImports ) : []; return ( @@ -55,7 +44,7 @@ export const SecretOverviewImportListView = ({ canReadSecretImports && setIsFormExpanded.toggle()} + onClick={() => setIsFormExpanded.toggle()} className={`group ${isFormExpanded ? "border-t-2 border-mineshaft-500" : ""}`} > @@ -106,7 +95,7 @@ export const SecretOverviewImportListView = ({ ); })} - {canReadSecretImports && isFormExpanded && ( + {isFormExpanded && ( Date: Tue, 18 Mar 2025 16:26:15 -0300 Subject: [PATCH 3/6] Rework of secret imports on overview page --- backend/src/lib/api-docs/constants.ts | 3 +- .../src/server/routes/v1/dashboard-router.ts | 60 ++++++ .../secret-import/secret-import-service.ts | 86 ++++++++- frontend/src/hooks/api/dashboard/queries.tsx | 8 +- frontend/src/hooks/api/dashboard/types.ts | 4 + frontend/src/hooks/api/secretImports/types.ts | 29 +-- frontend/src/hooks/utils/secrets-overview.tsx | 35 ---- .../OverviewPage/OverviewPage.tsx | 75 +++----- .../SecretOverviewImportListView.tsx | 179 ++++++------------ 9 files changed, 243 insertions(+), 236 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 7a350938f..35ef4e091 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -813,7 +813,8 @@ export const DASHBOARD = { search: "The text string to filter secret keys and folder names by.", includeSecrets: "Whether to include project secrets in the response.", includeFolders: "Whether to include project folders in the response.", - includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", + includeImports: "Whether to include project secret imports in the response." }, SECRET_DETAILS_LIST: { projectId: "The ID of the project to list secrets/folders from.", diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index db61594a2..11b15f688 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -109,6 +109,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(), includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), + includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) }), response: { @@ -124,9 +125,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }) .array() .optional(), + imports: SecretImportsSchema.omit({ importEnv: true }) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }), + environment: z.string() + }) + .array() + .optional(), totalFolderCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), + totalImportCount: z.number().optional(), totalCount: z.number() }) } @@ -143,6 +152,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { orderDirection, includeFolders, includeSecrets, + includeImports, includeDynamicSecrets } = req.query; @@ -159,6 +169,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let remainingLimit = limit; let adjustedOffset = offset; + let imports: Awaited> | undefined; let folders: Awaited> | undefined; let secrets: Awaited> | undefined; let dynamicSecrets: @@ -168,6 +179,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; let totalSecretCount: number | undefined; + let totalImportCount: number | undefined; + + if (includeImports) { + totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environments, + path: secretPath, + search + }); + + if (remainingLimit > 0 && totalImportCount > adjustedOffset) { + imports = await server.services.secretImport.getImportsMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environments, + path: secretPath, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_SECRET_IMPORTS, + metadata: { + environment: environments.join(","), + folderId: imports?.[0]?.folderId, + numberOfImports: imports.length + } + } + }); + + remainingLimit -= imports.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalImportCount); + } + } if (includeFolders) { // this is the unique count, ie duplicate folders across envs only count as 1 @@ -345,8 +403,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { folders, dynamicSecrets, secrets, + imports, totalFolderCount, totalDynamicSecretCount, + totalImportCount, totalSecretCount, totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index b8e8b2fa0..ce03e8e4e 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -469,6 +469,43 @@ export const secretImportServiceFactory = ({ return count; }; + const getProjectImportMultiEnvCount = async ({ + path: secretPath, + environments, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search + }: Omit & { environments: string[] }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + for (const environment of environments) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ); + } + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!folders?.length) + throw new NotFoundError({ + message: `Folder with path '${secretPath}' not found on environments with slugs '${environments.join(", ")}'` + }); + const counts = await Promise.all( + folders.map((folder) => secretImportDAL.getProjectImportCount({ folderId: folder.id, search })) + ); + + return counts.reduce((sum, count) => sum + count, 0); + }; + const getImports = async ({ path: secretPath, environment, @@ -688,6 +725,51 @@ export const secretImportServiceFactory = ({ })); }; + const getImportsMultiEnv = async ({ + path: secretPath, + environments, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search, + limit, + offset + }: Omit & { environments: string[] }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + for (const environment of environments) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ); + } + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!folders?.length) + throw new NotFoundError({ + message: `Folder with path '${secretPath}' not found on environments with slugs '${environments.join(", ")}'` + }); + + const secImportsArrays = await Promise.all( + folders.map(async (folder) => { + const imports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset }); + return imports.map((importItem) => ({ + ...importItem, + environment: folder.environment.slug + })); + }) + ); + return secImportsArrays.flat(); + }; + return { createImport, updateImport, @@ -698,6 +780,8 @@ export const secretImportServiceFactory = ({ getRawSecretsFromImports, resyncSecretImportReplication, getProjectImportCount, - fnSecretsFromImports + fnSecretsFromImports, + getProjectImportMultiEnvCount, + getImportsMultiEnv }; }; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index 1fb456325..ba417430a 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -143,6 +143,7 @@ export const useGetProjectSecretsOverview = ( search = "", includeSecrets, includeFolders, + includeImports, includeDynamicSecrets, environments }: TGetDashboardProjectSecretsOverviewDTO, @@ -170,6 +171,7 @@ export const useGetProjectSecretsOverview = ( projectId, includeSecrets, includeFolders, + includeImports, includeDynamicSecrets, environments }), @@ -184,6 +186,7 @@ export const useGetProjectSecretsOverview = ( projectId, includeSecrets, includeFolders, + includeImports, includeDynamicSecrets, environments }), @@ -197,12 +200,15 @@ export const useGetProjectSecretsOverview = ( ? unique(select.dynamicSecrets, (i) => i.name) : []; + const uniqueSecretImports = select.imports ? unique(select.imports, (i) => i.id) : []; + return { ...select, secrets: secrets ? mergePersonalSecrets(secrets) : undefined, totalUniqueSecretsInPage: uniqueSecrets.length, totalUniqueDynamicSecretsInPage: uniqueDynamicSecrets.length, - totalUniqueFoldersInPage: uniqueFolders.length + totalUniqueFoldersInPage: uniqueFolders.length, + totalUniqueSecretImportsInPage: uniqueSecretImports.length }; }, []), placeholderData: (previousData) => previousData diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 9540c4ae6..bdff878cd 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -9,13 +9,16 @@ export type DashboardProjectSecretsOverviewResponse = { folders?: (TSecretFolder & { environment: string })[]; dynamicSecrets?: (TDynamicSecret & { environment: string })[]; secrets?: SecretV3Raw[]; + imports?: TSecretImport[]; totalSecretCount?: number; totalFolderCount?: number; totalDynamicSecretCount?: number; + totalImportCount?: number; totalCount: number; totalUniqueSecretsInPage: number; totalUniqueDynamicSecretsInPage: number; totalUniqueFoldersInPage: number; + totalUniqueSecretImportsInPage: number; }; export type DashboardProjectSecretsDetailsResponse = { @@ -63,6 +66,7 @@ export type TGetDashboardProjectSecretsOverviewDTO = { includeSecrets?: boolean; includeFolders?: boolean; includeDynamicSecrets?: boolean; + includeImports?: boolean; environments: string[]; }; diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 7592657c0..d950c2ca2 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -14,6 +14,7 @@ export type TSecretImport = { isReplicationSuccess?: boolean; replicationStatus?: string; lastReplicated?: string; + environment?: string; }; export type TGetImportedFoldersByEnvDTO = { @@ -90,31 +91,3 @@ export type TDeleteSecretImportDTO = { environment: string; path?: string; }; - -export type TSecretImportMultiEnvData = { - currentEnv: string; - environment: string; - secretPath: string; - environmentInfo: WorkspaceEnv; - folderId: string; - secrets: { - id: string; - env: string; - key: string; - value: string; - secretValueHidden: boolean; - tags?: { - id: string; - slug: string; - color?: string; - projectId: string; - createdAt: string; - updatedAt: string; - __v: number; - }[]; - comment?: string; - createdAt: string; - updatedAt: string; - version: number; - }[]; -}; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index 155893d4b..89fc679c4 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo } from "react"; import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types"; -import { TSecretImportMultiEnvData } from "@app/hooks/api/secretImports/types"; type FolderNameAndDescription = { name: string; @@ -97,37 +96,3 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr return { secKeys, getSecretByKey, getEnvSecretKeyCount }; }; - -export const useSecretImportOverview = (secretImports: TSecretImportMultiEnvData[] | undefined) => { - const uniqueEnvSecretPaths = useMemo(() => { - const uniqueMap = new Map(); - secretImports?.forEach((importData) => { - if (importData) { - const key = `${importData.environment}-${importData.secretPath}`; - if (!uniqueMap.has(key)) { - uniqueMap.set(key, importData); - } - } - }); - return Array.from(uniqueMap.values()); - }, [secretImports]); - - const isSecretImportPresent = useCallback( - (sourceEnv: string, targetEnv: string, secretPath: string) => { - return ( - secretImports?.some( - (importData) => - importData?.currentEnv === sourceEnv && - importData?.environment === targetEnv && - importData?.secretPath === secretPath - ) ?? false - ); - }, - [secretImports] - ); - - return { - uniqueEnvSecretPaths, - isSecretImportPresent - }; -}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 29acae74c..94ab8c26d 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -68,15 +68,9 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; -import { TSecretImportMultiEnvData } from "@app/hooks/api/secretImports/types"; import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types"; import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; -import { - useDynamicSecretOverview, - useFolderOverview, - useSecretImportOverview, - useSecretOverview -} from "@app/hooks/utils"; +import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; @@ -195,33 +189,12 @@ export const OverviewPage = () => { setVisibleEnvs(userAvailableEnvs); }, [userAvailableEnvs]); - const { - secretImports, - isImportedSecretPresentInEnv, - getImportedSecretByKey, - getEnvImportedSecretKeyCount - } = useGetImportedSecretsAllEnvs({ - projectId: workspaceId, - path: secretPath, - environments: (userAvailableEnvs || []).map(({ slug }) => slug) - }); - const secretImportsData = useMemo( - () => - (secretImports?.map((s) => s.data as TSecretImportMultiEnvData[]) ?? []) - ?.flatMap((s) => s ?? []) - ?.filter((secretImport) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.SecretImports, { - environment: secretImport.currentEnv, - secretPath - }) - ) - ), - [secretImports] - ); - const { uniqueEnvSecretPaths, isSecretImportPresent } = - useSecretImportOverview(secretImportsData); + const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } = + useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (userAvailableEnvs || []).map(({ slug }) => slug) + }); const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( { @@ -233,6 +206,7 @@ export const OverviewPage = () => { includeFolders: filter.folder, includeDynamicSecrets: filter.dynamic, includeSecrets: filter.secret, + includeImports: filter.import, search: debouncedSearchFilter, limit, offset @@ -244,15 +218,29 @@ export const OverviewPage = () => { secrets, folders, dynamicSecrets, + imports, totalFolderCount, totalSecretCount, totalDynamicSecretCount, + totalImportCount, totalCount = 0, totalUniqueFoldersInPage, totalUniqueSecretsInPage, + totalUniqueSecretImportsInPage, totalUniqueDynamicSecretsInPage } = overview ?? {}; + const importsShaped = imports + ?.filter((el) => !el.isReserved) + ?.map(({ importPath, importEnv }) => ({ importPath, importEnv })) + .filter( + (el, index, self) => + index === + self.findIndex( + (item) => item.importPath === el.importPath && item.importEnv.slug === el.importEnv.slug + ) + ); + useResetPageHelper({ totalCount, offset, @@ -693,7 +681,6 @@ export const OverviewPage = () => { ); - return (
@@ -1099,16 +1086,14 @@ export const OverviewPage = () => { /> ))} {filter.import && - Object.entries(uniqueEnvSecretPaths).map(([key, secretImportsAllEnvs]) => ( + importsShaped && + importsShaped?.length > 0 && + importsShaped?.map((item, index) => ( s ?? []) ?? [] - ).filter(Boolean)} + key={`overview-secret-input-${index + 1}`} + allSecretImports={imports} /> ))} {secKeys.map((key, index) => ( @@ -1134,7 +1119,8 @@ export const OverviewPage = () => { (page * perPage > totalCount ? totalCount % perPage : perPage) - (totalUniqueFoldersInPage || 0) - (totalUniqueDynamicSecretsInPage || 0) - - (totalUniqueSecretsInPage || 0), + (totalUniqueSecretsInPage || 0) - + (totalUniqueSecretImportsInPage || 0), 0 )} /> @@ -1174,6 +1160,7 @@ export const OverviewPage = () => { dynamicSecretCount={totalDynamicSecretCount} secretCount={totalSecretCount} folderCount={totalFolderCount} + importCount={totalImportCount} /> } className="rounded-b-md border-t border-solid border-t-mineshaft-600" diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx index fb1982fa7..b92ea6dce 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx @@ -1,149 +1,76 @@ -import { faCheck, faFileImport, faKey, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faFileImport, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { EmptyState, SecretInput, TableContainer, Td, Tr } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; -import { TSecretImportMultiEnvData } from "@app/hooks/api/secretImports/types"; +import { Td, Tr } from "@app/components/v2"; +import { TSecretImport, WorkspaceEnv } from "@app/hooks/api/types"; import { EnvFolderIcon } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportItem"; -import { computeImportedSecretRows } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView"; type Props = { - secretImport: TSecretImportMultiEnvData; + secretImport: { importPath: string; importEnv: WorkspaceEnv }; environments: { name: string; slug: string }[]; - isImportedSecretPresentInEnv: ( - sourceEnv: string, - targetEnv: string, - secretPath: string - ) => boolean; - scrollOffset: number; - allSecretImports: TSecretImportMultiEnvData[]; + allSecretImports?: TSecretImport[]; }; export const SecretOverviewImportListView = ({ secretImport, environments = [], - isImportedSecretPresentInEnv, - scrollOffset, allSecretImports = [] }: Props) => { - const [isFormExpanded, setIsFormExpanded] = useToggle(); - const environmentImportDetails = secretImport.environmentInfo; - const totalCols = environments.length + 1; + const isSecretPresentInEnv = (envSlug: string) => { + return allSecretImports.some((item) => { + if (item.isReplication) { + const reservedItem = allSecretImports.find((element) => + element.importPath.includes(`__reserve_replication_${item.id}`) + ); + // If the reserved item exists, check if the envSlug matches + if (reservedItem) { + return reservedItem.environment === envSlug; + } + } else { + // If the item is not replication, check if the envSlug matches directly + return item.environment === envSlug; + } + return false; + }); + }; - const computeImportedSecrets = - allSecretImports.length > 0 - ? computeImportedSecretRows( - environmentImportDetails.slug, - secretImport.secretPath, - allSecretImports - ) - : []; return ( - <> - setIsFormExpanded.toggle()} - className={`group ${isFormExpanded ? "border-t-2 border-mineshaft-500" : ""}`} - > - -
-
- -
-
- -
+ + +
+
+
- - {environments.map(({ slug }, i) => { - const isPresent = isImportedSecretPresentInEnv( - slug, - secretImport.environment, - secretImport.secretPath - ); - - return ( - -
-
- -
-
- - ); - })} - - {isFormExpanded && ( - +
+ +
+
+ + {environments.map(({ slug }, i) => { + const isPresent = isSecretPresentInEnv(slug); + return ( -
- - - - - - - {/* */} - - - - {computeImportedSecrets?.length === 0 && ( - - - - )} - {computeImportedSecrets.map(({ key, value }, index) => ( - - - - - ))} - -
KeyValueOverride
- -
- {key} - - -
-
+
+
+ +
- - )} - + ); + })} + ); }; From a3e9392a2fe4c6e85d87183ee867ce149f267980 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Tue, 18 Mar 2025 16:34:31 -0300 Subject: [PATCH 4/6] Fix totalCount missing import count --- backend/src/server/routes/v1/dashboard-router.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 11b15f688..d976cbc1b 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -408,7 +408,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalDynamicSecretCount, totalImportCount, totalSecretCount, - totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + totalCount: + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0) }; } }); From af236ba89201e32a9a2494e5f07b1e8c1d63e556 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 19 Mar 2025 15:30:05 -0300 Subject: [PATCH 5/6] Avoid throwing forbidden on non accessible resources and return an empty response for those --- .../secret-import/secret-import-service.ts | 33 ++++++++++++++++--- .../src/hooks/api/secretImports/queries.tsx | 1 - 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index ce03e8e4e..fdd326b5f 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -487,7 +487,22 @@ export const secretImportServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); + const filteredEnvironments = []; for (const environment of environments) { + if ( + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ) + ) { + filteredEnvironments.push(environment); + } + } + if (filteredEnvironments.length === 0) { + return 0; + } + + for (const environment of filteredEnvironments) { ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) @@ -745,14 +760,22 @@ export const secretImportServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); + const filteredEnvironments = []; for (const environment of environments) { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) - ); + if ( + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) + ) + ) { + filteredEnvironments.push(environment); + } + } + if (filteredEnvironments.length === 0) { + return []; } - const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, filteredEnvironments, secretPath); if (!folders?.length) throw new NotFoundError({ message: `Folder with path '${secretPath}' not found on environments with slugs '${environments.join(", ")}'` diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 828bd52e1..dfc8c6497 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -167,7 +167,6 @@ export const useGetImportedSecretsAllEnvs = ({ select: useCallback( (data: Awaited>) => data.map((el) => ({ - currentEnv: env, environment: el.environment, secretPath: el.secretPath, environmentInfo: el.environmentInfo, From d7fd44b845bc197f961d4f64e81604127b12e36b Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 21 Mar 2025 15:06:17 -0300 Subject: [PATCH 6/6] Fix secret imports issue on isSecretPresentInEnv validation --- .../SecretOverviewImportListView.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx index b92ea6dce..e646362b8 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewImportListView/SecretOverviewImportListView.tsx @@ -20,16 +20,25 @@ export const SecretOverviewImportListView = ({ const isSecretPresentInEnv = (envSlug: string) => { return allSecretImports.some((item) => { if (item.isReplication) { - const reservedItem = allSecretImports.find((element) => - element.importPath.includes(`__reserve_replication_${item.id}`) - ); - // If the reserved item exists, check if the envSlug matches - if (reservedItem) { - return reservedItem.environment === envSlug; + if ( + item.importPath === secretImport.importPath && + item.importEnv.slug === secretImport.importEnv.slug + ) { + const reservedItem = allSecretImports.find((element) => + element.importPath.includes(`__reserve_replication_${item.id}`) + ); + // If the reserved item exists, check if the envSlug matches + if (reservedItem) { + return reservedItem.environment === envSlug; + } } } else { // If the item is not replication, check if the envSlug matches directly - return item.environment === envSlug; + return ( + item.environment === envSlug && + item.importPath === secretImport.importPath && + item.importEnv.slug === secretImport.importEnv.slug + ); } return false; }); @@ -37,7 +46,7 @@ export const SecretOverviewImportListView = ({ return ( - +