diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index d48aa1dfd..4b1566c55 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -270,7 +270,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const dynamicSecretCfgs = await server.services.dynamicSecret.list({ + const dynamicSecretCfgs = await server.services.dynamicSecret.listDynamicSecretsByEnv({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts index 0cc4aca2f..e47d9102d 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts @@ -1,10 +1,70 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export type TDynamicSecretDALFactory = ReturnType; export const dynamicSecretDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.DynamicSecret); - return orm; + + // find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination) + const listDynamicSecretsByFolderIds = async ( + { + folderIds, + search, + limit, + offset = 0, + orderBy = SecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: { + folderIds: string[]; + search?: string; + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + }, + tx?: Knex + ) => { + try { + const query = (tx || db.replicaNode())(TableName.DynamicSecret) + .whereIn("folderId", folderIds) + .where((bd) => { + if (search) { + void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`); + } + }) + .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`) + .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .select( + selectAllTableCols(TableName.DynamicSecret), + db.ref("slug").withSchema(TableName.Environment).as("environment"), + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`) + ) + .orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection); + + if (limit) { + const rankOffset = offset + 1; + return await (tx || db) + .with("w", query) + .select("*") + .from[number]>("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + } + + const dynamicSecrets = await query; + + return dynamicSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "List dynamic secret multi env" }); + } + }; + + return { ...orm, listDynamicSecretsByFolderIds }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index c44b2fecd..3f6492571 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; +import { OrderByDirection } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; @@ -17,7 +18,9 @@ import { TCreateDynamicSecretDTO, TDeleteDynamicSecretDTO, TDetailsDynamicSecretDTO, + TGetDynamicSecretsCountDTO, TListDynamicSecretsDTO, + TListDynamicSecretsMultiEnvDTO, TUpdateDynamicSecretDTO } from "./dynamic-secret-types"; import { AzureEntraIDProvider } from "./providers/azure-entra-id"; @@ -32,7 +35,7 @@ type TDynamicSecretServiceFactoryDep = { "pruneDynamicSecret" | "unsetLeaseRevocation" >; licenseService: Pick; - folderDAL: Pick; + folderDAL: Pick; projectDAL: Pick; permissionService: Pick; }; @@ -301,19 +304,55 @@ export const dynamicSecretServiceFactory = ({ return { ...dynamicSecretCfg, inputs: providerInputs }; }; - const list = async ({ + // get unique dynamic secret count across multiple envs + const getCountMultiEnv = async ({ actorAuthMethod, actorOrgId, actorId, actor, - projectSlug, + projectId, path, - environmentSlug - }: TListDynamicSecretsDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new BadRequestError({ message: "Project not found" }); + environmentSlugs, + search + }: TListDynamicSecretsMultiEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); - const projectId = project.id; + // verify user has access to each env in request + environmentSlugs.forEach((environmentSlug) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path); + if (!folders.length) throw new BadRequestError({ message: "Folders not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find( + { $in: { folderId: folders.map((folder) => folder.id) }, $search: search ? { name: `%${search}%` } : undefined }, + { countDistinct: "name" } + ); + + return Number(dynamicSecretCfg[0]?.count ?? 0); + }; + + // get dynamic secret count for a single env + const getDynamicSecretCount = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + path, + environmentSlug, + search, + projectId + }: TGetDynamicSecretsCountDTO) => { const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -329,7 +368,98 @@ export const dynamicSecretServiceFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) throw new BadRequestError({ message: "Folder not found" }); - const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id }); + const dynamicSecretCfg = await dynamicSecretDAL.find( + { folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined }, + { count: true } + ); + return Number(dynamicSecretCfg[0]?.count ?? 0); + }; + + const listDynamicSecretsByEnv = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + path, + environmentSlug, + limit, + offset, + orderBy, + orderDirection = OrderByDirection.ASC, + search, + ...params + }: TListDynamicSecretsDTO) => { + let { projectId } = params; + + if (!projectId) { + if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" }); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + projectId = project.id; + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find( + { folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined }, + { + limit, + offset, + sort: orderBy ? [[orderBy, orderDirection]] : undefined + } + ); + return dynamicSecretCfg; + }; + + // get dynamic secrets for multiple envs + const listDynamicSecretsByFolderIds = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + path, + environmentSlugs, + projectId, + ...params + }: TListDynamicSecretsMultiEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // verify user has access to each env in request + environmentSlugs.forEach((environmentSlug) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path); + if (!folders.length) throw new BadRequestError({ message: "Folders not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.listDynamicSecretsByFolderIds({ + folderIds: folders.map((folder) => folder.id), + ...params + }); + return dynamicSecretCfg; }; @@ -355,7 +485,10 @@ export const dynamicSecretServiceFactory = ({ updateByName, deleteByName, getDetails, - list, + listDynamicSecretsByEnv, + listDynamicSecretsByFolderIds, + getDynamicSecretCount, + getCountMultiEnv, fetchAzureEntraIdUsers }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 02f2cbb86..426135a4c 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { DynamicSecretProviderSchema } from "./providers/models"; @@ -50,5 +51,20 @@ export type TDetailsDynamicSecretDTO = { export type TListDynamicSecretsDTO = { path: string; environmentSlug: string; - projectSlug: string; + projectSlug?: string; + projectId?: string; + offset?: number; + limit?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; } & Omit; + +export type TListDynamicSecretsMultiEnvDTO = Omit< + TListDynamicSecretsDTO, + "projectId" | "environmentSlug" | "projectSlug" +> & { projectId: string; environmentSlugs: string[] }; + +export type TGetDynamicSecretsCountDTO = Omit & { + projectId: string; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 5ed7ed8f2..3bfe66718 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -697,6 +697,38 @@ export const SECRET_IMPORTS = { } } as const; +export const DASHBOARD = { + SECRET_OVERVIEW_LIST: { + projectId: "The ID of the project to list secrets/folders from.", + environments: + "The slugs of the environments to list secrets/folders from (comma separated, ie 'environments=dev,staging,prod').", + secretPath: "The secret path to list secrets/folders from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.", + limit: "The number of secrets/folders to return.", + orderBy: "The column to order secrets/folders by.", + orderDirection: "The direction to order secrets/folders in.", + 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." + }, + SECRET_DETAILS_LIST: { + projectId: "The ID of the project to list secrets/folders from.", + environment: "The slug of the environment to list secrets/folders from.", + secretPath: "The secret path to list secrets/folders from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.", + limit: "The number of secrets/folders to return.", + orderBy: "The column to order secrets/folders by.", + orderDirection: "The direction to order secrets/folders in.", + search: "The text string to filter secret keys and folder names by.", + tags: "The tags to filter secrets by (comma separated, ie 'tags=billing,engineering').", + includeSecrets: "Whether to include project secrets in the response.", + includeFolders: "Whether to include project folders in the response.", + includeImports: "Whether to include project secret imports in the response.", + includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + } +} as const; + export const AUDIT_LOGS = { EXPORT: { workspaceId: "The ID of the project to export audit logs from.", diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index cbf5db3b0..36d81ae34 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -51,11 +51,17 @@ export type TFindReturn; -export type TFindOpt = { +export type TFindOpt< + R extends object = object, + TCount extends boolean = boolean, + TCountDistinct extends keyof R | undefined = undefined +> = { limit?: number; offset?: number; sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>; + groupBy?: keyof R; count?: TCount; + countDistinct?: TCountDistinct; tx?: Knex; }; @@ -86,13 +92,18 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Find one" }); } }, - find: async ( + find: async < + TCount extends boolean = false, + TCountDistinct extends keyof Tables[Tname]["base"] | undefined = undefined + >( filter: TFindFilter, - { offset, limit, sort, count, tx }: TFindOpt = {} + { offset, limit, sort, count, tx, countDistinct }: TFindOpt = {} ) => { try { const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter)); - if (count) { + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { void query.select(db.raw("COUNT(*) OVER() AS count")); void query.select("*"); } @@ -101,7 +112,8 @@ export const ormify = (db: Kne if (sort) { void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); } - const res = (await query) as TFindReturn; + + const res = (await query) as TFindReturn; return res; } catch (error) { throw new DatabaseError({ error, name: "Find one" }); diff --git a/backend/src/server/routes/v3/dashboard-router.ts b/backend/src/server/routes/v3/dashboard-router.ts new file mode 100644 index 000000000..7d06b1ce7 --- /dev/null +++ b/backend/src/server/routes/v3/dashboard-router.ts @@ -0,0 +1,612 @@ +import { z } from "zod"; + +import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { DASHBOARD } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { OrderByDirection } from "@app/lib/types"; +import { secretsLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; +import { getUserAgentType } from "@app/server/plugins/audit-log"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedDynamicSecretSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerDashboardRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/secrets-overview", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "List project secrets overview", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.projectId), + environments: z + .string() + .trim() + .transform(decodeURIComponent) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.environments), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.secretPath), + offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_OVERVIEW_LIST.offset), + limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_OVERVIEW_LIST.limit), + orderBy: z + .nativeEnum(SecretsOrderBy) + .default(SecretsOrderBy.Name) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderDirection) + .optional(), + search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(), + includeSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), + includeFolders: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), + includeDynamicSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(), + dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(), + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional() + }) + .array() + .optional(), + totalFolderCount: z.number().optional(), + totalDynamicSecretCount: z.number().optional(), + totalSecretCount: z.number().optional(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + secretPath, + projectId, + limit, + offset, + search, + orderBy, + orderDirection, + includeFolders, + includeSecrets, + includeDynamicSecrets + } = req.query; + + const environments = req.query.environments.split(","); + + if (!projectId || environments.length === 0) + throw new BadRequestError({ message: "Missing workspace id or environment(s)" }); + + const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId); + + // prevent older projects from accessing endpoint + if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + + let remainingLimit = limit; + let adjustedOffset = offset; + + let folders: Awaited> | undefined; + let secrets: Awaited> | undefined; + let dynamicSecrets: + | Awaited> + | undefined; + + let totalFolderCount: number | undefined; + let totalDynamicSecretCount: number | undefined; + let totalSecretCount: number | undefined; + + if (includeFolders) { + // this is the unique count, ie duplicate folders across envs only count as 1 + totalFolderCount = await server.services.folder.getProjectFolderCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId, + path: secretPath, + environments, + search + }); + + if (remainingLimit > 0 && totalFolderCount > adjustedOffset) { + folders = await server.services.folder.getFoldersMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environments, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + // get the count of unique folder names to properly adjust remaining limit + const uniqueFolderCount = new Set(folders.map((folder) => folder.name)).size; + + remainingLimit -= uniqueFolderCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount); + } + } + + if (includeDynamicSecrets) { + // this is the unique count, ie duplicate secrets across envs only count as 1 + totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + environmentSlugs: environments, + path: secretPath + }); + + if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) { + dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByFolderIds({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + orderBy, + orderDirection, + environmentSlugs: environments, + path: secretPath, + limit: remainingLimit, + offset: adjustedOffset + }); + + // get the count of unique dynamic secret names to properly adjust remaining limit + const uniqueDynamicSecretsCount = new Set(dynamicSecrets.map((dynamicSecret) => dynamicSecret.name)).size; + + remainingLimit -= uniqueDynamicSecretsCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount); + } + } + + if (includeSecrets) { + // this is the unique count, ie duplicate secrets across envs only count as 1 + totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environments, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + search + }); + + if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { + secrets = await server.services.secret.getSecretsRawMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environments, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + for await (const environment of environments) { + const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length; + + if (secretCountFromEnv) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCountFromEnv + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCountFromEnv, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + } + } + } + + return { + folders, + dynamicSecrets, + secrets, + totalFolderCount, + totalDynamicSecretCount, + totalSecretCount, + totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + }; + } + }); + + server.route({ + method: "GET", + url: "/secrets-details", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "List project secrets details", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.projectId), + environment: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DASHBOARD.SECRET_DETAILS_LIST.secretPath), + offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_DETAILS_LIST.offset), + limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_DETAILS_LIST.limit), + orderBy: z + .nativeEnum(SecretsOrderBy) + .default(SecretsOrderBy.Name) + .describe(DASHBOARD.SECRET_DETAILS_LIST.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(DASHBOARD.SECRET_DETAILS_LIST.orderDirection) + .optional(), + search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(), + tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(), + includeSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), + includeFolders: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), + includeDynamicSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), + includeImports: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports) + }), + response: { + 200: z.object({ + imports: SecretImportsSchema.omit({ importEnv: true }) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + .array() + .optional(), + folders: SecretFoldersSchema.array().optional(), + dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(), + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional() + }) + .array() + .optional(), + totalImportCount: z.number().optional(), + totalFolderCount: z.number().optional(), + totalDynamicSecretCount: z.number().optional(), + totalSecretCount: z.number().optional(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + secretPath, + environment, + projectId, + limit, + offset, + search, + orderBy, + orderDirection, + includeFolders, + includeSecrets, + includeDynamicSecrets, + includeImports + } = req.query; + + if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + + const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId); + + // prevent older projects from accessing endpoint + if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + + const tags = req.query.tags?.split(",") ?? []; + + let remainingLimit = limit; + let adjustedOffset = offset; + + let imports: Awaited> | undefined; + let folders: Awaited> | undefined; + let secrets: Awaited>["secrets"] | undefined; + let dynamicSecrets: Awaited> | undefined; + + let totalImportCount: number | undefined; + let totalFolderCount: number | undefined; + let totalDynamicSecretCount: number | undefined; + let totalSecretCount: number | undefined; + + if (includeImports) { + totalImportCount = await server.services.secretImport.getProjectImportCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environment, + path: secretPath, + search + }); + + if (remainingLimit > 0 && totalImportCount > adjustedOffset) { + imports = await server.services.secretImport.getImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environment, + 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, + folderId: imports?.[0]?.folderId, + numberOfImports: imports.length + } + } + }); + + remainingLimit -= imports.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalImportCount); + } + } + + if (includeFolders) { + totalFolderCount = await server.services.folder.getProjectFolderCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + path: secretPath, + environments: [environment], + search + }); + + if (remainingLimit > 0 && totalFolderCount > adjustedOffset) { + folders = await server.services.folder.getFolders({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environment, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + remainingLimit -= folders.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount); + } + } + + if (includeDynamicSecrets) { + totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + environmentSlug: environment, + path: secretPath + }); + + if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) { + dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByEnv({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + orderBy, + orderDirection, + environmentSlug: environment, + path: secretPath, + limit: remainingLimit, + offset: adjustedOffset + }); + + remainingLimit -= dynamicSecrets.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount); + } + } + + if (includeSecrets) { + totalSecretCount = await server.services.secret.getSecretsCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + search, + tagSlugs: tags + }); + + if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { + const secretsRaw = await server.services.secret.getSecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset, + tagSlugs: tags + }); + + secrets = secretsRaw.secrets; + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secrets.length + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + } + + return { + imports, + folders, + dynamicSecrets, + secrets, + totalImportCount, + totalFolderCount, + totalDynamicSecretCount, + totalSecretCount, + totalCount: + (totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + }; + } + }); +}; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 6a061a635..f77c25c73 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerDashboardRouter } from "./dashboard-router"; import { registerLoginRouter } from "./login-router"; import { registerSecretBlindIndexRouter } from "./secret-blind-index-router"; import { registerSecretRouter } from "./secret-router"; @@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" }); + await server.register(registerDashboardRouter, { prefix: "/dashboard" }); }; diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index a2a01be68..6365bd824 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -5,6 +5,8 @@ import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate } import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { groupBy, removeTrailingSlash } from "@app/lib/fn"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export const validateFolderName = (folderName: string) => { const validNameRegex = /^[a-zA-Z0-9-_]+$/; @@ -83,7 +85,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str .from("parent"); }; -const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: string, secretPath: string) => { +const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: string[], secretPath: string) => { // this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2 const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath; // next goal to sanitize saw the raw sql query is safe @@ -111,7 +113,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri projectId, parentId: null }) - .where(`${TableName.Environment}.slug`, environment) + .whereIn(`${TableName.Environment}.slug`, environments) .select(selectAllTableCols(TableName.SecretFolder)) .union( (qb) => @@ -139,14 +141,14 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri .from("parent") .leftJoin(TableName.Environment, `${TableName.Environment}.id`, "parent.envId") .select< - TSecretFolders & { + (TSecretFolders & { depth: number; path: string; envId: string; envSlug: string; envName: string; projectId: string; - } + })[] >( selectAllTableCols("parent" as TableName.SecretFolder), db.ref("id").withSchema(TableName.Environment).as("envId"), @@ -214,7 +216,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { const folder = await sqlFindFolderByPathQuery( tx || db.replicaNode(), projectId, - environment, + [environment], removeTrailingSlash(path) ) .orderBy("depth", "desc") @@ -230,6 +232,35 @@ export const secretFolderDALFactory = (db: TDbClient) => { } }; + // finds folders by path for multiple envs + const findBySecretPathMultiEnv = async (projectId: string, environments: string[], path: string, tx?: Knex) => { + try { + const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1; + + const folders = await sqlFindFolderByPathQuery( + tx || db.replicaNode(), + projectId, + environments, + removeTrailingSlash(path) + ) + .orderBy("depth", "desc") + .where("depth", pathDepth); + + const firstFolder = folders[0]; + + if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) { + return []; + } + + return folders.map((folder) => { + const { envId: id, envName: name, envSlug: slug, ...el } = folder; + return { ...el, envId: id, environment: { id, name, slug } }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find folders by secret path multi env" }); + } + }; + // used in folder creation // even if its the original given /path1/path2 // it will stop automatically at /path2 @@ -238,7 +269,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { const folder = await sqlFindFolderByPathQuery( tx || db.replicaNode(), projectId, - environment, + [environment], removeTrailingSlash(path) ) .orderBy("depth", "desc") @@ -352,14 +383,77 @@ export const secretFolderDALFactory = (db: TDbClient) => { } }; + // find project folders for multiple envs + const findByMultiEnv = async ( + { + environmentIds, + parentIds, + search, + limit, + offset = 0, + orderBy = SecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: { + environmentIds: string[]; + parentIds: string[]; + search?: string; + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + }, + tx?: Knex + ) => { + try { + const query = (tx || db.replicaNode())(TableName.SecretFolder) + .whereIn("parentId", parentIds) + .whereIn("envId", environmentIds) + .where("isReserved", false) + .where((bd) => { + if (search) { + void bd.whereILike(`${TableName.SecretFolder}.name`, `%${search}%`); + } + }) + .leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .select( + selectAllTableCols(TableName.SecretFolder), + db.raw( + `DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${ + orderDirection ?? OrderByDirection.ASC + }) as rank` + ), + db.ref("slug").withSchema(TableName.Environment).as("environment") + ) + .orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection); + + if (limit) { + const rankOffset = offset + 1; // ranks start from 1 + return await (tx || db) + .with("w", query) + .select("*") + .from[number]>("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + } + + const folders = await query; + + return folders; + } catch (error) { + throw new DatabaseError({ error, name: "Find folders multi env" }); + } + }; + return { ...secretFolderOrm, update, findBySecretPath, + findBySecretPathMultiEnv, findById, findByManySecretPath, findSecretPathByFolderIds, findClosestFolder, - findByProjectId + findByProjectId, + findByMultiEnv }; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 45b5205b2..d63518a80 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -7,6 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrderByDirection } from "@app/lib/types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -26,7 +27,7 @@ type TSecretFolderServiceFactoryDep = { permissionService: Pick; snapshotService: Pick; folderDAL: TSecretFolderDALFactory; - projectEnvDAL: Pick; + projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; projectDAL: Pick; }; @@ -396,7 +397,12 @@ export const secretFolderServiceFactory = ({ actorOrgId, actorAuthMethod, environment, - path: secretPath + path: secretPath, + search, + orderBy, + orderDirection, + limit, + offset }: TGetFolderDTO) => { // folder list is allowed to be read by anyone // permission to check does user has access @@ -408,11 +414,92 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!parentFolder) return []; - const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false }); + const folders = await folderDAL.find( + { + envId: env.id, + parentId: parentFolder.id, + isReserved: false, + $search: search ? { name: `%${search}%` } : undefined + }, + { + sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined, + limit, + offset + } + ); + return folders; + }; + + // get folders for multiple envs + const getFoldersMultiEnv = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + path: secretPath, + ...params + }: Omit & { environments: string[] }) => { + // folder list is allowed to be read by anyone + // permission to check does user has access + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); + + const envs = await projectEnvDAL.findBySlugs(projectId, environments); + + if (!envs.length) + throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" }); + + const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!parentFolders.length) return []; + + const folders = await folderDAL.findByMultiEnv({ + environmentIds: envs.map((env) => env.id), + parentIds: parentFolders.map((folder) => folder.id), + ...params + }); return folders; }; + // get the unique count of folders within a project path + const getProjectFolderCount = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + path: secretPath, + search + }: Omit & { environments: string[] }) => { + // folder list is allowed to be read by anyone + // permission to check does user has access + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); + + const envs = await projectEnvDAL.findBySlugs(projectId, environments); + + if (!envs.length) + throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" }); + + const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!parentFolders.length) return 0; + + const folders = await folderDAL.find( + { + $in: { + envId: envs.map((env) => env.id), + parentId: parentFolders.map((folder) => folder.id) + }, + isReserved: false, + $search: search ? { name: `%${search}%` } : undefined + }, + { countDistinct: "name" } + ); + + return Number(folders[0]?.count ?? 0); + }; + const getFolderById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id }: TGetFolderByIdDTO) => { const folder = await folderDAL.findById(id); if (!folder) throw new NotFoundError({ message: "folder not found" }); @@ -429,6 +516,8 @@ export const secretFolderServiceFactory = ({ updateManyFolders, deleteFolder, getFolders, - getFolderById + getFolderById, + getProjectFolderCount, + getFoldersMultiEnv }; }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 5c80b9582..a6ddf3688 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -1,4 +1,5 @@ -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export enum ReservedFolders { SecretReplication = "__reserve_replication_" @@ -36,6 +37,11 @@ export type TDeleteFolderDTO = { export type TGetFolderDTO = { environment: string; path: string; + search?: string; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + limit?: number; + offset?: number; } & TProjectPermission; export type TGetFolderByIdDTO = { diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index 9a7c7e4dc..893a6d6b4 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -49,10 +49,30 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; - const find = async (filter: Partial, tx?: Knex) => { + const find = async ( + { + search, + limit, + offset, + ...filter + }: Partial< + TSecretImports & { + projectId: string; + search?: string; + limit?: number; + offset?: number; + } + >, + tx?: Knex + ) => { try { - const docs = await (tx || db.replicaNode())(TableName.SecretImport) + const query = (tx || db.replicaNode())(TableName.SecretImport) .where(filter) + .where((bd) => { + if (search) { + void bd.whereILike("importPath", `%${search}%`); + } + }) .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) .select( db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, @@ -61,6 +81,13 @@ export const secretImportDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.Environment).as("envId") ) .orderBy("position", "asc"); + + if (limit) { + void query.limit(limit).offset(offset ?? 0); + } + + const docs = await query; + return docs.map(({ envId, slug, name, ...el }) => ({ ...el, importEnv: { id: envId, slug, name } @@ -70,6 +97,28 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; + const getProjectImportCount = async ( + { search, ...filter }: Partial, + tx?: Knex + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretImport) + .where(filter) + .where("isReplication", false) + .where((bd) => { + if (search) { + void bd.whereILike("importPath", `%${search}%`); + } + }) + .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) + .count(); + + return Number(docs[0]?.count ?? 0); + } catch (error) { + throw new DatabaseError({ error, name: "get secret imports count" }); + } + }; + const findByFolderIds = async (folderIds: string[], tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.SecretImport) @@ -97,6 +146,7 @@ export const secretImportDALFactory = (db: TDbClient) => { find, findByFolderIds, findLastImportPosition, - updateAllPosition + updateAllPosition, + getProjectImportCount }; }; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 981a1bf62..b21a6c3ce 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -220,7 +220,7 @@ export const fnSecretsV2FromImports = async ({ const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId); const processedImports = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => { - const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0]; + const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0]; const folderDeeperImportSecrets = secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || []; const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 45cecf03d..f4dd03bf3 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; @@ -394,6 +394,36 @@ export const secretImportServiceFactory = ({ return { message: "replication started" }; }; + const getProjectImportCount = async ({ + path: secretPath, + environment, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search + }: TGetSecretImportsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) throw new NotFoundError({ message: "Folder not found", name: "Get imports" }); + + const count = await secretImportDAL.getProjectImportCount({ folderId: folder.id, search }); + + return count; + }; + const getImports = async ({ path: secretPath, environment, @@ -401,7 +431,10 @@ export const secretImportServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + search, + limit, + offset }: TGetSecretImportsDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -418,7 +451,7 @@ export const secretImportServiceFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" }); - const secImports = await secretImportDAL.find({ folderId: folder.id }); + const secImports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset }); return secImports; }; @@ -535,6 +568,7 @@ export const secretImportServiceFactory = ({ getSecretsFromImports, getRawSecretsFromImports, resyncSecretImportReplication, + getProjectImportCount, fnSecretsFromImports }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index 01847738b..0a72c4da2 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -32,6 +32,9 @@ export type TDeleteSecretImportDTO = { export type TGetSecretImportsDTO = { environment: string; path: string; + search?: string; + limit?: number; + offset?: number; } & TProjectPermission; export type TGetSecretsFromImportDTO = { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index fe4d89bac..b04915abe 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -5,6 +5,8 @@ import { TDbClient } from "@app/db"; import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export type TSecretV2BridgeDALFactory = ReturnType; @@ -181,7 +183,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => { + // get unique secret count by folder IDs + const countByFolderIds = async ( + folderIds: string[], + userId?: string, + tx?: Knex, + filters?: { + search?: string; + tagSlugs?: string[]; + } + ) => { try { // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) if (userId && !uuidValidate(userId)) { @@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId = undefined; } - const secs = await (tx || db.replicaNode())(TableName.SecretV2) + const query = (tx || db.replicaNode())(TableName.SecretV2) .whereIn("folderId", folderIds) + .where((bd) => { + if (filters?.search) { + void bd.whereILike("key", `%${filters?.search}%`); + } + }) + .where((bd) => { + void bd.whereNull("userId").orWhere({ userId: userId || null }); + }) + .countDistinct("key"); + + // only need to join tags if filtering by tag slugs + const slugs = filters?.tagSlugs?.filter(Boolean); + if (slugs && slugs.length > 0) { + void query + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .whereIn("slug", slugs); + } + + const secrets = await query; + + return Number(secrets[0]?.count ?? 0); + } catch (error) { + throw new DatabaseError({ error, name: "get folder secret count" }); + } + }; + + const findByFolderIds = async ( + folderIds: string[], + userId?: string, + tx?: Knex, + filters?: { + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; + tagSlugs?: string[]; + } + ) => { + try { + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + if (userId && !uuidValidate(userId)) { + // eslint-disable-next-line no-param-reassign + userId = undefined; + } + + const query = (tx || db.replicaNode())(TableName.SecretV2) + .whereIn("folderId", folderIds) + .where((bd) => { + if (filters?.search) { + void bd.whereILike("key", `%${filters?.search}%`); + } + }) .where((bd) => { void bd.whereNull("userId").orWhere({ userId: userId || null }); }) @@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) - .select(selectAllTableCols(TableName.SecretV2)) + .select( + selectAllTableCols(TableName.SecretV2), + db.raw(`DENSE_RANK() OVER (ORDER BY "key" ${filters?.orderDirection ?? OrderByDirection.ASC}) as rank`) + ) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) - .orderBy("id", "asc"); + .where((bd) => { + const slugs = filters?.tagSlugs?.filter(Boolean); + if (slugs && slugs.length > 0) { + void bd.whereIn("slug", slugs); + } + }) + .orderBy( + filters?.orderBy === SecretsOrderBy.Name ? "key" : "id", + filters?.orderDirection ?? OrderByDirection.ASC + ); + + let secs: Awaited; + + if (filters?.limit) { + const rankOffset = (filters?.offset ?? 0) + 1; // ranks start at 1 + secs = await (tx || db) + .with("w", query) + .select("*") + .from[number]>("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + filters.limit); + } else { + secs = await query; + } const data = sqlNestRelationships({ data: secs, @@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { findBySecretKeys, upsertSecretReferences, findReferencedSecretReferences, - findAllProjectSecretValues + findAllProjectSecretValues, + countByFolderIds }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index ed9cd3a7d..d7f895084 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = { projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv" >; secretImportDAL: Pick; secretQueueService: Pick; @@ -431,6 +431,165 @@ export const secretV2BridgeServiceFactory = ({ }); }; + // get unique secrets count for multiple envs + const getSecretsCountMultiEnv = async ({ + actorId, + path, + + projectId, + actor, + actorOrgId, + actorAuthMethod, + environments, + ...params + }: Pick & { + environments: string[]; + }) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // verify user has access to all environments + environments.forEach((environment) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); + if (!folders.length) return 0; + + const count = await secretDAL.countByFolderIds( + folders.map((folder) => folder.id), + actorId, + undefined, + params + ); + + return count; + }; + + // get secret count for individual env + const getSecretsCount = async ({ + actorId, + path, + environment, + projectId, + actor, + actorOrgId, + actorAuthMethod, + ...params + }: Pick< + TGetSecretsDTO, + | "actorId" + | "actor" + | "path" + | "projectId" + | "actorOrgId" + | "actorAuthMethod" + | "tagSlugs" + | "environment" + | "search" + >) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) return 0; + + const count = await secretDAL.countByFolderIds([folder.id], actorId, undefined, params); + + return count; + }; + + // get secrets for multiple envs + const getSecretsMultiEnv = async ({ + actorId, + path, + environments, + projectId, + actor, + actorOrgId, + actorAuthMethod, + ...params + }: Pick & { + environments: string[]; + }) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + let paths: { folderId: string; path: string; environment: string }[] = []; + + // verify user has access to all environments + environments.forEach((environment) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); + + if (!folders.length) { + return []; + } + + paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug })); + + const groupedPaths = groupBy(paths, (p) => p.folderId); + + const secrets = await secretDAL.findByFolderIds( + paths.map((p) => p.folderId), + actorId, + undefined, + params + ); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedSecrets = secrets.map((secret) => + reshapeBridgeSecret( + projectId, + groupedPaths[secret.folderId][0].environment, + groupedPaths[secret.folderId][0].path, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + } + ) + ); + + return decryptedSecrets; + }; + const getSecrets = async ({ actorId, path, @@ -441,8 +600,8 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, includeImports, recursive, - tagSlugs = [], - expandSecretReferences: shouldExpandSecretReferences + expandSecretReferences: shouldExpandSecretReferences, + ...params }: TGetSecretsDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -490,7 +649,9 @@ export const secretV2BridgeServiceFactory = ({ const secrets = await secretDAL.findByFolderIds( paths.map((p) => p.folderId), - actorId + actorId, + undefined, + params ); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -509,9 +670,7 @@ export const secretV2BridgeServiceFactory = ({ : "" }) ); - const filteredSecrets = tagSlugs.length - ? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug)))) - : decryptedSecrets; + const expandSecretReferences = expandSecretReferencesFactory({ projectId, folderDAL, @@ -520,7 +679,7 @@ export const secretV2BridgeServiceFactory = ({ }); if (shouldExpandSecretReferences) { - const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath); + const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath); await Promise.allSettled( Object.keys(secretsGroupByPath).map((groupedPath) => Promise.allSettled( @@ -541,7 +700,7 @@ export const secretV2BridgeServiceFactory = ({ if (!includeImports) { return { - secrets: filteredSecrets + secrets: decryptedSecrets }; } @@ -569,7 +728,7 @@ export const secretV2BridgeServiceFactory = ({ }); return { - secrets: filteredSecrets, + secrets: decryptedSecrets, imports: importedSecrets }; }; @@ -1416,6 +1575,9 @@ export const secretV2BridgeServiceFactory = ({ getSecrets, getSecretVersions, backfillSecretReferences, - moveSecrets + moveSecrets, + getSecretsCount, + getSecretsCountMultiEnv, + getSecretsMultiEnv }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 8c7a32a7f..a76c57561 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -1,8 +1,9 @@ import { Knex } from "knex"; import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; @@ -21,6 +22,11 @@ export type TGetSecretsDTO = { includeImports?: boolean; recursive?: boolean; tagSlugs?: string[]; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + offset?: number; + limit?: number; + search?: string; } & TProjectPermission; export type TGetASecretDTO = { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 124515c8b..0bf1f1171 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -954,6 +954,120 @@ export const secretServiceFactory = ({ return secretsDeleted; }; + const getSecretsCount = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + tagSlugs = [], + ...v2Params + }: Pick< + TGetSecretsRawDTO, + | "projectId" + | "path" + | "actor" + | "actorId" + | "actorOrgId" + | "actorAuthMethod" + | "environment" + | "tagSlugs" + | "search" + >) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: "Project version does not support pagination", + name: "pagination_not_supported" + }); + + const count = await secretV2BridgeService.getSecretsCount({ + projectId, + actorId, + actor, + actorOrgId, + environment, + path, + actorAuthMethod, + tagSlugs, + ...v2Params + }); + + return count; + }; + + const getSecretsCountMultiEnv = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + ...v2Params + }: Pick< + TGetSecretsRawDTO, + "projectId" | "path" | "actor" | "actorId" | "actorOrgId" | "actorAuthMethod" | "search" + > & { environments: string[] }) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: "Project version does not support pagination", + name: "pagination_not_supported" + }); + + const count = await secretV2BridgeService.getSecretsCountMultiEnv({ + projectId, + actorId, + actor, + actorOrgId, + environments, + path, + actorAuthMethod, + ...v2Params + }); + + return count; + }; + + const getSecretsRawMultiEnv = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + ...params + }: Omit & { + environments: string[]; + }) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: "Project version does not support pagination", + name: "pagination_not_supported" + }); + + const secrets = await secretV2BridgeService.getSecretsMultiEnv({ + projectId, + actorId, + actor, + actorOrgId, + environments, + path, + actorAuthMethod, + ...params + }); + + return secrets; + }; + const getSecretsRaw = async ({ projectId, path, @@ -965,7 +1079,8 @@ export const secretServiceFactory = ({ includeImports, expandSecretReferences, recursive, - tagSlugs = [] + tagSlugs = [], + ...paramsV2 }: TGetSecretsRawDTO) => { const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (shouldUseSecretV2Bridge) { @@ -980,7 +1095,8 @@ export const secretServiceFactory = ({ recursive, actorAuthMethod, includeImports, - tagSlugs + tagSlugs, + ...paramsV2 }); return { secrets, imports }; } @@ -2693,6 +2809,9 @@ export const secretServiceFactory = ({ getSecretVersions, backfillSecretReferences, moveSecrets, - startSecretV2Migration + startSecretV2Migration, + getSecretsCount, + getSecretsCountMultiEnv, + getSecretsRawMultiEnv }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index b5281af68..7c09c9349 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -2,7 +2,7 @@ import { Knex } from "knex"; import { z } from "zod"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TSecretDALFactory } from "@app/services/secret/secret-dal"; @@ -105,6 +105,8 @@ export type TGetSecretsDTO = { environment: string; includeImports?: boolean; recursive?: boolean; + limit?: number; + offset?: number; } & TProjectPermission; export type TGetASecretDTO = { @@ -167,6 +169,10 @@ export type TDeleteBulkSecretDTO = { }>; } & TProjectPermission; +export enum SecretsOrderBy { + Name = "name" // "key" for secrets but using name for use across resources +} + export type TGetSecretsRawDTO = { expandSecretReferences?: boolean; path: string; @@ -174,6 +180,11 @@ export type TGetSecretsRawDTO = { includeImports?: boolean; recursive?: boolean; tagSlugs?: string[]; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + offset?: number; + limit?: number; + search?: string; } & TProjectPermission; export type TGetASecretRawDTO = { diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index cecae5287..715d8c51d 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -100,7 +100,7 @@ export default function NavHeader({ onValueChange={(value) => { if (value && onEnvChange) onEnvChange(value); }} - className="bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary" + className="border-none bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary" dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl" > {userAvailableEnvs?.map(({ name, slug }) => ( diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 7b3178707..cc1f7df95 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -1,3 +1,4 @@ +import { ReactElement } from "react"; import { faCaretDown, faCheck, @@ -23,6 +24,7 @@ export type PaginationProps = { onChangePerPage: (newRows: number) => void; className?: string; perPageList?: number[]; + startAdornment?: ReactElement; }; export const Pagination = ({ @@ -32,7 +34,8 @@ export const Pagination = ({ onChangePage, onChangePerPage, perPageList = [10, 20, 50, 100], - className + className, + startAdornment }: PaginationProps) => { const prevPageNumber = Math.max(1, page - 1); const canGoPrev = page > 1; @@ -46,11 +49,12 @@ export const Pagination = ({ return (
-
+ {startAdornment} +
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/hooks/api/dashboard/index.ts b/frontend/src/hooks/api/dashboard/index.ts new file mode 100644 index 000000000..b0fb9324a --- /dev/null +++ b/frontend/src/hooks/api/dashboard/index.ts @@ -0,0 +1 @@ +export { useGetProjectSecretsDetails } from "./queries"; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx new file mode 100644 index 000000000..38c07b6cf --- /dev/null +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -0,0 +1,261 @@ +import { useCallback } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import axios from "axios"; + +import { createNotification } from "@app/components/notifications"; +import { apiRequest } from "@app/config/request"; +import { + DashboardProjectSecretsDetails, + DashboardProjectSecretsDetailsResponse, + DashboardProjectSecretsOverview, + DashboardProjectSecretsOverviewResponse, + DashboardSecretsOrderBy, + TGetDashboardProjectSecretsDetailsDTO, + TGetDashboardProjectSecretsOverviewDTO +} from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; + +export const dashboardKeys = { + all: () => ["dashboard"] as const, + getDashboardSecrets: ({ + projectId, + secretPath + }: Pick) => + [...dashboardKeys.all(), { projectId, secretPath }] as const, + getProjectSecretsOverview: ({ + projectId, + secretPath, + ...params + }: TGetDashboardProjectSecretsOverviewDTO) => + [ + ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), + "secrets-overview", + params + ] as const, + getProjectSecretsDetails: ({ + projectId, + secretPath, + environment, + ...params + }: TGetDashboardProjectSecretsDetailsDTO) => + [ + ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), + environment, + "secrets-details", + params + ] as const +}; + +export const fetchProjectSecretsOverview = async ({ + includeFolders, + includeSecrets, + includeDynamicSecrets, + environments, + ...params +}: TGetDashboardProjectSecretsOverviewDTO) => { + const { data } = await apiRequest.get( + "/api/v3/dashboard/secrets-overview", + { + params: { + ...params, + environments: encodeURIComponent(environments.join(",")), + includeFolders: includeFolders ? "1" : "", + includeSecrets: includeSecrets ? "1" : "", + includeDynamicSecrets: includeDynamicSecrets ? "1" : "" + } + } + ); + + return data; +}; + +export const fetchProjectSecretsDetails = async ({ + includeFolders, + includeImports, + includeSecrets, + includeDynamicSecrets, + tags, + ...params +}: TGetDashboardProjectSecretsDetailsDTO) => { + const { data } = await apiRequest.get( + "/api/v3/dashboard/secrets-details", + { + params: { + ...params, + includeImports: includeImports ? "1" : "", + includeFolders: includeFolders ? "1" : "", + includeSecrets: includeSecrets ? "1" : "", + includeDynamicSecrets: includeDynamicSecrets ? "1" : "", + tags: encodeURIComponent( + Object.entries(tags) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, enabled]) => enabled) + .map(([tag]) => tag) + .join(",") + ) + } + } + ); + + return data; +}; + +export const useGetProjectSecretsOverview = ( + { + projectId, + secretPath, + offset = 0, + limit = 100, + orderBy = DashboardSecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC, + search = "", + includeSecrets, + includeFolders, + includeDynamicSecrets, + environments + }: TGetDashboardProjectSecretsOverviewDTO, + options?: Omit< + UseQueryOptions< + DashboardProjectSecretsOverviewResponse, + unknown, + DashboardProjectSecretsOverview, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + ...options, + // wait for all values to be available + enabled: Boolean(projectId) && (options?.enabled ?? true) && Boolean(environments.length), + queryKey: dashboardKeys.getProjectSecretsOverview({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + includeSecrets, + includeFolders, + includeDynamicSecrets, + environments + }), + queryFn: () => + fetchProjectSecretsOverview({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + includeSecrets, + includeFolders, + includeDynamicSecrets, + environments + }), + onError: (error) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as { message: string }; + createNotification({ + title: "Error fetching secret details", + type: "error", + text: serverResponse.message + }); + } + }, + select: useCallback((data: Awaited>) => { + const { secrets, ...select } = data; + + return { + ...select, + secrets: secrets ? mergePersonalSecrets(secrets) : undefined + }; + }, []), + keepPreviousData: true + }); +}; + +export const useGetProjectSecretsDetails = ( + { + projectId, + secretPath, + environment, + offset = 0, + limit = 100, + orderBy = DashboardSecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC, + search = "", + includeSecrets, + includeFolders, + includeImports, + includeDynamicSecrets, + tags + }: TGetDashboardProjectSecretsDetailsDTO, + options?: Omit< + UseQueryOptions< + DashboardProjectSecretsDetailsResponse, + unknown, + DashboardProjectSecretsDetails, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + ...options, + // wait for all values to be available + enabled: Boolean(projectId) && (options?.enabled ?? true), + queryKey: dashboardKeys.getProjectSecretsDetails({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + environment, + includeSecrets, + includeFolders, + includeImports, + includeDynamicSecrets, + tags + }), + queryFn: () => + fetchProjectSecretsDetails({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + environment, + includeSecrets, + includeFolders, + includeImports, + includeDynamicSecrets, + tags + }), + onError: (error) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as { message: string }; + createNotification({ + title: "Error fetching secret details", + type: "error", + text: serverResponse.message + }); + } + }, + select: useCallback( + (data: Awaited>) => ({ + ...data, + secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined + }), + [] + ), + keepPreviousData: true + }); +}; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts new file mode 100644 index 000000000..08e75ee3b --- /dev/null +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -0,0 +1,68 @@ +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; +import { TSecretImport } from "@app/hooks/api/secretImports/types"; +import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; + +export type DashboardProjectSecretsOverviewResponse = { + folders?: (TSecretFolder & { environment: string })[]; + dynamicSecrets?: (TDynamicSecret & { environment: string })[]; + secrets?: SecretV3Raw[]; + totalSecretCount?: number; + totalFolderCount?: number; + totalDynamicSecretCount?: number; + totalCount: number; +}; + +export type DashboardProjectSecretsDetailsResponse = { + imports?: TSecretImport[]; + folders?: TSecretFolder[]; + dynamicSecrets?: TDynamicSecret[]; + secrets?: SecretV3Raw[]; + totalImportCount?: number; + totalFolderCount?: number; + totalDynamicSecretCount?: number; + totalSecretCount?: number; + totalCount: number; +}; + +export type DashboardProjectSecretsOverview = Omit< + DashboardProjectSecretsOverviewResponse, + "secrets" +> & { + secrets?: SecretV3RawSanitized[]; +}; + +export type DashboardProjectSecretsDetails = Omit< + DashboardProjectSecretsDetailsResponse, + "secrets" +> & { + secrets?: SecretV3RawSanitized[]; +}; + +export enum DashboardSecretsOrderBy { + Name = "name" +} + +export type TGetDashboardProjectSecretsOverviewDTO = { + projectId: string; + secretPath: string; + offset?: number; + limit?: number; + orderBy?: DashboardSecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; + includeSecrets?: boolean; + includeFolders?: boolean; + includeDynamicSecrets?: boolean; + environments: string[]; +}; + +export type TGetDashboardProjectSecretsDetailsDTO = Omit< + TGetDashboardProjectSecretsOverviewDTO, + "environments" +> & { + environment: string; + includeImports?: boolean; + tags: Record; +}; diff --git a/frontend/src/hooks/api/dynamicSecret/mutation.ts b/frontend/src/hooks/api/dynamicSecret/mutation.ts index 5f41e38f5..f8fbb4d05 100644 --- a/frontend/src/hooks/api/dynamicSecret/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecret/mutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { dynamicSecretKeys } from "./queries"; import { @@ -22,6 +23,8 @@ export const useCreateDynamicSecret = () => { return data.dynamicSecret; }, onSuccess: (_, { path, environmentSlug, projectSlug }) => { + // TODO: optimize but we currently don't pass projectId + queryClient.invalidateQueries(dashboardKeys.all()); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); } }); @@ -39,6 +42,8 @@ export const useUpdateDynamicSecret = () => { return data.dynamicSecret; }, onSuccess: (_, { path, environmentSlug, projectSlug }) => { + // TODO: optimize but currently don't pass projectId + queryClient.invalidateQueries(dashboardKeys.all()); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); } }); @@ -56,6 +61,8 @@ export const useDeleteDynamicSecret = () => { return data.dynamicSecret; }, onSuccess: (_, { path, environmentSlug, projectSlug }) => { + // TODO: optimize but currently don't pass projectId + queryClient.invalidateQueries(dashboardKeys.all()); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); } }); diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 236a13a26..d85bc558a 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -8,6 +8,7 @@ import { } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { @@ -124,6 +125,12 @@ export const useCreateFolder = () => { return data; }, onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, environment, path }) ); @@ -151,6 +158,12 @@ export const useUpdateFolder = () => { return data; }, onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, environment, path }) ); @@ -179,6 +192,12 @@ export const useDeleteFolder = () => { return data; }, onSuccess: (_, { path = "/", projectId, environment }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, environment, path }) ); @@ -206,6 +225,12 @@ export const useUpdateFolderBatch = () => { }, onSuccess: (_, { projectId, folders }) => { folders.forEach((folder) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: folder.path ?? "/" + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index 04f1f01e6..4bee1a4ed 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretImportKeys } from "./queries"; import { @@ -31,6 +32,9 @@ export const useCreateSecretImport = () => { queryClient.invalidateQueries( secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" }) + ); } }); }; @@ -55,6 +59,9 @@ export const useUpdateSecretImport = () => { queryClient.invalidateQueries( secretImportKeys.getSecretImportSecrets({ environment, path, projectId }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" }) + ); } }); }; @@ -93,6 +100,9 @@ export const useDeleteSecretImport = () => { queryClient.invalidateQueries( secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" }) + ); } }); }; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 4f4e00922..54aa97b43 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -1,6 +1,7 @@ import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; @@ -44,6 +45,9 @@ export const useCreateSecretV3 = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -96,6 +100,9 @@ export const useUpdateSecretV3 = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -139,6 +146,9 @@ export const useDeleteSecretV3 = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -172,6 +182,9 @@ export const useCreateSecretBatch = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -205,6 +218,9 @@ export const useUpdateSecretBatch = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -240,6 +256,9 @@ export const useDeleteSecretBatch = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -295,6 +314,12 @@ export const useMoveSecrets = ({ return data; }, onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: sourceSecretPath + }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId: projectId, diff --git a/frontend/src/hooks/utils/index.ts b/frontend/src/hooks/utils/index.ts new file mode 100644 index 000000000..db6645c41 --- /dev/null +++ b/frontend/src/hooks/utils/index.ts @@ -0,0 +1 @@ +export * from "./secrets-overview"; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx new file mode 100644 index 000000000..b84fb3902 --- /dev/null +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -0,0 +1,86 @@ +import { useCallback, useMemo } from "react"; + +import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types"; + +export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => { + const folderNames = useMemo(() => { + const names = new Set(); + folders?.forEach((folder) => { + names.add(folder.name); + }); + return [...names]; + }, [folders]); + + const isFolderPresentInEnv = useCallback( + (name: string, env: string) => { + return Boolean( + folders?.find( + ({ name: folderName, environment }) => folderName === name && environment === env + ) + ); + }, + [folders] + ); + + const getFolderByNameAndEnv = useCallback( + (name: string, env: string) => { + return folders?.find( + ({ name: folderName, environment }) => folderName === name && environment === env + ); + }, + [folders] + ); + + return { folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; +}; + +export const useDynamicSecretOverview = ( + dynamicSecrets: DashboardProjectSecretsOverview["dynamicSecrets"] +) => { + const dynamicSecretNames = useMemo(() => { + const names = new Set(); + dynamicSecrets?.forEach((dynamicSecret) => { + names.add(dynamicSecret.name); + }); + return [...names]; + }, [dynamicSecrets]); + + const isDynamicSecretPresentInEnv = useCallback( + (name: string, env: string) => { + return Boolean( + dynamicSecrets?.find( + ({ name: dynamicSecretName, environment }) => + dynamicSecretName === name && environment === env + ) + ); + }, + [dynamicSecrets] + ); + + return { dynamicSecretNames, isDynamicSecretPresentInEnv }; +}; + +export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secrets"]) => { + const secKeys = useMemo(() => { + const keys = new Set(); + secrets?.forEach((secret) => keys.add(secret.key)); + return [...keys]; + }, [secrets]); + + const getEnvSecretKeyCount = useCallback( + (env: string) => { + return secrets?.filter((secret) => secret.env === env).length ?? 0; + }, + [secrets] + ); + + const getSecretByKey = useCallback( + (env: string, key: string) => { + const sec = secrets?.find((s) => s.env === env && s.key === key); + return sec; + }, + [secrets] + ); + + return { secKeys, getSecretByKey, getEnvSecretKeyCount }; +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index a6e52f7c1..180bd5e40 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -277,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { })} - {!isLoading && data && data.totalCount > INIT_PER_PAGE && ( + {!isLoading && data && data.totalCount > 0 && ( - {!isLoading && data && data.totalCount > INIT_PER_PAGE && ( + {!isLoading && data && data.totalCount > 0 && ( { const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({ organizationId, - limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will replace with combobox in separate PR + limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR }); const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships; const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({ workspaceId, - limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will optimize in PR referenced above + limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above }); const identityMemberships = identityMembershipsData?.identityMemberships; diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index 14cd7b9b8..aa6804511 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import NavHeader from "@app/components/navigation/NavHeader"; import { createNotification } from "@app/components/notifications"; @@ -17,29 +18,28 @@ import { } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; import { - useGetDynamicSecrets, useGetImportedSecretsSingleEnv, - useGetProjectFolders, - useGetProjectSecrets, useGetSecretApprovalPolicyOfABoard, - useGetSecretImports, useGetWorkspaceSnapshotList, useGetWsSnapshotCount, useGetWsTags } from "@app/hooks/api"; +import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { DynamicSecretListView } from "@app/views/SecretMainPage/components/DynamicSecretListView"; +import { FolderListView } from "@app/views/SecretMainPage/components/FolderListView"; +import { SecretImportListView } from "@app/views/SecretMainPage/components/SecretImportListView"; +import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount"; import { SecretV2MigrationSection } from "../SecretOverviewPage/components/SecretV2MigrationSection"; import { ActionBar } from "./components/ActionBar"; import { CreateSecretForm } from "./components/CreateSecretForm"; -import { DynamicSecretListView } from "./components/DynamicSecretListView"; -import { FolderListView } from "./components/FolderListView"; import { PitDrawer } from "./components/PitDrawer"; import { SecretDropzone } from "./components/SecretDropzone"; -import { SecretImportListView } from "./components/SecretImportListView"; import { SecretListView } from "./components/SecretListView"; import { SnapshotView } from "./components/SnapshotView"; import { StoreProvider } from "./SecretMainPage.store"; -import { Filter, SortDir } from "./SecretMainPage.types"; +import { Filter, RowType } from "./SecretMainPage.types"; const LOADER_TEXT = [ "Retrieving your encrypted secrets...", @@ -55,12 +55,8 @@ export const SecretMainPage = () => { const { permission } = useProjectPermission(); const [isVisible, setIsVisible] = useState(false); - const [sortDir, setSortDir] = useState(SortDir.ASC); - const [filter, setFilter] = useState({ - tags: {}, - searchFilter: (router.query.searchFilter as string) || "" - }); - const debouncedSearchFilter = useDebounce(filter.searchFilter); + const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC); + const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(INIT_PER_PAGE); const paginationOffset = (page - 1) * perPage; @@ -83,6 +79,18 @@ export const SecretMainPage = () => { ProjectPermissionSub.SecretRollback ); + const [filter, setFilter] = useState({ + tags: {}, + searchFilter: (router.query.searchFilter as string) || "", + include: { + [RowType.Folder]: true, + [RowType.Import]: canReadSecret, + [RowType.DynamicSecret]: canReadSecret, + [RowType.Secret]: canReadSecret + } + }); + const debouncedSearchFilter = useDebounce(filter.searchFilter); + useEffect(() => { if ( !isWorkspaceLoading && @@ -91,43 +99,43 @@ export const SecretMainPage = () => { ) { router.push(`/project/${workspaceId}/secrets/overview`); createNotification({ - text: "No envronment found with given slug", + text: "No environment found with given slug", type: "error" }); } }, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]); - // fetch secrets - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ - environment, - workspaceId, - secretPath, - options: { - enabled: canReadSecret - } - }); - - // fetch folders - const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({ - projectId: workspaceId, - environment, - path: secretPath - }); - - // fetch secret imports const { - data: secretImports, - isLoading: isSecretImportsLoading, - isFetching: isSecretImportsFetching - } = useGetSecretImports({ - projectId: workspaceId, + data, + isLoading: isDetailsLoading, + isFetching: isDetailsFetching + } = useGetProjectSecretsDetails({ environment, - path: secretPath, - options: { - enabled: canReadSecret - } + projectId: workspaceId, + secretPath, + offset: paginationOffset, + limit: perPage, + search: debouncedSearchFilter, + orderDirection, + includeImports: canReadSecret && filter.include.import, + includeFolders: filter.include.folder, + includeDynamicSecrets: canReadSecret && filter.include.dynamic, + includeSecrets: canReadSecret && filter.include.secret, + tags: filter.tags }); + const { + imports, + folders, + dynamicSecrets, + secrets, + totalImportCount = 0, + totalFolderCount = 0, + totalDynamicSecretCount = 0, + totalSecretCount = 0, + totalCount = 0 + } = data ?? {}; + // fetch imported secrets to show user the overriden ones const { data: importedSecrets } = useGetImportedSecretsSingleEnv({ projectId: workspaceId, @@ -138,13 +146,7 @@ export const SecretMainPage = () => { } }); - const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({ - projectSlug, - environmentSlug: environment, - path: secretPath - }); - - // fech tags + // fetch tags const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ @@ -174,12 +176,14 @@ export const SecretMainPage = () => { isPaused: !canDoReadRollback }); - const isNotEmtpy = Boolean( - secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length + const isNotEmpty = Boolean( + secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length ); const handleSortToggle = () => - setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC)); + setOrderDirection((state) => + state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC + ); const handleEnvChange = (slug: string) => { const query: Record = { ...router.query, env: slug }; @@ -191,17 +195,31 @@ export const SecretMainPage = () => { }; const handleTagToggle = useCallback( - (tagId: string) => + (tagSlug: string) => setFilter((state) => { - const isTagPresent = Boolean(state.tags?.[tagId]); + const isTagPresent = Boolean(state.tags?.[tagSlug]); const newTagFilter = { ...state.tags }; - if (isTagPresent) delete newTagFilter[tagId]; - else newTagFilter[tagId] = true; + if (isTagPresent) delete newTagFilter[tagSlug]; + else newTagFilter[tagSlug] = true; return { ...state, tags: newTagFilter }; }), [] ); + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + include: { + ...state.include, + [rowType]: !state.include[rowType] + } + }; + }), + [] + ); + const handleSearchChange = useCallback( (searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })), [] @@ -219,126 +237,18 @@ export const SecretMainPage = () => { handlePopUpClose("snapshots"); }, []); - // loading screen when u have permission - const loadingOnAccess = - canReadSecret && - (isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading); - - const rows = useMemo(() => { - const filteredSecrets = - secrets - ?.filter(({ key, tags: secretTags, value }) => { - const isTagFilterActive = Boolean(Object.keys(filter.tags).length); - return ( - (!isTagFilterActive || secretTags?.some(({ id }) => filter.tags?.[id])) && - (key.toUpperCase().includes(debouncedSearchFilter.toUpperCase()) || - value?.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - ); - }) - .sort((a, b) => - sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key) - ) ?? []; - const filteredFolders = - folders - ?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => - sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) - ) ?? []; - const filteredDynamicSecrets = - dynamicSecrets - ?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => - sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) - ) ?? []; - const filteredSecretImports = - secretImports - ?.filter(({ importPath }) => - importPath.toLowerCase().includes(debouncedSearchFilter.toLowerCase()) - ) - .sort((a, b) => - sortDir === "asc" - ? a.importPath.localeCompare(b.importPath) - : b.importPath.localeCompare(a.importPath) - ) ?? []; - - const totalRows = - filteredSecretImports.length + - filteredFolders.length + - filteredDynamicSecrets.length + - filteredSecrets.length; - - const paginatedImports = filteredSecretImports.slice( - paginationOffset, - paginationOffset + perPage - ); - - let remainingRows = perPage - paginatedImports.length; - const foldersStartIndex = Math.max(0, paginationOffset - filteredSecretImports.length); - const paginatedFolders = - remainingRows > 0 - ? filteredFolders.slice(foldersStartIndex, foldersStartIndex + remainingRows) - : []; - - remainingRows -= paginatedFolders.length; - const dynamicSecretStartIndex = Math.max( - 0, - paginationOffset - filteredSecretImports.length - filteredFolders.length - ); - const paginatiedDynamicSecrets = - remainingRows > 0 - ? filteredDynamicSecrets.slice( - dynamicSecretStartIndex, - dynamicSecretStartIndex + remainingRows - ) - : []; - - remainingRows -= paginatiedDynamicSecrets.length; - const secretStartIndex = Math.max( - 0, - paginationOffset - - filteredSecretImports.length - - filteredFolders.length - - filteredDynamicSecrets.length - ); - - const paginatiedSecrets = - remainingRows > 0 - ? filteredSecrets.slice(secretStartIndex, secretStartIndex + remainingRows) - : []; - - return { - imports: paginatedImports, - folders: paginatedFolders, - secrets: paginatiedSecrets, - dynamicSecrets: paginatiedDynamicSecrets, - totalRows - }; - }, [ - sortDir, - debouncedSearchFilter, - folders, - secrets, - dynamicSecrets, - paginationOffset, - perPage, - filter.tags, - importedSecrets - ]); - useEffect(() => { // reset page if no longer valid - if (rows.totalRows < paginationOffset) setPage(1); - }, [rows.totalRows]); + if (totalCount < paginationOffset) setPage(1); + }, [totalCount]); - // loading screen when you don't have permission but as folder's is viewable need to wait for that - const loadingOnDenied = !canReadSecret && isFoldersLoading; - if (loadingOnAccess || loadingOnDenied) { + if (isDetailsLoading) { return ; } return ( -
+
{ isVisible={isVisible} filter={filter} tags={tags} - onVisiblilityToggle={handleToggleVisibility} + onVisibilityToggle={handleToggleVisibility} onSearchChange={handleSearchChange} onToggleTagFilter={handleTagToggle} snapshotCount={snapshotCount || 0} isSnapshotCountLoading={isSnapshotCountLoading} + onToggleRowType={handleToggleRowType} onClickRollbackMode={() => handlePopUpToggle("snapshots", true)} /> -
+
- {isNotEmtpy && ( -
+ {isNotEmpty && ( +
{ > Key
Value
)} - {canReadSecret && ( + {canReadSecret && imports?.length && ( )} - - {canReadSecret && ( + {folders?.length && ( + + )} + {canReadSecret && dynamicSecrets?.length && ( )} - {canReadSecret && ( + {canReadSecret && secrets?.length && ( { /> )} {!canReadSecret && folders?.length === 0 && } - {!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} - /> - )}
+ {!isDetailsLoading && totalCount > 0 && ( + + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} { environment={environment} workspaceId={workspaceId} secretPath={secretPath} - isSmaller={isNotEmtpy} + isSmaller={isNotEmpty} environments={currentWorkspace?.environments} isProtectedBranch={isProtectedBranch} /> diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.types.ts b/frontend/src/views/SecretMainPage/SecretMainPage.types.ts index 489e19e5c..848e8b91a 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.types.ts +++ b/frontend/src/views/SecretMainPage/SecretMainPage.types.ts @@ -1,15 +1,14 @@ export type Filter = { tags: Record; searchFilter: string; + include: { + [key in RowType]: boolean; + }; }; -export enum SortDir { - ASC = "asc", - DESC = "desc" -} - export enum RowType { Folder = "folder", + Import = "import", DynamicSecret = "dynamic", - Secret = "Secret" + Secret = "secret" } diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 94ac029bd..1c00f6823 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -13,7 +13,9 @@ import { faFileImport, faFilter, faFingerprint, + faFolder, faFolderPlus, + faKey, faMagnifyingGlass, faMinusSquare, faPlus, @@ -62,7 +64,7 @@ import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; -import { Filter } from "../../SecretMainPage.types"; +import { Filter, RowType } from "../../SecretMainPage.types"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; @@ -83,7 +85,8 @@ type Props = { isSnapshotCountLoading?: boolean; onSearchChange: (term: string) => void; onToggleTagFilter: (tagId: string) => void; - onVisiblilityToggle: () => void; + onVisibilityToggle: () => void; + onToggleRowType: (rowType: RowType) => void; onClickRollbackMode: () => void; }; @@ -100,8 +103,9 @@ export const ActionBar = ({ isSnapshotCountLoading, onSearchChange, onToggleTagFilter, - onVisiblilityToggle, - onClickRollbackMode + onVisibilityToggle, + onClickRollbackMode, + onToggleRowType }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", @@ -298,7 +302,9 @@ export const ActionBar = ({ ariaLabel="Download" className={twMerge( "transition-all", - Object.keys(filter.tags).length && "border-primary/50 text-primary" + (Object.keys(filter.tags).length || + Object.values(filter.include).filter((include) => !include).length) && + "border-primary/50 text-primary" )} > @@ -306,6 +312,60 @@ export const ActionBar = ({ Filter By + { + e.preventDefault(); + onToggleRowType(RowType.Import); + }} + icon={filter?.include[RowType.Import] && } + iconPos="right" + > +
+ + Imports +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.Folder); + }} + icon={filter?.include[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter?.include[RowType.DynamicSecret] && + } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.Secret); + }} + icon={filter?.include[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
{ evt.preventDefault(); - onToggleTagFilter(id); + onToggleTagFilter(slug); }} key={id} - icon={filter?.tags[id] && } + icon={filter?.tags[slug] && } iconPos="right" >
@@ -346,7 +406,7 @@ export const ActionBar = ({
- +
diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx index 6b133d945..abef7b6c3 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx @@ -27,7 +27,6 @@ import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; -import { SortDir } from "../../SecretMainPage.types"; import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease"; import { DynamicSecretLease } from "./DynamicSecretLease"; import { EditDynamicSecretForm } from "./EditDynamicSecretForm"; @@ -38,19 +37,17 @@ const formatProviderName = (type: DynamicSecretProviders) => { }; type Props = { - dynamicSecrets: TDynamicSecret[]; + dynamicSecrets?: TDynamicSecret[]; environment: string; projectSlug: string; secretPath?: string; - sortDir: SortDir; }; export const DynamicSecretListView = ({ dynamicSecrets = [], environment, projectSlug, - secretPath = "/", - sortDir = SortDir.ASC + secretPath = "/" }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "dynamicSecretLeases", @@ -59,7 +56,6 @@ export const DynamicSecretListView = ({ "deleteDynamicSecret" ] as const); - const deleteDynamicSecret = useDeleteDynamicSecret(); const handleDynamicSecretDelete = async () => { @@ -90,158 +86,148 @@ export const DynamicSecretListView = ({ return ( <> - {dynamicSecrets - .sort((a, b) => - sortDir === SortDir.ASC - ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) - ) - .map((secret) => { - const isRevocking = secret.status === DynamicSecretStatus.Deleting; - return ( - handlePopUpToggle("dynamicSecretLeases", state)} + {dynamicSecrets.map((secret) => { + const isRevoking = secret.status === DynamicSecretStatus.Deleting; + return ( + handlePopUpToggle("dynamicSecretLeases", state)} + > +
{ + if (evt.key === "Enter" && !isRevoking) + handlePopUpOpen("dynamicSecretLeases", secret.id); + }} + onClick={() => { + if (!isRevoking) { + handlePopUpOpen("dynamicSecretLeases", secret.id); + } + }} > -
{ - if (evt.key === "Enter" && !isRevocking) - handlePopUpOpen("dynamicSecretLeases", secret.id); - }} - onClick={() => { - if (!isRevocking) { - handlePopUpOpen("dynamicSecretLeases", secret.id); - } - }} - > -
- -
-
- {secret.name} - - {formatProviderName(secret.type)} - - {Boolean(secret.status) && ( - - - - )} -
-
- - {secret.status === DynamicSecretStatus.FailedDeletion && ( - - - - )} -
-
- - {(isAllowed) => ( - { - evt.stopPropagation(); - handlePopUpOpen("updateDynamicSecret", secret); - }} - isDisabled={!isAllowed || isRevocking} - > - - - )} - - - {(isAllowed) => ( - { - evt.stopPropagation(); - handlePopUpOpen("deleteDynamicSecret", secret); - }} - isDisabled={!isAllowed || isRevocking} - > - - - )} - -
+
+
- - handlePopUpOpen("createDynamicSecretLease", secret)} - onClose={() => handlePopUpClose("dynamicSecretLeases")} - projectSlug={projectSlug} - key={secret.id} - dynamicSecretName={secret.name} - secretPath={secretPath} - environment={environment} - /> - - - ); - })} +
+ {secret.name} + + {formatProviderName(secret.type)} + + {Boolean(secret.status) && ( + + + + )} +
+
+ + {secret.status === DynamicSecretStatus.FailedDeletion && ( + + + + )} +
+
+ + {(isAllowed) => ( + { + evt.stopPropagation(); + handlePopUpOpen("updateDynamicSecret", secret); + }} + isDisabled={!isAllowed || isRevoking} + > + + + )} + + + {(isAllowed) => ( + { + evt.stopPropagation(); + handlePopUpOpen("deleteDynamicSecret", secret); + }} + isDisabled={!isAllowed || isRevoking} + > + + + )} + +
+
+ + handlePopUpOpen("createDynamicSecretLease", secret)} + onClose={() => handlePopUpClose("dynamicSecretLeases")} + projectSlug={projectSlug} + key={secret.id} + dynamicSecretName={secret.name} + secretPath={secretPath} + environment={environment} + /> + + + ); + })} handlePopUpToggle("createDynamicSecretLease", state)} diff --git a/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx b/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx index dfc670fad..e4f74cedc 100644 --- a/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx @@ -11,7 +11,6 @@ import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; -import { SortDir } from "../../SecretMainPage.types"; import { FolderForm } from "../ActionBar/FolderForm"; type Props = { @@ -19,17 +18,13 @@ type Props = { environment: string; workspaceId: string; secretPath?: string; - sortDir: SortDir; - searchTerm?: string; }; export const FolderListView = ({ folders = [], environment, workspaceId, - searchTerm, - secretPath = "/", - sortDir = SortDir.ASC + secretPath = "/" }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "updateFolder", @@ -104,84 +99,77 @@ export const FolderListView = ({ return ( <> - {folders - .filter(({ name }) => name.toUpperCase().includes(String(searchTerm?.toUpperCase()))) - .sort((a, b) => - sortDir === SortDir.ASC - ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) - ) - .map(({ name, id }) => ( -
-
- -
-
{ - if (evt.key === "Enter") handleFolderClick(name); - }} - onClick={() => handleFolderClick(name)} - > - {name} -
-
- - {(isAllowed) => ( - handlePopUpOpen("updateFolder", { id, name })} - isDisabled={!isAllowed} - > - - - )} - - - {(isAllowed) => ( - handlePopUpOpen("deleteFolder", { id, name })} - isDisabled={!isAllowed} - > - - - )} - -
+ {folders.map(({ name, id }) => ( +
+
+
- ))} +
{ + if (evt.key === "Enter") handleFolderClick(name); + }} + onClick={() => handleFolderClick(name)} + > + {name} +
+
+ + {(isAllowed) => ( + handlePopUpOpen("updateFolder", { id, name })} + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteFolder", { id, name })} + isDisabled={!isAllowed} + > + + + )} + +
+
+ ))} handlePopUpToggle("updateFolder", isOpen)} diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx index 09759fba7..88295b440 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx @@ -14,9 +14,10 @@ import { Button, Modal, ModalContent } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; -import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/types"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; @@ -190,6 +191,9 @@ export const SecretDropzone = ({ queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); handlePopUpClose("overlapKeyWarning"); createNotification({ diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx index 9be3cf03b..e7c6b526f 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx @@ -291,7 +291,7 @@ export const SecretImportItem = ({ Key Value - Override + {/* Override */} @@ -304,7 +304,7 @@ export const SecretImportItem = ({ )} {importedSecrets .filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase())) - .map(({ key, value, overriden }, index) => ( + .map(({ key, value }, index) => ( {key} @@ -312,9 +312,9 @@ export const SecretImportItem = ({ - + {/* - + */} ))} diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx index cef0d2916..e212d6da9 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx @@ -90,18 +90,18 @@ type Props = { secretPath?: string; secretImports?: TSecretImport[]; isFetching?: boolean; - secrets?: SecretV3RawSanitized[]; + // secrets?: SecretV3RawSanitized[]; importedSecrets?: TImportedSecrets; searchTerm: string; }; export const SecretImportListView = ({ - secretImports = [], + secretImports, environment, workspaceId, secretPath, importedSecrets, - secrets = [], + // secrets = [], isFetching, searchTerm }: Props) => { @@ -117,11 +117,11 @@ export const SecretImportListView = ({ useSensor(KeyboardSensor, {}) ); - const [items, setItems] = useState(secretImports); + const [items, setItems] = useState(secretImports ?? []); useEffect(() => { if (!isFetching) { - setItems(secretImports); + setItems(secretImports ?? []); } }, [isFetching, secretImports]); @@ -170,7 +170,7 @@ export const SecretImportListView = ({ }; const handleOpenReplicationSecrets = (replicationImportId: string) => { - const reservedImport = secretImports.find( + const reservedImport = secretImports?.find( ({ isReserved, importPath, importEnv }) => importEnv.slug === environment && isReserved && @@ -208,8 +208,8 @@ export const SecretImportListView = ({ importedSecrets={computeImportedSecretRows( item.importEnv.slug, item.importPath, - importedSecrets, - secrets + importedSecrets + // secrets scott - now that secrets are paginated we are not showing if they are overridden (yet?) )} secretPath={secretPath} environment={environment} diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index b6377a35d..811a9e03b 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -7,6 +7,7 @@ import { CreateTagModal } from "@app/components/tags/CreateTagModal"; import { DeleteActionModal } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; @@ -213,6 +214,12 @@ export const SecretListView = ({ }); if (cb) cb(); } + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId: workspaceId, + secretPath + }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -246,6 +253,9 @@ export const SecretListView = ({ try { await handleSecretOperation("delete", SecretType.Shared, key, { secretId }); // wrap this in another function and then reuse + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 97101b21a..46e5f4ba7 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,20 +1,24 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; -import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowDown, faArrowUp, + faFingerprint, + faFolder, faFolderBlank, faFolderPlus, + faKey, faList, faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import NavHeader from "@app/components/navigation/NavHeader"; import { createNotification } from "@app/components/notifications"; @@ -55,22 +59,25 @@ import { useCreateFolder, useCreateSecretV3, useDeleteSecretV3, - useGetDynamicSecretsOfAllEnv, - useGetFoldersByEnv, useGetImportedSecretsAllEnvs, - useGetProjectSecretsAllEnv, useUpdateSecretV3 } from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; +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 { SecretType, TSecretFolder } from "@app/hooks/api/types"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; +import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow"; +import { SecretOverviewTableRow } from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow"; +import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; -import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; -import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection"; import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; @@ -82,9 +89,12 @@ export enum EntryType { enum RowType { Folder = "folder", DynamicSecret = "dynamic", - Secret = "Secret" + Secret = "secret" } +type Filter = { + [key in RowType]: boolean; +}; const INIT_PER_PAGE = 20; export const SecretOverviewPage = () => { @@ -96,7 +106,7 @@ export const SecretOverviewPage = () => { // coz when overflow the table goes to the right const parentTableRef = useRef(null); const [expandableTableWidth, setExpandableTableWidth] = useState(0); - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC); const { permission } = useProjectPermission(); useEffect(() => { @@ -106,6 +116,7 @@ export const SecretOverviewPage = () => { }, [parentTableRef.current]); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); + const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; const { currentOrg } = useOrganization(); const workspaceId = currentWorkspace?.id as string; const projectSlug = currentWorkspace?.slug as string; @@ -113,6 +124,12 @@ export const SecretOverviewPage = () => { const debouncedSearchFilter = useDebounce(searchFilter); const secretPath = (router.query?.secretPath as string) || "/"; + const [filter, setFilter] = useState({ + [RowType.Folder]: true, + [RowType.DynamicSecret]: true, + [RowType.Secret]: true + }); + const [selectedEntries, setSelectedEntries] = useState<{ [EntryType.FOLDER]: Record; [EntryType.SECRET]: Record; @@ -173,61 +190,64 @@ export const SecretOverviewPage = () => { }, [isWorkspaceLoading, workspaceId, router.isReady]); const userAvailableEnvs = currentWorkspace?.environments || []; - const [visibleEnvs, setVisibleEnvs] = useState( - userAvailableEnvs?.filter(({ slug }) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: slug, - secretPath - }) - ) + + const readableEnvs = userAvailableEnvs?.filter(({ slug }) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: slug, + secretPath + }) ) ); + const [visibleEnvs, setVisibleEnvs] = useState(readableEnvs); + useEffect(() => { - setVisibleEnvs( - userAvailableEnvs?.filter(({ slug }) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: slug, - secretPath - }) - ) - ) - ); + setVisibleEnvs(readableEnvs); }, [userAvailableEnvs, secretPath]); - const { - data: secrets, - getSecretByKey, - secKeys, - getEnvSecretKeyCount - } = useGetProjectSecretsAllEnv({ - workspaceId, - envs: userAvailableEnvs.map(({ slug }) => slug), - secretPath - }); - - const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({ - projectId: workspaceId, - path: secretPath, - environments: userAvailableEnvs.map(({ slug }) => slug) - }); - const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({ projectId: workspaceId, path: secretPath, environments: userAvailableEnvs.map(({ slug }) => slug) }); - const { dynamicSecretNames, dynamicSecrets, isDynamicSecretPresentInEnv } = - useGetDynamicSecretsOfAllEnv({ - projectSlug, - environmentSlugs: userAvailableEnvs.map(({ slug }) => slug), - path: secretPath - }); + const paginationOffset = (page - 1) * perPage; + + const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: visibleEnvs.map((env) => env.slug), + secretPath, + orderDirection, + orderBy: DashboardSecretsOrderBy.Name, + includeFolders: filter.folder, + includeDynamicSecrets: filter.dynamic, + includeSecrets: filter.secret, + search: debouncedSearchFilter, + limit: perPage, + offset: paginationOffset + }, + { enabled: isProjectV3 } + ); + + const { + secrets, + folders, + dynamicSecrets, + totalCount = 0, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount + } = overview ?? {}; + + const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets); const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); @@ -472,40 +492,23 @@ export const SecretOverviewPage = () => { } }; - const rows = useMemo(() => { - const filteredSecretNames = - secKeys - ?.filter((name) => name.toUpperCase().includes(debouncedSearchFilter.toUpperCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; - const filteredFolderNames = - folderNames - ?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; - const filteredDynamicSecrets = - dynamicSecretNames - ?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; - - return [ - ...filteredFolderNames.map((name) => ({ name, type: RowType.Folder })), - ...filteredDynamicSecrets.map((name) => ({ name, type: RowType.DynamicSecret })), - ...filteredSecretNames.map((name) => ({ name, type: RowType.Secret })) - ]; - }, [sortDir, debouncedSearchFilter, secKeys, folderNames, dynamicSecretNames]); - - const paginationOffset = (page - 1) * perPage; - useEffect(() => { // reset page if no longer valid - if (rows.length < paginationOffset) setPage(1); - }, [rows.length]); + if (totalCount < paginationOffset) setPage(1); + }, [totalCount]); - const isTableLoading = - folders?.some(({ isLoading }) => isLoading) || - secrets?.some(({ isLoading }) => isLoading) || - dynamicSecrets?.some(({ isLoading }) => isLoading); + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); - if (isWorkspaceLoading || isTableLoading) { + if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) { return (
{ // This is needed to also show imports from other paths – right now those are missing. // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; - const isTableEmpty = - !( - folders?.every(({ isLoading }) => isLoading) && - secrets?.every(({ isLoading }) => isLoading) && - dynamicSecrets?.every(({ isLoading }) => isLoading) - ) && rows.length === 0; + const isTableEmpty = totalCount === 0; + + const isTableFiltered = + Boolean(Object.values(filter).filter((enabled) => !enabled).length) || + visibleEnvs.length !== readableEnvs?.length; + + if (!isProjectV3) + return ( +
+ +
+ ); return ( <>
-
@@ -591,7 +599,10 @@ export const SecretOverviewPage = () => { ariaLabel="Environments" variant="plain" size="sm" - className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10" + className={twMerge( + "flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10", + isTableFiltered && "border-primary/50 text-primary" + )} > @@ -600,37 +611,25 @@ export const SecretOverviewPage = () => { Choose visible environments - {userAvailableEnvs - .filter(({ slug }) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: slug, - secretPath - }) - ) - ) - .map((availableEnv) => { - const { id: envId, name } = availableEnv; + {readableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; - const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); - return ( - handleEnvSelect(envId)} - key={envId} - icon={ - isEnvSelected ? ( - - ) : ( - - ) - } - iconPos="left" - > -
{name}
-
- ); - })} + const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); + return ( + { + e.preventDefault(); + handleEnvSelect(envId); + }} + key={envId} + disabled={visibleEnvs?.length === 1} + icon={isEnvSelected && } + iconPos="right" + > +
{name}
+
+ ); + })} {/* */} + Filter project resources + { + e.preventDefault(); + handleToggleRowType(RowType.Folder); + }} + icon={filter[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter[RowType.DynamicSecret] && + } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.Secret); + }} + icon={filter[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
)} @@ -727,9 +768,17 @@ export const SecretOverviewPage = () => { variant="plain" className="ml-2" ariaLabel="sort" - onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))} + onClick={() => + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } > - +
@@ -766,7 +815,7 @@ export const SecretOverviewPage = () => { - {canViewOverviewPage && isTableLoading && ( + {canViewOverviewPage && isOverviewLoading && ( { )} - {isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && ( + {isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && ( { )} - {!isTableLoading && - rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => { - switch (row.type) { - case RowType.Secret: - if (visibleEnvs?.length === 0) return null; - return ( - - toggleSelectedEntry(EntryType.SECRET, row.name) - } - secretPath={secretPath} - getImportedSecretByKey={getImportedSecretByKey} - isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} - onSecretCreate={handleSecretCreate} - onSecretDelete={handleSecretDelete} - onSecretUpdate={handleSecretUpdate} - key={`overview-${row.name}-${index + 1}`} - environments={visibleEnvs} - secretKey={row.name} - getSecretByKey={getSecretByKey} - expandableColWidth={expandableTableWidth} - /> - ); - case RowType.DynamicSecret: - return ( - - ); - case RowType.Folder: - return ( - - toggleSelectedEntry(EntryType.FOLDER, row.name) - } - environments={visibleEnvs} - key={`overview-${row.name}-${index + 1}`} - onClick={handleFolderClick} - onToggleFolderEdit={(name: string) => - handlePopUpOpen("updateFolder", { name }) - } - /> - ); - default: - return null; - } - })} + {!isOverviewLoading && visibleEnvs.length > 0 && ( + <> + {folderNames.map((folderName, index) => ( + + toggleSelectedEntry(EntryType.FOLDER, folderName) + } + environments={visibleEnvs} + key={`overview-${folderName}-${index + 1}`} + onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } + /> + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secKeys.map((key, index) => ( + toggleSelectedEntry(EntryType.SECRET, key)} + secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} + isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} + onSecretCreate={handleSecretCreate} + onSecretDelete={handleSecretDelete} + onSecretUpdate={handleSecretUpdate} + key={`overview-${key}-${index + 1}`} + environments={visibleEnvs} + secretKey={key} + getSecretByKey={getSecretByKey} + expandableColWidth={expandableTableWidth} + /> + ))} + + )} @@ -913,10 +953,17 @@ export const SecretOverviewPage = () => { - {!isTableLoading && rows.length > INIT_PER_PAGE && ( + {!isOverviewLoading && totalCount > 0 && ( + } className="border-t border-solid border-t-mineshaft-600" - count={rows.length} + count={totalCount} page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} diff --git a/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx b/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx new file mode 100644 index 000000000..f4492a31d --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount.tsx @@ -0,0 +1,45 @@ +import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type Props = { + folderCount?: number; + importCount?: number; + secretCount?: number; + dynamicSecretCount?: number; +}; + +export const SecretTableResourceCount = ({ + folderCount = 0, + dynamicSecretCount = 0, + secretCount = 0, + importCount = 0 +}: Props) => { + return ( +
+ {importCount > 0 && ( +
+ + {importCount} +
+ )} + {folderCount > 0 && ( +
+ + {folderCount} +
+ )} + {dynamicSecretCount > 0 && ( +
+ + {dynamicSecretCount} +
+ )} + {secretCount > 0 && ( +
+ + {secretCount} +
+ )} +
+ ); +}; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/index.tsx b/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/index.tsx new file mode 100644 index 000000000..d2b5705fa --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/index.tsx @@ -0,0 +1 @@ +export { SecretTableResourceCount } from "./SecretTableResourceCount"; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx b/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx index 50ed175b4..e94189261 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx @@ -1,15 +1,16 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2"; import { useProjectPermission, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useGetWorkspaceById, useMigrateProjectToV3 } from "@app/hooks/api"; +import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; @@ -28,6 +29,7 @@ const formSchema = z.object({ export const SecretV2MigrationSection = () => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const); const { currentWorkspace } = useWorkspace(); + const queryClient = useQueryClient(); const { data: workspaceDetails, refetch } = useGetWorkspaceById( // if v3 no need to fetch currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "", @@ -51,6 +53,7 @@ export const SecretV2MigrationSection = () => { if (isProjectUpgraded && migrateProjectToV3.data) { createNotification({ type: "success", text: "Project upgrade completed successfully" }); migrateProjectToV3.reset(); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); } }, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]); @@ -78,7 +81,7 @@ export const SecretV2MigrationSection = () => { const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin); return ( -
+
{isUpgrading && (
@@ -88,18 +91,29 @@ export const SecretV2MigrationSection = () => {
)} -

Action Required

-

- Infisical secrets engine is now 10x faster and allows you to encrypt secrets with your own - KMS. Upgrade your project to receive these improvements. +

+ +

+ Upgrade your project +

+
+

+ Your existing workflows to fetch secrets will continue to work. However, viewing secrets on the UI requires you to upgrade your project. +

+

+ Upgrading your project enables the use of Infisical's new secrets engine, which is 10x faster and + allows you to encrypt secrets with your own KMS provider. +

+

+ The upgrade takes only 1-2 minutes and will not cause any downtime.

{didProjectUpgradeFailed && (