From 246fe8113407d93f701df6fe194c8c2981e75ad6 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 10 Jul 2025 11:17:45 -0300 Subject: [PATCH 01/15] feat(secrets-ui): Improve SecretDashboard table to support multiple changes on a single commit --- backend/src/ee/routes/v1/pit-router.ts | 135 +++- backend/src/ee/services/pit/pit-service.ts | 285 +++++++- backend/src/server/routes/index.ts | 7 +- .../folder-commit/folder-commit-service.ts | 36 +- .../secret-folder/secret-folder-service.ts | 454 ++++++++++++- .../secret-folder/secret-folder-types.ts | 29 + .../secret-v2-bridge/secret-v2-bridge-fns.ts | 107 +-- .../secret-v2-bridge-service.ts | 110 ++-- .../secret-v2-bridge-types.ts | 5 +- backend/src/services/secret/secret-types.ts | 30 + frontend/src/hooks/api/secretFolders/types.ts | 2 + frontend/src/hooks/api/secrets/mutations.tsx | 101 +++ frontend/src/hooks/api/secrets/types.ts | 2 + .../SecretApprovalRequestChanges.tsx | 2 +- .../SecretDashboardPage.tsx | 184 +++++- .../SecretMainPage.store.tsx | 504 ++++++++++++++- .../components/ActionBar/ActionBar.tsx | 27 + .../components/ActionBar/FolderForm.tsx | 9 +- .../components/CommitForm/CommitForm.tsx | 595 +++++++++++++++++ .../components/CommitForm/index.tsx | 1 + .../CreateSecretForm/CreateSecretForm.tsx | 32 +- .../FolderListView/FolderListView.tsx | 220 +++++-- .../components/SecretListView/SecretItem.tsx | 609 ++++++++++-------- .../SecretListView/SecretListView.tsx | 190 +++++- 24 files changed, 3177 insertions(+), 499 deletions(-) create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx create mode 100644 frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/index.tsx diff --git a/backend/src/ee/routes/v1/pit-router.ts b/backend/src/ee/routes/v1/pit-router.ts index f993e31d7..27d17fc25 100644 --- a/backend/src/ee/routes/v1/pit-router.ts +++ b/backend/src/ee/routes/v1/pit-router.ts @@ -3,11 +3,14 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { removeTrailingSlash } from "@app/lib/fn"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { isValidFolderName } from "@app/lib/validator"; +import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; +import { SecretNameSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { booleanSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas"; +import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; const commitHistoryItemSchema = z.object({ id: z.string(), @@ -413,4 +416,134 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { return result; } }); + + server.route({ + method: "POST", + url: "/batch/commit", + config: { + rateLimit: secretsLimit + }, + schema: { + hide: true, + description: "Commit changes", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + message: z.string().trim(), + changes: z.object({ + secrets: z.object({ + create: z + .array( + z.object({ + secretKey: SecretNameSchema, + secretValue: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), + secretComment: z.string().trim().optional().default(""), + skipMultilineEncoding: z.boolean().optional(), + metadata: z.record(z.string()).optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tagIds: z.string().array().optional() + }) + ) + .optional(), + update: z + .array( + z.object({ + secretKey: SecretNameSchema, + newSecretName: SecretNameSchema.optional(), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional(), + secretComment: z.string().trim().optional().default(""), + skipMultilineEncoding: z.boolean().optional(), + metadata: z.record(z.string()).optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tagIds: z.string().array().optional() + }) + ) + .optional(), + delete: z + .array( + z.object({ + secretKey: SecretNameSchema + }) + ) + .optional() + }), + folders: z.object({ + create: z + .array( + z.object({ + folderName: z + .string() + .trim() + .refine((name) => isValidFolderName(name), { + message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed." + }), + description: z.string().optional() + }) + ) + .optional(), + update: z + .array( + z.object({ + folderName: z + .string() + .trim() + .refine((name) => isValidFolderName(name), { + message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed." + }), + description: z.string().optional(), + id: z.string() + }) + ) + .optional(), + delete: z + .array( + z.object({ + folderName: z + .string() + .trim() + .refine((name) => isValidFolderName(name), { + message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed." + }), + id: z.string() + }) + ) + .optional() + }) + }) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.pit.processNewCommitRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectId: req.body.workspaceId, + environment: req.body.environment, + secretPath: req.body.secretPath, + message: req.body.message, + changes: { + secrets: req.body.changes.secrets, + folders: req.body.changes.folders + } + }); + return { message: "success" }; + } + }); }; diff --git a/backend/src/ee/services/pit/pit-service.ts b/backend/src/ee/services/pit/pit-service.ts index c2a485b35..c31f9184e 100644 --- a/backend/src/ee/services/pit/pit-service.ts +++ b/backend/src/ee/services/pit/pit-service.ts @@ -2,28 +2,50 @@ import { ForbiddenError } from "@casl/ability"; import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; -import { ResourceType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; +import { TFolderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; +import { + ResourceType, + TCreateCommitChangeDTO, + TFolderCommitServiceFactory +} from "@app/services/folder-commit/folder-commit-service"; import { isFolderCommitChange, isSecretCommitChange } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; +import { SecretProtectionType, TProcessNewCommitRawDTO } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; +import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; +import { SecretOperations, SecretUpdateMode } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TSecretApprovalPolicyServiceFactory } from "../secret-approval-policy/secret-approval-policy-service"; +import { TSecretApprovalRequestServiceFactory } from "../secret-approval-request/secret-approval-request-service"; type TPitServiceFactoryDep = { folderCommitService: TFolderCommitServiceFactory; secretService: Pick; - folderService: Pick; + folderService: Pick< + TSecretFolderServiceFactory, + "getFolderById" | "getFolderVersions" | "createManyFolders" | "updateManyFolders" | "deleteManyFolders" + >; permissionService: Pick; - folderDAL: Pick; + folderDAL: Pick; projectEnvDAL: Pick; + secretApprovalRequestService: Pick< + TSecretApprovalRequestServiceFactory, + "generateSecretApprovalRequest" | "generateSecretApprovalRequestV2Bridge" + >; + secretApprovalPolicyService: Pick; + projectDAL: Pick; + secretV2BridgeService: TSecretV2BridgeServiceFactory; + folderCommitDAL: Pick; }; export type TPitServiceFactory = ReturnType; @@ -34,7 +56,12 @@ export const pitServiceFactory = ({ folderService, permissionService, folderDAL, - projectEnvDAL + projectEnvDAL, + secretApprovalRequestService, + secretApprovalPolicyService, + projectDAL, + secretV2BridgeService, + folderCommitDAL }: TPitServiceFactoryDep) => { const getCommitsCount = async ({ actor, @@ -471,6 +498,251 @@ export const pitServiceFactory = ({ }); }; + const processNewCommitRaw = async ({ + actorId, + projectSlug, + projectId: optionalProjectId, + environment, + actor, + actorOrgId, + actorAuthMethod, + secretPath, + message, + changes = { + secrets: { + create: [], + update: [], + delete: [] + }, + folders: { + create: [], + update: [], + delete: [] + } + } + }: { + actorId: string; + projectSlug?: string; + projectId?: string; + environment: string; + actor: ActorType; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + secretPath: string; + message: string; + changes: TProcessNewCommitRawDTO; + }) => { + if (!projectSlug && !optionalProjectId) + throw new BadRequestError({ message: "Must provide either project slug or projectId" }); + + let projectId = optionalProjectId as string; + if (!optionalProjectId && projectSlug) { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + projectId = project.id; + } + + const policy = + actor === ActorType.USER + ? await secretApprovalPolicyService.getSecretApprovalPolicy(projectId, environment, secretPath) + : undefined; + + const project = await projectDAL.findById(projectId); + if (project.enforceCapitalization) { + const caseViolatingSecretKeys = [ + // Check create operations + ...(changes.secrets?.create + ?.filter((sec) => sec.secretKey !== sec.secretKey.toUpperCase()) + .map((sec) => sec.secretKey) ?? []), + + // Check update operations + ...(changes.secrets?.update + ?.filter( + (sec) => + sec.secretKey !== sec.secretKey.toUpperCase() || + (sec.newSecretKey && sec.newSecretKey !== sec.newSecretKey.toUpperCase()) + ) + .map((sec) => sec.secretKey) ?? []) + ]; + + if (caseViolatingSecretKeys.length) { + throw new BadRequestError({ + message: `Secret names must be in UPPERCASE per project requirements: ${caseViolatingSecretKeys.join( + ", " + )}. You can disable this requirement in project settings` + }); + } + } + + await folderCommitDAL.transaction(async (trx) => { + const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!targetFolder) + throw new NotFoundError({ + message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`, + name: "CreateManySecret" + }); + const commitChanges: TCreateCommitChangeDTO[] = []; + + if ((changes.folders?.create?.length ?? 0) > 0) { + await folderService.createManyFolders({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + folders: + changes.folders?.create?.map((folder) => ({ + name: folder.folderName, + environment, + path: secretPath, + description: folder.description + })) ?? [], + tx: trx, + commitChanges + }); + } + + if ((changes.folders?.update?.length ?? 0) > 0) { + await folderService.updateManyFolders({ + projectId, + projectSlug: projectSlug || "", + actor, + actorId, + actorOrgId, + actorAuthMethod, + folders: + changes.folders?.update?.map((folder) => ({ + environment, + path: secretPath, + id: folder.id, + name: folder.folderName, + description: folder.description + })) ?? [], + tx: trx, + commitChanges + }); + } + + if ((changes.folders?.delete?.length ?? 0) > 0) { + await folderService.deleteManyFolders({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + folders: + changes.folders?.delete?.map((folder) => ({ + environment, + path: secretPath, + idOrName: folder.id + })) ?? [], + tx: trx, + commitChanges + }); + } + + if (policy) { + const approval = await secretApprovalRequestService.generateSecretApprovalRequestV2Bridge({ + policy, + secretPath, + environment, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + data: { + [SecretOperations.Create]: + changes.secrets?.create?.map((el) => ({ + tagIds: el.tagIds, + secretValue: el.secretValue, + secretComment: el.secretComment, + metadata: el.metadata, + skipMultilineEncoding: el.skipMultilineEncoding, + secretKey: el.secretKey, + secretMetadata: el.secretMetadata + })) ?? [], + [SecretOperations.Update]: + changes.secrets?.update?.map((el) => ({ + tagIds: el.tagIds, + secretValue: el.secretValue, + secretComment: el.secretComment, + metadata: el.metadata, + skipMultilineEncoding: el.skipMultilineEncoding, + secretKey: el.secretKey, + secretMetadata: el.secretMetadata + })) ?? [], + [SecretOperations.Delete]: + changes.secrets?.delete?.map((el) => ({ + secretKey: el.secretKey + })) ?? [] + } + }); + return { type: SecretProtectionType.Approval as const, approval }; + } + + if ((changes.secrets?.create?.length ?? 0) > 0) { + await secretV2BridgeService.createManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: changes.secrets?.create ?? [], + tx: trx, + commitChanges + }); + } + if ((changes.secrets?.update?.length ?? 0) > 0) { + await secretV2BridgeService.updateManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: changes.secrets?.update ?? [], + mode: SecretUpdateMode.FailOnNotFound, + tx: trx, + commitChanges + }); + } + if ((changes.secrets?.delete?.length ?? 0) > 0) { + await secretV2BridgeService.deleteManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: changes.secrets?.delete ?? [], + tx: trx, + commitChanges + }); + } + if (commitChanges?.length > 0) { + await folderCommitService.createCommit( + { + actor: { + type: actor || ActorType.PLATFORM, + metadata: { + id: actorId + } + }, + message, + folderId: targetFolder.id, + changes: commitChanges + }, + trx + ); + } + }); + }; + return { getCommitsCount, getCommitsForFolder, @@ -478,6 +750,7 @@ export const pitServiceFactory = ({ compareCommitChanges, rollbackToCommit, revertCommit, - getFolderStateAtCommit + getFolderStateAtCommit, + processNewCommitRaw }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8500e6518..5391abdf3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1536,7 +1536,12 @@ export const registerRoutes = async ( folderService, permissionService, folderDAL, - projectEnvDAL + projectEnvDAL, + secretApprovalRequestService, + secretApprovalPolicyService, + projectDAL, + secretV2BridgeService, + folderCommitDAL }); const identityOidcAuthService = identityOidcAuthServiceFactory({ diff --git a/backend/src/services/folder-commit/folder-commit-service.ts b/backend/src/services/folder-commit/folder-commit-service.ts index 3576f444b..6d78ef50d 100644 --- a/backend/src/services/folder-commit/folder-commit-service.ts +++ b/backend/src/services/folder-commit/folder-commit-service.ts @@ -47,6 +47,14 @@ export enum ResourceType { FOLDER = "folder" } +export type TCreateCommitChangeDTO = { + type: string; + secretVersionId?: string; + folderVersionId?: string; + isUpdate?: boolean; + folderId?: string; +}; + type TCreateCommitDTO = { actor: { type: string; @@ -57,13 +65,7 @@ type TCreateCommitDTO = { }; message?: string; folderId: string; - changes: { - type: string; - secretVersionId?: string; - folderVersionId?: string; - isUpdate?: boolean; - folderId?: string; - }[]; + changes: TCreateCommitChangeDTO[]; omitIgnoreFilter?: boolean; }; @@ -895,16 +897,16 @@ export const folderCommitServiceFactory = ({ } } - const newCommit = await folderCommitDAL.create( - { - actorMetadata: metadata, - actorType: data.actor.type, - message: data.message, - folderId: data.folderId, - envId: folder.envId - }, - tx - ); + const newCommit = await folderCommitDAL.create( + { + actorMetadata: metadata, + actorType: data.actor.type, + message: data.message, + folderId: data.folderId, + envId: folder.envId + }, + tx + ); const batchSize = 500; const chunks = chunkArray(changes, batchSize); diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index e06cfcb01..06b7dc5dc 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -1,4 +1,6 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError, subject } from "@casl/ability"; +import { Knex } from "knex"; import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; @@ -12,14 +14,21 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; -import { ChangeType, CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { + ChangeType, + CommitType, + TCreateCommitChangeDTO, + TFolderCommitServiceFactory +} from "../folder-commit/folder-commit-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; import { TCreateFolderDTO, + TCreateManyFoldersDTO, TDeleteFolderDTO, + TDeleteManyFoldersDTO, TGetFolderByIdDTO, TGetFolderDTO, TGetFoldersDeepByEnvsDTO, @@ -236,19 +245,26 @@ export const secretFolderServiceFactory = ({ actor, actorId, projectSlug, + projectId: providedProjectId, actorAuthMethod, actorOrgId, - folders - }: TUpdateManyFoldersDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) { - throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + folders, + tx: providedTx, + commitChanges + }: TUpdateManyFoldersDTO & { tx?: Knex; commitChanges?: TCreateCommitChangeDTO[]; projectId?: string }) => { + let projectId = providedProjectId; + if (!projectId) { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) { + throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + } + projectId = project.id; } const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: project.id, + projectId, actorAuthMethod, actorOrgId }); @@ -260,12 +276,12 @@ export const secretFolderServiceFactory = ({ ); }); - const result = await folderDAL.transaction(async (tx) => - Promise.all( + const executeBulkUpdate = async (tx: Knex) => { + return Promise.all( folders.map(async (newFolder) => { const { environment, path: secretPath, id, name, description } = newFolder; - const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + const parentFolder = await folderDAL.findBySecretPath(projectId as string, environment, secretPath, tx); if (!parentFolder) { throw new NotFoundError({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`, @@ -273,10 +289,10 @@ export const secretFolderServiceFactory = ({ }); } - const env = await projectEnvDAL.findOne({ projectId: project.id, slug: environment }); + const env = await projectEnvDAL.findOne({ projectId, slug: environment }); if (!env) { throw new NotFoundError({ - message: `Environment with slug '${environment}' in project with ID '${project.id}' not found`, + message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`, name: "UpdateManyFolders" }); } @@ -323,26 +339,34 @@ export const secretFolderServiceFactory = ({ }, tx ); - await folderCommitService.createCommit( - { - actor: { - type: actor, - metadata: { - id: actorId - } + if (commitChanges) { + commitChanges.push({ + type: CommitType.ADD, + isUpdate: true, + folderVersionId: folderVersion.id + }); + } else { + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder updated", + folderId: parentFolder.id, + changes: [ + { + type: CommitType.ADD, + isUpdate: true, + folderVersionId: folderVersion.id + } + ] }, - message: "Folder updated", - folderId: parentFolder.id, - changes: [ - { - type: CommitType.ADD, - isUpdate: true, - folderVersionId: folderVersion.id - } - ] - }, - tx - ); + tx + ); + } if (!doc) { throw new NotFoundError({ message: `Failed to update folder with id '${id}', not found`, @@ -352,13 +376,16 @@ export const secretFolderServiceFactory = ({ return { oldFolder: folder, newFolder: doc }; }) - ) - ); + ); + }; + + // Execute with provided transaction or create new one + const result = providedTx ? await executeBulkUpdate(providedTx) : await folderDAL.transaction(executeBulkUpdate); await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string))); return { - projectId: project.id, + projectId, newFolders: result.map((res) => res.newFolder), oldFolders: result.map((res) => res.oldFolder) }; @@ -974,6 +1001,361 @@ export const secretFolderServiceFactory = ({ })); }; + const createManyFolders = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + folders, + tx: providedTx, + commitChanges + }: TCreateManyFoldersDTO & { tx?: Knex; commitChanges?: TCreateCommitChangeDTO[] }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }); + + folders.forEach(({ environment, path: secretPath }) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.SecretFolders, { environment, secretPath }) + ); + }); + + const foldersByEnv = folders.reduce( + (acc, folder) => { + if (!acc[folder.environment]) { + acc[folder.environment] = []; + } + acc[folder.environment].push(folder); + return acc; + }, + {} as Record + ); + + const executeBulkCreate = async (tx: Knex) => { + const createdFolders = []; + + for (const [environment, envFolders] of Object.entries(foldersByEnv)) { + const env = await projectEnvDAL.findOne({ projectId, slug: environment }); + if (!env) { + throw new NotFoundError({ + message: `Environment with slug '${environment}' in project with ID '${projectId}' not found` + }); + } + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateFolder(env.id, env.projectId)]); + + for (const folderSpec of envFolders) { + const { name, path: secretPath, description } = folderSpec; + + const pathWithFolder = path.join(secretPath, name); + const parentFolder = await folderDAL.findClosestFolder(projectId, environment, pathWithFolder, tx); + + if (!parentFolder) { + throw new NotFoundError({ + message: `Parent folder for path '${pathWithFolder}' not found` + }); + } + + // Check if the exact folder already exists + const existingFolder = await folderDAL.findOne( + { + envId: env.id, + parentId: parentFolder.id, + name, + isReserved: false + }, + tx + ); + + if (existingFolder) { + createdFolders.push(existingFolder); + // eslint-disable-next-line no-continue + continue; + } + + // Handle exact folder case + if (parentFolder.path === pathWithFolder) { + createdFolders.push(parentFolder); + // eslint-disable-next-line no-continue + continue; + } + + let currentParentId = parentFolder.id; + + // Build the full path we need by processing each segment + if (parentFolder.path !== secretPath) { + const missingSegments = secretPath.substring(parentFolder.path.length).split("/").filter(Boolean); + const newFolders: TSecretFoldersInsert[] = []; + + for (const segment of missingSegments) { + const existingSegment = await folderDAL.findOne( + { + name: segment, + parentId: currentParentId, + envId: env.id, + isReserved: false + }, + tx + ); + + if (existingSegment) { + currentParentId = existingSegment.id; + } else { + const newFolder = { + name: segment, + parentId: currentParentId, + id: uuidv4(), + envId: env.id, + version: 1 + }; + + currentParentId = newFolder.id; + newFolders.push(newFolder); + } + } + + if (newFolders.length) { + const docs = await folderDAL.insertMany(newFolders, tx); + const folderVersions = await folderVersionDAL.insertMany( + docs.map((doc) => ({ + name: doc.name, + envId: doc.envId, + version: doc.version, + folderId: doc.id, + description: doc.description + })), + tx + ); + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folders created (batch)", + folderId: currentParentId, + changes: folderVersions.map((fv) => ({ + type: CommitType.ADD, + folderVersionId: fv.id + })) + }, + tx + ); + } + } + + // Create the target folder + const doc = await folderDAL.create( + { name, envId: env.id, version: 1, parentId: currentParentId, description }, + tx + ); + + const folderVersion = await folderVersionDAL.create( + { + name: doc.name, + envId: doc.envId, + version: doc.version, + folderId: doc.id, + description: doc.description + }, + tx + ); + + if (commitChanges) { + commitChanges.push({ + type: CommitType.ADD, + folderVersionId: folderVersion.id + }); + } else { + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder created (batch)", + folderId: doc.id, + changes: [ + { + type: CommitType.ADD, + folderVersionId: folderVersion.id + } + ] + }, + tx + ); + } + + createdFolders.push(doc); + } + } + + return createdFolders; + }; + const result = providedTx ? await executeBulkCreate(providedTx) : await folderDAL.transaction(executeBulkCreate); + const uniqueParentIds = [...new Set(result.map((folder) => folder.parentId).filter(Boolean))]; + await Promise.all(uniqueParentIds.map((parentId) => snapshotService.performSnapshot(parentId as string))); + + return { + folders: result, + count: result.length + }; + }; + + const deleteManyFolders = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + folders, + tx: providedTx, + commitChanges + }: TDeleteManyFoldersDTO & { tx?: Knex; commitChanges?: TCreateCommitChangeDTO[] }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }); + + folders.forEach(({ environment, path: secretPath }) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.SecretFolders, { environment, secretPath }) + ); + }); + + const foldersByEnv = folders.reduce( + (acc, folder) => { + if (!acc[folder.environment]) { + acc[folder.environment] = []; + } + acc[folder.environment].push(folder); + return acc; + }, + {} as Record + ); + + const executeBulkDelete = async (tx: Knex) => { + const deletedFolders = []; + + for (const [environment, envFolders] of Object.entries(foldersByEnv)) { + const env = await projectEnvDAL.findOne({ projectId, slug: environment }); + if (!env) { + throw new NotFoundError({ + message: `Environment with slug '${environment}' not found` + }); + } + + for (const folderSpec of envFolders) { + const { path: secretPath, idOrName } = folderSpec; + + const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx); + if (!parentFolder) { + throw new NotFoundError({ + message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` + }); + } + + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + + let folderToDelete = await folderDAL + .findOne({ + envId: env.id, + name: idOrName, + parentId: parentFolder.id, + isReserved: false + }) + .catch(() => null); + + if (!folderToDelete && uuidValidate(idOrName)) { + folderToDelete = await folderDAL + .findOne({ + envId: env.id, + id: idOrName, + parentId: parentFolder.id, + isReserved: false + }) + .catch(() => null); + } + + if (!folderToDelete) { + throw new NotFoundError({ + message: `Folder with ID/name '${idOrName}' not found` + }); + } + + const [doc] = await folderDAL.delete( + { + envId: env.id, + id: folderToDelete.id, + parentId: parentFolder.id, + isReserved: false + }, + tx + ); + + const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx); + + if (commitChanges) { + commitChanges.push({ + type: CommitType.DELETE, + folderVersionId: folderVersions[doc.id].id, + folderId: doc.id + }); + } else { + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder deleted (batch)", + folderId: parentFolder.id, + changes: [ + { + type: CommitType.DELETE, + folderVersionId: folderVersions[doc.id].id, + folderId: doc.id + } + ] + }, + tx + ); + } + + deletedFolders.push(doc); + } + } + + return deletedFolders; + }; + + const result = providedTx ? await executeBulkDelete(providedTx) : await folderDAL.transaction(executeBulkDelete); + + const uniqueParentIds = [...new Set(result.map((folder) => folder.parentId).filter(Boolean))]; + await Promise.all(uniqueParentIds.map((parentId) => snapshotService.performSnapshot(parentId as string))); + + return { + folders: result, + count: result.length + }; + }; + return { createFolder, updateFolder, @@ -986,6 +1368,8 @@ export const secretFolderServiceFactory = ({ getFoldersDeepByEnvs, getProjectEnvironmentsFolders, getFolderVersionsByIds, - getFolderVersions + getFolderVersions, + createManyFolders, + deleteManyFolders }; }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 4008676db..b57e4073f 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -1,6 +1,8 @@ import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + export enum ReservedFolders { SecretReplication = "__reserve_replication_" } @@ -62,3 +64,30 @@ export type TGetFoldersDeepByEnvsDTO = { export type TFindFoldersDeepByParentIdsDTO = { parentIds: string[]; }; + +export type TCreateManyFoldersDTO = { + projectId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId?: string; + folders: Array<{ + name: string; + environment: string; + path: string; + description?: string; + }>; +}; + +export type TDeleteManyFoldersDTO = { + projectId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId?: string; + folders: Array<{ + environment: string; + path: string; + idOrName: string; + }>; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 01718d570..3cab7ec3c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -67,6 +67,7 @@ export const getAllSecretReferences = (maybeSecretReference: string) => { export const fnSecretBulkInsert = async ({ // TODO: Pick types here folderId, + commitChanges, orgId, inputSecrets, secretDAL, @@ -134,28 +135,32 @@ export const fnSecretBulkInsert = async ({ tx ); - const commitChanges = secretVersions + const changes = secretVersions .filter(({ type }) => type === SecretType.Shared) .map((sv) => ({ type: CommitType.ADD, secretVersionId: sv.id })); - if (commitChanges.length > 0) { - await folderCommitService.createCommit( - { - actor: { - type: actorType || ActorType.PLATFORM, - metadata: { - id: actor?.actorId - } + if (changes.length > 0) { + if (commitChanges) { + commitChanges.push(...changes); + } else { + await folderCommitService.createCommit( + { + actor: { + type: actorType || ActorType.PLATFORM, + metadata: { + id: actor?.actorId + } + }, + message: "Secret Created", + folderId, + changes }, - message: "Secret Created", - folderId, - changes: commitChanges - }, - tx - ); + tx + ); + } } await secretDAL.upsertSecretReferences( @@ -209,6 +214,7 @@ export const fnSecretBulkUpdate = async ({ tx, inputSecrets, folderId, + commitChanges, orgId, secretDAL, secretVersionDAL, @@ -359,28 +365,32 @@ export const fnSecretBulkUpdate = async ({ { tx } ); - const commitChanges = secretVersions + const changes = secretVersions .filter(({ type }) => type === SecretType.Shared) .map((sv) => ({ type: CommitType.ADD, isUpdate: true, secretVersionId: sv.id })); - if (commitChanges.length > 0) { - await folderCommitService.createCommit( - { - actor: { - type: actorType || ActorType.PLATFORM, - metadata: { - id: actor?.actorId - } + if (changes.length > 0) { + if (commitChanges) { + commitChanges.push(...changes); + } else { + await folderCommitService.createCommit( + { + actor: { + type: actorType || ActorType.PLATFORM, + metadata: { + id: actor?.actorId + } + }, + message: "Secret Updated", + folderId, + changes }, - message: "Secret Updated", - folderId, - changes: commitChanges - }, - tx - ); + tx + ); + } } return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id })); @@ -395,7 +405,8 @@ export const fnSecretBulkDelete = async ({ secretDAL, secretQueueService, folderCommitService, - secretVersionDAL + secretVersionDAL, + commitChanges }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretKey }) => ({ @@ -421,27 +432,31 @@ export const fnSecretBulkDelete = async ({ tx ); - const commitChanges = deletedSecrets + const changes = deletedSecrets .filter(({ type }) => type === SecretType.Shared) .map(({ id }) => ({ type: CommitType.DELETE, secretVersionId: secretVersions[id].id })); - if (commitChanges.length > 0) { - await folderCommitService.createCommit( - { - actor: { - type: actorType || ActorType.PLATFORM, - metadata: { - id: actorId - } + if (changes.length > 0) { + if (commitChanges) { + commitChanges.push(...changes); + } else { + await folderCommitService.createCommit( + { + actor: { + type: actorType || ActorType.PLATFORM, + metadata: { + id: actorId + } + }, + message: "Secret Deleted", + folderId, + changes }, - message: "Secret Deleted", - folderId, - changes: commitChanges - }, - tx - ); + tx + ); + } } return deletedSecrets; 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 71180772f..8db15a7e6 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 @@ -28,7 +28,7 @@ import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "../auth/auth-type"; -import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { TCreateCommitChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -1474,8 +1474,10 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, environment, projectId, - secrets: inputSecrets - }: TCreateManySecretDTO) => { + secrets: inputSecrets, + tx: providedTx, + commitChanges + }: TCreateManySecretDTO & { tx?: Knex; commitChanges?: TCreateCommitChangeDTO[] }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -1558,8 +1560,8 @@ export const secretV2BridgeServiceFactory = ({ const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); - const newSecrets = await secretDAL.transaction(async (tx) => - fnSecretBulkInsert({ + const executeBulkInsert = async (tx: Knex) => { + return fnSecretBulkInsert({ inputSecrets: inputSecrets.map((el) => { const references = secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences; @@ -1581,6 +1583,7 @@ export const secretV2BridgeServiceFactory = ({ }; }), folderId, + commitChanges, orgId: actorOrgId, secretDAL, resourceMetadataDAL, @@ -1593,8 +1596,13 @@ export const secretV2BridgeServiceFactory = ({ actorId }, tx - }) - ); + }); + }; + + const newSecrets = providedTx + ? await executeBulkInsert(providedTx) + : await secretDAL.transaction(executeBulkInsert); + await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -1641,8 +1649,10 @@ export const secretV2BridgeServiceFactory = ({ projectId, secretPath: defaultSecretPath = "/", secrets: inputSecrets, - mode: updateMode - }: TUpdateManySecretDTO) => { + mode: updateMode, + tx: providedTx, + commitChanges + }: TUpdateManySecretDTO & { tx?: Knex; commitChanges?: TCreateCommitChangeDTO[] }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -1671,18 +1681,20 @@ export const secretV2BridgeServiceFactory = ({ const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); - const updatedSecrets: Array< - TSecretsV2 & { - secretPath: string; - tags: { - id: string; - slug: string; - color?: string | null; - name: string; - }[]; - } - > = []; - await secretDAL.transaction(async (tx) => { + // Function to execute the bulk update operation + const executeBulkUpdate = async (tx: Knex) => { + const updatedSecrets: Array< + TSecretsV2 & { + secretPath: string; + tags: { + id: string; + slug: string; + color?: string | null; + name: string; + }[]; + } + > = []; + for await (const folder of folders) { if (!folder) throw new NotFoundError({ message: "Folder not found" }); @@ -1801,7 +1813,7 @@ export const secretV2BridgeServiceFactory = ({ { operator: "eq", field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey + value: el.newSecretName as string }, { operator: "eq", @@ -1855,6 +1867,7 @@ export const secretV2BridgeServiceFactory = ({ orgId: actorOrgId, folderCommitService, tx, + commitChanges, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; const encryptedValue = @@ -1934,7 +1947,13 @@ export const secretV2BridgeServiceFactory = ({ updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); } } - }); + + return updatedSecrets; + }; + + const updatedSecrets = providedTx + ? await executeBulkUpdate(providedTx) + : await secretDAL.transaction(executeBulkUpdate); await secretDAL.invalidateSecretCacheByProjectId(projectId); await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined))); @@ -1991,8 +2010,10 @@ export const secretV2BridgeServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId - }: TDeleteManySecretDTO) => { + actorOrgId, + tx: providedTx, + commitChanges + }: TDeleteManySecretDTO & { tx?: Knex; commitChanges?: TCreateCommitChangeDTO[] }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -2051,24 +2072,29 @@ export const secretV2BridgeServiceFactory = ({ ); }); + const executeBulkDelete = async (tx: Knex) => { + return fnSecretBulkDelete({ + secretDAL, + secretQueueService, + folderCommitService, + secretVersionDAL, + inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ + secretKey, + type: type || SecretType.Shared + })), + projectId, + folderId, + actorId, + actorType: actor, + commitChanges, + tx + }); + }; + try { - const secretsDeleted = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ - secretDAL, - secretQueueService, - folderCommitService, - secretVersionDAL, - inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ - secretKey, - type: type || SecretType.Shared - })), - projectId, - folderId, - actorId, - actorType: actor, - tx - }) - ); + const secretsDeleted = providedTx + ? await executeBulkDelete(providedTx) + : await secretDAL.transaction(executeBulkDelete); await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); 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 f4171b1a7..974d7bcba 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 @@ -8,7 +8,7 @@ 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"; -import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { TCreateCommitChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; @@ -167,6 +167,7 @@ export type TFnSecretBulkInsert = { folderId: string; orgId: string; tx?: Knex; + commitChanges?: TCreateCommitChangeDTO[]; inputSecrets: Array< Omit & { tagIds?: string[]; @@ -214,6 +215,7 @@ export type TFnSecretBulkUpdate = { actorId?: string; }; tx?: Knex; + commitChanges?: TCreateCommitChangeDTO[]; }; export type TFnSecretBulkDelete = { @@ -223,6 +225,7 @@ export type TFnSecretBulkDelete = { actorId: string; actorType?: string; tx?: Knex; + commitChanges?: TCreateCommitChangeDTO[]; secretDAL: Pick; secretQueueService: { removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 12b8e7175..9a9d0e9c2 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -544,3 +544,33 @@ export enum SecretProtectionType { } export type TStartSecretsV2MigrationDTO = TProjectPermission; + +export type TProcessNewCommitRawDTO = { + secrets: { + create?: { + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + tagIds?: string[]; + secretMetadata?: ResourceMetadataDTO; + metadata?: { source?: string }; + }[]; + update?: { + secretKey: string; + newSecretKey?: string; + secretValue?: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + tagIds?: string[]; + secretMetadata?: ResourceMetadataDTO; + metadata?: { source?: string }; + }[]; + delete?: { secretKey: string }[]; + }; + folders: { + create?: { folderName: string; description?: string }[]; + update?: { folderName: string; description?: string; id: string }[]; + delete?: { folderName: string; id: string }[]; + }; +}; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 33653ff72..f41267cc8 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -9,6 +9,8 @@ export type TSecretFolder = { name: string; description?: string; parentId?: string | null; + isPending?: boolean; + pendingAction?: string; }; export type TSecretFolderWithPath = TSecretFolder & { path: string }; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 82bf623e6..30c2051a7 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -2,6 +2,10 @@ import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-qu import { apiRequest } from "@app/config/request"; import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { + PendingChanges, + PendingSecretUpdate +} from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.store"; import { commitKeys } from "../folderCommits/queries"; import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries"; @@ -420,3 +424,100 @@ export const useBackfillSecretReference = () => return data.message; } }); + +export const useCreateCommit = () => { + const queryClient = useQueryClient(); + return useMutation< + object, + object, + { + workspaceId: string; + environment: string; + secretPath: string; + pendingChanges: PendingChanges; + message: string; + } + >({ + mutationFn: async ({ workspaceId, environment, secretPath, pendingChanges, message }) => { + const transformedSecretUpdates = pendingChanges.secrets + .filter((change) => change.type === "update") + .map((change: PendingSecretUpdate) => { + const updatePayload: any = { + secretKey: change.secretKey + }; + + // Only include fields that actually changed + if (change.newSecretName) { + updatePayload.newSecretName = change.newSecretName; + } + if (change.secretValue !== undefined) { + updatePayload.secretValue = change.secretValue; + } + if (change.secretComment !== undefined) { + updatePayload.secretComment = change.secretComment; + } + if (change.skipMultilineEncoding !== undefined) { + updatePayload.skipMultilineEncoding = change.skipMultilineEncoding; + } + if (change.tags) { + updatePayload.tagIds = change.tags.map((tag) => tag.id); + } + if (change.secretMetadata) { + updatePayload.secretMetadata = change.secretMetadata; + } + + return updatePayload; + }); + const { data } = await apiRequest.post("/api/v1/pit/batch/commit", { + workspaceId, + environment, + secretPath, + changes: { + secrets: { + create: + pendingChanges.secrets + .filter((change) => change.type === "create") + .map((change) => ({ + secretKey: change.secretKey, + secretValue: change.secretValue, + secretComment: change.secretComment, + skipMultilineEncoding: change.skipMultilineEncoding, + tagIds: change.tags?.map((tag) => tag.id), + secretMetadata: change.secretMetadata + })) || [], + update: transformedSecretUpdates, + delete: pendingChanges.secrets.filter((change) => change.type === "delete") || [] + }, + folders: { + create: pendingChanges.folders.filter((change) => change.type === "create") || [], + update: pendingChanges.folders.filter((change) => change.type === "update") || [], + delete: pendingChanges.folders.filter((change) => change.type === "delete") || [] + } + }, + message + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); + } + }); +}; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index fa597169f..33776a629 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -66,6 +66,8 @@ export type SecretV3RawSanitized = { isRotatedSecret?: boolean; secretReminderRecipients?: SecretReminderRecipient[]; rotationId?: string; + isPending?: boolean; + pendingAction?: "create" | "update" | "delete"; }; export type SecretV3Raw = { diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index d6931abb1..660ddfd65 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -72,7 +72,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica if (score[CommitType.DELETE]) text.push( - {Boolean(text.length) && "and"} + {Boolean(text.length) && " and "} {score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"} Deleted diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 0f1c194d2..b056c22cd 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; @@ -46,12 +47,14 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useCreateCommit } from "@app/hooks/api/secrets/mutations"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection"; import { ActionBar } from "./components/ActionBar"; +import { CommitForm } from "./components/CommitForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { DynamicSecretListView } from "./components/DynamicSecretListView"; import { FolderListView } from "./components/FolderListView"; @@ -61,8 +64,11 @@ import { SecretImportListView } from "./components/SecretImportListView"; import { SecretListView, SecretNoAccessListView } from "./components/SecretListView"; import { SnapshotView } from "./components/SnapshotView"; import { + PendingChanges, PopUpNames, StoreProvider, + useBatchMode, + useBatchModeActions, usePopUpAction, usePopUpState, useSelectedSecretActions, @@ -90,8 +96,11 @@ const Page = () => { }); const { permission } = useProjectPermission(); + const { mutateAsync: createCommit } = useCreateCommit(); const [isVisible, setIsVisible] = useState(false); + const { isBatchMode, pendingChanges } = useBatchMode(); + const { loadPendingChanges, setExistingKeys } = useBatchModeActions(); const { offset, @@ -121,6 +130,22 @@ const Page = () => { const projectSlug = currentWorkspace?.slug || ""; const secretPath = (routerQueryParams.secretPath as string) || "/"; + useEffect(() => { + if (isBatchMode && workspaceId && environment && secretPath) { + loadPendingChanges({ workspaceId, environment, secretPath }); + } + }, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]); + + const handleCreateCommit = (changes: PendingChanges, message: string) => { + createCommit({ + workspaceId, + environment, + secretPath, + pendingChanges: changes, + message + }); + }; + const canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, @@ -350,6 +375,16 @@ const Page = () => { noAccessSecretCount ); + useEffect(() => { + if (data && isBatchMode) { + const existingSecretKeys = [...(secrets?.map((s) => s.key) || [])]; + + const existingFolderNames = folders?.map((f) => f.name) || []; + + setExistingKeys(existingSecretKeys, existingFolderNames); + } + }, [data, isBatchMode, setExistingKeys, secrets, importedSecrets, folders]); + const handleSortToggle = () => setOrderDirection((state) => state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC @@ -476,6 +511,134 @@ const Page = () => { setFilter(defaultFilterState); setDebouncedSearchFilter(""); }; + + const getMergedSecretsWithPending = () => { + if (!isBatchMode || pendingChanges.secrets.length === 0) { + return secrets; + } + + const mergedSecrets = [...(secrets || [])]; + + pendingChanges.secrets.forEach((change) => { + switch (change.type) { + case "create": + mergedSecrets.unshift({ + id: change.id, + key: change.secretKey, + value: change.secretValue, + comment: change.secretComment || "", + skipMultilineEncoding: change.skipMultilineEncoding || false, + tags: change.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [], + secretMetadata: change.secretMetadata || [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + isPending: true, + pendingAction: "create" + } as any); + break; + + case "update": + const updateIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey); + if (updateIndex >= 0) { + mergedSecrets[updateIndex] = { + ...mergedSecrets[updateIndex], + key: change.newSecretName || change.secretKey, + value: + change.secretValue !== undefined + ? change.secretValue + : mergedSecrets[updateIndex].value, + comment: + change.secretComment !== undefined + ? change.secretComment + : mergedSecrets[updateIndex].comment, + skipMultilineEncoding: + change.skipMultilineEncoding !== undefined + ? change.skipMultilineEncoding + : mergedSecrets[updateIndex].skipMultilineEncoding, + secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata, + isPending: true, + pendingAction: "update" + }; + } + break; + + case "delete": + const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey); + if (deleteIndex >= 0) { + mergedSecrets[deleteIndex] = { + ...mergedSecrets[deleteIndex], + isPending: true, + pendingAction: "delete" + }; + } + break; + + default: + break; + } + }); + + return mergedSecrets; + }; + + const getMergedFoldersWithPending = () => { + if (!isBatchMode || pendingChanges.folders.length === 0) { + return folders; + } + + const mergedFolders = [...(folders || [])]; + + pendingChanges.folders.forEach((change) => { + switch (change.type) { + case "create": + mergedFolders.unshift({ + id: change.id, + name: change.folderName, + description: change.description, + parentId: null, + isPending: true, + pendingAction: "create" + } as any); + break; + + case "update": + const updateIndex = mergedFolders.findIndex((f) => f.id === change.id); + if (updateIndex >= 0) { + mergedFolders[updateIndex] = { + ...mergedFolders[updateIndex], + name: change.folderName, + description: + change.description !== undefined + ? change.description + : mergedFolders[updateIndex].description, + isPending: true, + pendingAction: "update" + }; + } + break; + + case "delete": + const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id); + if (deleteIndex >= 0) { + mergedFolders[deleteIndex] = { + ...mergedFolders[deleteIndex], + isPending: true, + pendingAction: "delete" + }; + } + break; + + default: + break; + } + }); + + return mergedFolders; + }; + + const mergedSecrets = getMergedSecretsWithPending(); + const mergedFolders = getMergedFoldersWithPending(); return (
@@ -487,6 +650,7 @@ const Page = () => { projectSlug={projectSlug} secretPath={secretPath} isVisible={isVisible} + isBatchMode={isBatchMode} filter={filter} tags={tags} onVisibilityToggle={handleToggleVisibility} @@ -559,9 +723,9 @@ const Page = () => { importedSecrets={importedSecrets} /> )} - {Boolean(folders?.length) && ( + {Boolean(mergedFolders?.length) && ( { {canReadSecretRotations && Boolean(secretRotations?.length) && ( )} - {canReadSecret && Boolean(secrets?.length) && ( + {canReadSecret && Boolean(mergedSecrets?.length) && ( { usedBySecretSyncs={usedBySecretSyncs} /> )} + {(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && ( + { + await handleCreateCommit(changes, commitMessage); + }} + environment={environment} + workspaceId={workspaceId} + secretPath={secretPath} + isCommitting={false} + /> + )} {noAccessSecretCount > 0 && } {!canReadSecret && !canReadDynamicSecret && @@ -633,6 +808,7 @@ const Page = () => { secretPath={secretPath} autoCapitalize={currentWorkspace?.autoCapitalization} isProtectedBranch={isProtectedBranch} + isBatchMode={isBatchMode} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx index 0e4ecca95..858ac9680 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { createContext, ReactNode, useContext, useEffect, useRef } from "react"; import { useRouter } from "@tanstack/react-router"; import { createStore, StateCreator, StoreApi, useStore } from "zustand"; @@ -8,6 +9,158 @@ import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; // akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state // This is first step and the whole secret crud will be moved to this global page scope state // this will allow more stuff like undo grouping stuffs etc + +// Base interface for all pending changes +export interface BasePendingChange { + id: string; + timestamp: number; +} + +// Secret-related change types +export interface PendingSecretCreate extends BasePendingChange { + resourceType: "secret"; + type: "create"; + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + tags?: { id: string; slug: string }[]; + secretMetadata?: { key: string; value: string }[]; + originalKey?: string; +} + +export interface PendingSecretUpdate extends BasePendingChange { + resourceType: "secret"; + type: "update"; + secretKey: string; + newSecretName?: string; + originalValue?: string; + secretValue?: string; + originalComment?: string; + secretComment?: string; + originalSkipMultilineEncoding?: boolean; + skipMultilineEncoding?: boolean; + originalTags?: { id: string; slug: string }[]; + tags?: { id: string; slug: string }[]; + originalSecretMetadata?: { key: string; value: string }[]; + secretMetadata?: { key: string; value: string }[]; +} + +export interface PendingSecretDelete extends BasePendingChange { + resourceType: "secret"; + type: "delete"; + secretKey: string; + secretValue: string; +} + +// Folder-related change types +export interface PendingFolderCreate extends BasePendingChange { + resourceType: "folder"; + type: "create"; + id: string; + folderName: string; + description?: string; + parentPath: string; +} + +export interface PendingFolderUpdate extends BasePendingChange { + resourceType: "folder"; + type: "update"; + originalFolderName: string; + folderName: string; + id: string; + originalDescription?: string; + description?: string; +} + +export interface PendingFolderDelete extends BasePendingChange { + resourceType: "folder"; + type: "delete"; + id: string; + folderName: string; + folderPath: string; +} + +// Union types for each resource +export type PendingSecretChange = PendingSecretCreate | PendingSecretUpdate | PendingSecretDelete; +export type PendingFolderChange = PendingFolderCreate | PendingFolderUpdate | PendingFolderDelete; +export type PendingChange = PendingSecretChange | PendingFolderChange; + +// Grouped changes for better processing +export interface PendingChanges { + secrets: PendingSecretChange[]; + folders: PendingFolderChange[]; +} + +// Context interface for batch operations +export interface BatchContext { + workspaceId: string; + environment: string; + secretPath: string; +} + +const STORAGE_KEY = "infisical_pending_changes"; + +const generateContextKey = (workspaceId: string, environment: string, secretPath: string) => { + return `${workspaceId}_${environment}_${secretPath}`; +}; + +const savePendingChangesToStorage = ( + changes: PendingChanges, + workspaceId: string, + environment: string, + secretPath: string +) => { + const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; + try { + localStorage.setItem(key, JSON.stringify(changes)); + } catch (error) { + console.warn("Failed to save pending changes to localStorage:", error); + } +}; + +const loadPendingChangesFromStorage = ( + workspaceId: string, + environment: string, + secretPath: string +): PendingChanges => { + const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; + const stored = localStorage.getItem(key); + if (!stored) return { secrets: [], folders: [] }; + + try { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return { + secrets: parsed.filter( + (change: any) => !change.resourceType || change.resourceType === "secret" + ), + folders: [] + }; + } + return { + secrets: parsed.secrets || [], + folders: parsed.folders || [] + }; + } catch (error) { + console.warn("Failed to parse pending changes from localStorage:", error); + return { secrets: [], folders: [] }; + } +}; + +const clearPendingChangesFromStorage = ( + workspaceId: string, + environment: string, + secretPath: string +) => { + const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; + try { + localStorage.removeItem(key); + } catch (error) { + console.warn("Failed to clear pending changes from localStorage:", error); + } +}; + type SelectedSecretState = { selectedSecret: Record; action: { @@ -16,14 +169,16 @@ type SelectedSecretState = { set: (secrets: Record) => void; }; }; -const createSelectedSecretStore: StateCreator = (set) => ({ + +const createSelectedSecretStore: StateCreator = ( + set +) => ({ selectedSecret: {}, action: { toggle: (secret) => set((state) => { const isChecked = Boolean(state.selectedSecret?.[secret.id]); const newChecks = { ...state.selectedSecret }; - // remove selection if its present else add it if (isChecked) delete newChecks[secret.id]; else newChecks[secret.id] = secret; return { selectedSecret: newChecks }; @@ -33,6 +188,332 @@ const createSelectedSecretStore: StateCreator = (set) => ({ } }); +const cleanupRevertedFields = (update: PendingSecretUpdate): PendingSecretUpdate => { + const cleaned = { ...update }; + + if (cleaned.secretValue === cleaned.originalValue) { + cleaned.secretValue = undefined; + } + + if (cleaned.secretComment === cleaned.originalComment) { + cleaned.secretComment = undefined; + } + + if (cleaned.skipMultilineEncoding === cleaned.originalSkipMultilineEncoding) { + cleaned.skipMultilineEncoding = undefined; + } + + // For arrays, compare stringified versions + if (JSON.stringify(cleaned.tags) === JSON.stringify(cleaned.originalTags)) { + cleaned.tags = undefined; + } + + if (JSON.stringify(cleaned.secretMetadata) === JSON.stringify(cleaned.originalSecretMetadata)) { + cleaned.secretMetadata = undefined; + } + + // If the new name is the same as original key, remove it + if (cleaned.newSecretName === cleaned.secretKey) { + cleaned.newSecretName = undefined; + } + + return cleaned; +}; + +type BatchModeState = { + isBatchMode: boolean; + pendingChanges: PendingChanges; + existingSecretKeys: Set; + existingFolderNames: Set; + currentContext: BatchContext | null; + batchActions: { + addPendingChange: (change: PendingChange, context: BatchContext) => void; + loadPendingChanges: (context: BatchContext) => void; + clearAllPendingChanges: (context: BatchContext) => void; + setExistingKeys: (secretKeys: string[], folderNames: string[]) => void; + getTotalPendingChangesCount: () => number; + removePendingChange: (changeId: string, resourceType: string, context: BatchContext) => void; + }; +}; + +const createBatchModeStore: StateCreator = (set, get) => ({ + isBatchMode: true, // Always enabled by default + pendingChanges: { secrets: [], folders: [] }, + currentContext: null, + existingSecretKeys: new Set(), + existingFolderNames: new Set(), + batchActions: { + addPendingChange: (change: PendingChange, context: BatchContext) => + set((state) => { + const newChanges = { ...state.pendingChanges }; + + if (change.resourceType === "folder") { + if ( + change.type === "create" && + (state.existingFolderNames.has(change.folderName) || + newChanges.folders.some((f) => f.folderName === change.folderName)) + ) { + return { pendingChanges: newChanges }; + } + if ( + change.type === "update" && + change.folderName !== change.originalFolderName && + (state.existingFolderNames.has(change.folderName) || + newChanges.folders.some((f) => f.folderName === change.folderName)) + ) { + return { pendingChanges: newChanges }; + } + } + + if (change.resourceType === "secret") { + if ( + change.type === "create" && + (state.existingSecretKeys.has(change.secretKey) || + newChanges.secrets.some((s) => s.secretKey === change.secretKey)) + ) { + return { pendingChanges: newChanges }; + } + if ( + change.type === "update" && + change.newSecretName && + change.newSecretName !== change.secretKey && + (state.existingSecretKeys.has(change.newSecretName) || + newChanges.secrets.some( + (s) => + (s.secretKey === change.newSecretName || + (s.type === "update" && s.newSecretName === change.newSecretName)) && + s.id !== change.id + )) + ) { + return { pendingChanges: newChanges }; + } + } + + if (change.resourceType === "secret") { + const secretChanges = [...newChanges.secrets]; + + if (change.type === "create") { + const existingCreateIndex = secretChanges.findIndex( + (c) => + c.type === "create" && + (c.secretKey === change.secretKey || c.secretKey === change.originalKey) + ); + + if (existingCreateIndex >= 0) { + secretChanges[existingCreateIndex] = { + ...secretChanges[existingCreateIndex], + ...change, + timestamp: Date.now() + }; + } else { + secretChanges.push(change); + } + } else if (change.type === "update") { + const existingCreateIndex = secretChanges.findIndex( + (c) => c.type === "create" && c.id === change.id + ); + + if (existingCreateIndex >= 0) { + const existingCreate = secretChanges[existingCreateIndex] as PendingSecretCreate; + secretChanges[existingCreateIndex] = { + ...existingCreate, + secretKey: change.newSecretName || change.secretKey || existingCreate.secretKey, + secretValue: + change.secretValue !== undefined + ? change.secretValue + : existingCreate.secretValue, + secretComment: + change.secretComment !== undefined + ? change.secretComment + : existingCreate.secretComment, + skipMultilineEncoding: + change.skipMultilineEncoding !== undefined + ? change.skipMultilineEncoding + : existingCreate.skipMultilineEncoding, + tags: change.tags !== undefined ? change.tags : existingCreate.tags, + secretMetadata: + change.secretMetadata !== undefined + ? change.secretMetadata + : existingCreate.secretMetadata, + timestamp: Date.now() + }; + } else { + const existingUpdateIndex = secretChanges.findIndex( + (c) => c.type === "update" && c.id === change.id + ); + + if (existingUpdateIndex >= 0) { + const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate; + + const improvedUpdate: PendingSecretUpdate = { + ...existingUpdate, + secretKey: existingUpdate.secretKey, + originalValue: existingUpdate.originalValue, + originalComment: existingUpdate.originalComment, + originalSkipMultilineEncoding: existingUpdate.originalSkipMultilineEncoding, + originalTags: existingUpdate.originalTags, + originalSecretMetadata: existingUpdate.originalSecretMetadata, + + newSecretName: + change.newSecretName !== undefined + ? change.newSecretName + : existingUpdate.newSecretName, + secretValue: + change.secretValue !== undefined + ? change.secretValue + : existingUpdate.secretValue, + secretComment: + change.secretComment !== undefined + ? change.secretComment + : existingUpdate.secretComment, + skipMultilineEncoding: + change.skipMultilineEncoding !== undefined + ? change.skipMultilineEncoding + : existingUpdate.skipMultilineEncoding, + tags: change.tags !== undefined ? change.tags : existingUpdate.tags, + secretMetadata: + change.secretMetadata !== undefined + ? change.secretMetadata + : existingUpdate.secretMetadata, + + timestamp: Date.now() + }; + + const cleanedUpdate = cleanupRevertedFields(improvedUpdate); + + secretChanges[existingUpdateIndex] = cleanedUpdate; + } else { + secretChanges.push(change); + } + } + } else { + secretChanges.push(change); + } + + newChanges.secrets = secretChanges; + } else if (change.resourceType === "folder") { + const folderChanges = [...newChanges.folders]; + + if (change.type === "create") { + const existingCreateIndex = folderChanges.findIndex( + (c) => c.type === "create" && c.folderName === change.folderName + ); + + if (existingCreateIndex >= 0) { + folderChanges[existingCreateIndex] = { + ...folderChanges[existingCreateIndex], + ...change, + timestamp: Date.now() + }; + } else { + folderChanges.push(change); + } + } else if (change.type === "update") { + const existingCreateIndex = folderChanges.findIndex( + (c) => c.type === "create" && c.folderName === change.originalFolderName + ); + + if (existingCreateIndex >= 0) { + const existingCreate = folderChanges[existingCreateIndex] as PendingFolderCreate; + folderChanges[existingCreateIndex] = { + ...existingCreate, + folderName: change.folderName || existingCreate.folderName, + description: + change.description !== undefined + ? change.description + : existingCreate.description, + timestamp: Date.now() + }; + } else { + const existingUpdateIndex = folderChanges.findIndex( + (c) => c.type === "update" && c.id === change.id + ); + + if (existingUpdateIndex >= 0) { + const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate; + + folderChanges[existingUpdateIndex] = { + ...existingUpdate, + originalFolderName: existingUpdate.originalFolderName, + originalDescription: existingUpdate.originalDescription, + + folderName: + change.folderName !== undefined ? change.folderName : existingUpdate.folderName, + description: + change.description !== undefined + ? change.description + : existingUpdate.description, + + timestamp: Date.now() + }; + } else { + folderChanges.push(change); + } + } + } else { + folderChanges.push(change); + } + + newChanges.folders = folderChanges; + } + + savePendingChangesToStorage( + newChanges, + context.workspaceId, + context.environment, + context.secretPath + ); + return { pendingChanges: newChanges }; + }), + + removePendingChange: (changeId: string, resourceType: string, context: BatchContext) => + set((state) => { + const newChanges = { ...state.pendingChanges }; + + if (resourceType === "secret") { + newChanges.secrets = newChanges.secrets.filter((c) => c.id !== changeId); + } else if (resourceType === "folder") { + newChanges.folders = newChanges.folders.filter((c) => c.id !== changeId); + } + + savePendingChangesToStorage( + newChanges, + context.workspaceId, + context.environment, + context.secretPath + ); + return { pendingChanges: newChanges }; + }), + + loadPendingChanges: (context) => { + const changes = loadPendingChangesFromStorage( + context.workspaceId, + context.environment, + context.secretPath + ); + set({ pendingChanges: changes }); + }, + + clearAllPendingChanges: (context) => { + clearPendingChangesFromStorage(context.workspaceId, context.environment, context.secretPath); + set({ + pendingChanges: { secrets: [], folders: [] } + }); + }, + + setExistingKeys: (secretKeys, folderNames) => + set({ + existingSecretKeys: new Set(secretKeys), + existingFolderNames: new Set(folderNames) + }), + + getTotalPendingChangesCount: () => { + const state = get(); + return state.pendingChanges.secrets.length + state.pendingChanges.folders.length; + } + } +}); + export enum PopUpNames { CreateSecretForm = "create-secret-form" } @@ -58,13 +539,14 @@ const createPopUpStore: StateCreator = (set) => ({ } }); -type CombinedState = SelectedSecretState & PopUpState; +type CombinedState = SelectedSecretState & PopUpState & BatchModeState; const StoreContext = createContext | null>(null); export const StoreProvider = ({ children }: { children: ReactNode }) => { const storeRef = useRef>( createStore((...a) => ({ ...createSelectedSecretStore(...a), - ...createPopUpStore(...a) + ...createPopUpStore(...a), + ...createBatchModeStore(...a) })) ); const router = useRouter(); @@ -99,3 +581,17 @@ export const useSelectedSecretActions = () => useStoreContext(useShallow((state) export const usePopUpState = (id: PopUpNames) => useStoreContext(useShallow((state) => state.popUp?.[id] || { isOpen: false })); export const usePopUpAction = () => useStoreContext(useShallow((state) => state.popUpActions)); + +export const useBatchMode = () => + useStoreContext( + useShallow((state) => ({ + isBatchMode: state.isBatchMode, + pendingChanges: state.pendingChanges, + currentContext: state.currentContext, + totalChangesCount: state.batchActions.getTotalPendingChangesCount(), + secretChangesCount: state.pendingChanges.secrets.length, + folderChangesCount: state.pendingChanges.folders.length + })) + ); + +export const useBatchModeActions = () => useStoreContext(useShallow((state) => state.batchActions)); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index c6fdc4a9d..48db3177b 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -79,7 +79,9 @@ import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/typ import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput"; import { + PendingFolderCreate, PopUpNames, + useBatchModeActions, usePopUpAction, useSelectedSecretActions, useSelectedSecrets @@ -109,6 +111,7 @@ type Props = { filter: Filter; tags?: WsTag[]; isVisible?: boolean; + isBatchMode?: boolean; snapshotCount: number; isSnapshotCountLoading?: boolean; protectedBranchPolicyName?: string; @@ -137,6 +140,7 @@ export const ActionBar = ({ filter, tags = [], isVisible, + isBatchMode, snapshotCount, isSnapshotCountLoading, onSearchChange, @@ -174,6 +178,7 @@ export const ActionBar = ({ options: { onSuccess: undefined } }); const queryClient = useQueryClient(); + const { addPendingChange } = useBatchModeActions(); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); @@ -183,6 +188,28 @@ export const ActionBar = ({ const handleFolderCreate = async (folderName: string, description: string | null) => { try { + if (isBatchMode) { + const folderId = `${folderName}`; + const pendingFolderCreate: PendingFolderCreate = { + id: folderId, + resourceType: "folder", + type: "create", + folderName, + description: description || undefined, + parentPath: secretPath, + timestamp: Date.now() + }; + + addPendingChange(pendingFolderCreate, { + workspaceId, + environment, + secretPath + }); + + handlePopUpClose("addFolder"); + return; + } + await createFolder({ name: folderName, path: secretPath, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx index 885838498..ab3a71af5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx @@ -8,7 +8,12 @@ import { TextArea } from "@app/components/v2/TextArea/TextArea"; type Props = { onCreateFolder?: (folderName: string, description: string | null) => Promise; - onUpdateFolder?: (folderName: string, description: string | null) => Promise; + onUpdateFolder?: ( + folderName: string, + description: string | null, + oldFolderName?: string, + oldFolderDescription?: string + ) => Promise; isEdit?: boolean; defaultFolderName?: string; defaultDescription?: string; @@ -69,7 +74,7 @@ export const FolderForm = ({ const descriptionShaped = description && description.trim() !== "" ? description : null; if (isEdit) { - await onUpdateFolder?.(name, descriptionShaped); + await onUpdateFolder?.(name, descriptionShaped, defaultFolderName, defaultDescription); } else { await onCreateFolder?.(name, descriptionShaped); } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx new file mode 100644 index 000000000..32d94d35d --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -0,0 +1,595 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useState } from "react"; +import { faCodeCommit, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Badge, + Button, + FontAwesomeSymbol, + IconButton, + Input, + Modal, + ModalContent +} from "@app/components/v2"; + +import { + PendingChange, + PendingChanges, + useBatchMode, + useBatchModeActions +} from "../../SecretMainPage.store"; +import { FontAwesomeSpriteName } from "../SecretListView/SecretListView.utils"; + +interface CommitFormProps { + onCommit: (changes: PendingChanges, commitMessage: string) => Promise; + isCommitting?: boolean; + environment: string; + workspaceId: string; + secretPath: string; +} + +interface ChangeTableProps { + change: PendingChange; + environment: string; + workspaceId: string; + secretPath: string; +} + +const TagsList: React.FC<{ tags?: { id: string; slug: string }[]; className?: string }> = ({ + tags, + className = "" +}) => { + if (!tags || tags.length === 0) { + return (no tags); + } + + return ( +
+ {tags.map((tag) => ( + + {tag.slug} + + ))} +
+ ); +}; + +const MetadataList: React.FC<{ + metadata?: { key: string; value: string }[]; + className?: string; +}> = ({ metadata, className = "" }) => { + if (!metadata || metadata.length === 0) { + return (no metadata); + } + + return ( +
+ {metadata.map((item) => ( +
+ {item.key}: + {item.value} +
+ ))} +
+ ); +}; + +const ComparisonTableRow: React.FC<{ + label: string; + previousValue: React.ReactNode; + newValue: React.ReactNode; + hideIfSame?: boolean; +}> = ({ label, previousValue, newValue, hideIfSame = false }) => { + const isSame = hideIfSame && String(previousValue) === String(newValue); + + if (isSame) return null; + + return ( + + {label}: + +
{previousValue}
+ + +
{newValue}
+ + + ); +}; + +const ChangeTable: React.FC = ({ + change, + environment, + workspaceId, + secretPath +}) => { + const getChangeBadge = (type: PendingChange["type"]) => { + switch (type) { + case "create": + return Created; + case "update": + return Updated; + case "delete": + return Deleted; + default: + return null; + } + }; + + const renderSecretChanges = () => { + if (change.resourceType !== "secret") return null; + + if (change.type === "create") { + return ( +
+ + + + + + + + + + + {change.secretComment !== undefined && change.secretComment !== "" && ( + + + + + )} + {change.tags && change.tags.length > 0 && ( + + + + + )} + {change.secretMetadata && change.secretMetadata.length > 0 && ( + + + + + )} + +
Key: + {change.secretKey} +
Value: +
+ {change.secretValue || ( + (empty) + )} +
+
Comment: + {change.secretComment} +
Tags: + +
Metadata: + +
+
+ ); + } + + if (change.type === "update") { + const hasKeyChange = change.newSecretName && change.secretKey !== change.newSecretName; + const hasValueChange = change.secretValue !== change.originalValue; + const hasCommentChange = change.secretComment !== change.originalComment; + const hasMultilineChange = + change.skipMultilineEncoding !== change.originalSkipMultilineEncoding; + const hasTagsChange = JSON.stringify(change.tags) !== JSON.stringify(change.originalTags); + const hasMetadataChange = + JSON.stringify(change.secretMetadata) !== JSON.stringify(change.originalSecretMetadata); + + const hasChanges = [ + hasKeyChange, + hasValueChange, + hasCommentChange, + hasMultilineChange, + hasTagsChange, + hasMetadataChange + ].some(Boolean); + + if (!hasChanges) return null; + + return ( +
+ + + {hasKeyChange && ( + {change.secretKey}} + newValue={{change.newSecretName}} + /> + )} + {hasValueChange && ( + + {change.originalValue || (empty)} + + } + newValue={ +
+ {change.secretValue || (empty)} +
+ } + /> + )} + {hasCommentChange && ( + (empty)} + newValue={change.secretComment || (empty)} + /> + )} + {hasMultilineChange && ( + + )} + {hasTagsChange && ( + } + newValue={} + /> + )} + {hasMetadataChange && ( + } + newValue={} + /> + )} +
+
+
+ ); + } + + if (change.type === "delete") { + return ( +
+ + + + + + + + + + + + + + + + + +
Field + Deleted Value + +
Key: + {change.secretKey} +
Value: +
+ {change.secretValue || (empty)} +
+
+
+ ); + } + + return null; + }; + + const renderFolderChanges = () => { + if (change.resourceType !== "folder") return null; + + if (change.type === "create") { + return ( +
+ + + + + + + {change.description !== undefined && change.description !== "" && ( + + + + + )} + +
Name: + {change.folderName} +
Description: + {change.description} +
+
+ ); + } + + if (change.type === "update") { + const hasNameChange = change.folderName !== change.originalFolderName; + const hasDescriptionChange = change.description !== change.originalDescription; + + const hasChanges = [hasNameChange, hasDescriptionChange].some(Boolean); + + if (!hasChanges) return null; + + return ( +
+ + + {hasNameChange && ( + {change.originalFolderName}} + newValue={{change.folderName}} + /> + )} + {hasDescriptionChange && ( + (empty) + } + newValue={change.description || (empty)} + /> + )} + +
+
+ ); + } + + if (change.type === "delete") { + return ( +
+ + + + + + + + + + + + + +
Field + Deleted Value + +
Name: + {change.folderName} +
+
+ ); + } + + return null; + }; + + const getChangeName = () => { + if (change.resourceType === "secret") { + return change.type === "update" ? change.newSecretName || change.secretKey : change.secretKey; + } + if (change.resourceType === "folder") { + return change.type === "update" ? change.originalFolderName : change.folderName; + } + return "Unknown"; + }; + + const { removePendingChange } = useBatchModeActions(); + + const handleDeletePending = (changeType: string, id: string) => { + removePendingChange(id, changeType, { + workspaceId, + environment, + secretPath + }); + }; + + return ( +
+
+
+ {getChangeName()} + {getChangeBadge(change.type)} +
+ handleDeletePending(change.resourceType, change.id)} + > + + +
+ {change.resourceType === "secret" ? renderSecretChanges() : renderFolderChanges()} +
+ ); +}; + +export const CommitForm: React.FC = ({ + onCommit, + isCommitting = false, + environment, + workspaceId, + secretPath +}) => { + const { isBatchMode, pendingChanges, totalChangesCount } = useBatchMode(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); + const { clearAllPendingChanges } = useBatchModeActions(); + + if (!isBatchMode || totalChangesCount === 0) { + return null; + } + + const handleCommit = async () => { + if (!commitMessage.trim()) { + return; + } + + try { + await onCommit(pendingChanges, commitMessage); + clearAllPendingChanges({ + workspaceId, + environment, + secretPath + }); + setIsModalOpen(false); + setCommitMessage(""); + } catch (error) { + console.error("Failed to commit changes:", error); + } + }; + + return ( + <> + {/* Floating Panel */} + {!isModalOpen && ( +
+
+
+
+ + Ready to Commit +
+ + {totalChangesCount} change{totalChangesCount !== 1 ? "s" : ""} + +
+
+ +
+ +
+
+ )} + + {/* Commit Modal */} + + + + Commit Changes + + {totalChangesCount} change{totalChangesCount !== 1 ? "s" : ""} + +
+ } + className="max-h-[90vh] max-w-5xl" + > +
+

+ Write a commit message and review the changes you're about to commit. +

+ + {/* Changes List */} +
+
+ {/* Folder Changes */} + {pendingChanges.folders.length > 0 && ( +
+

+ + Folders ({pendingChanges.folders.length}) +

+
+ {pendingChanges.folders.map((change) => ( + + ))} +
+
+ )} + + {/* Secret Changes */} + {pendingChanges.secrets.length > 0 && ( +
+

+ + Secrets ({pendingChanges.secrets.length}) +

+
+ {pendingChanges.secrets.map((change) => ( + + ))} +
+
+ )} +
+
+ + {/* Commit Message */} +
+ + setCommitMessage(e.target.value)} + placeholder="Describe your changes..." + className="w-full" + required + /> +
+ + {/* Action Buttons */} +
+ + +
+
+ + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/index.tsx new file mode 100644 index 000000000..47502d9a5 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/index.tsx @@ -0,0 +1 @@ +export { CommitForm } from "./CommitForm"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx index a4318d53a..03f5c639a 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -14,7 +14,12 @@ import { getKeyValue } from "@app/helpers/parseEnvVar"; import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; import { SecretType } from "@app/hooks/api/types"; -import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; +import { + PendingSecretCreate, + PopUpNames, + useBatchModeActions, + usePopUpAction +} from "../../SecretMainPage.store"; const typeSchema = z.object({ key: z.string().trim().min(1, { message: "Secret key is required" }), @@ -31,6 +36,7 @@ type Props = { // modal props autoCapitalize?: boolean; isProtectedBranch?: boolean; + isBatchMode?: boolean; }; export const CreateSecretForm = ({ @@ -38,7 +44,8 @@ export const CreateSecretForm = ({ workspaceId, secretPath = "/", autoCapitalize = true, - isProtectedBranch = false + isProtectedBranch = false, + isBatchMode = false }: Props) => { const { register, @@ -53,6 +60,7 @@ export const CreateSecretForm = ({ const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const createWsTag = useCreateWsTag(); + const { addPendingChange } = useBatchModeActions(); const { permission } = useProjectPermission(); const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); @@ -85,6 +93,26 @@ export const CreateSecretForm = ({ const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => { try { + if (isBatchMode) { + const pendingSecretCreate: PendingSecretCreate = { + id: key, + type: "create", + secretKey: key, + secretValue: value || "", + secretComment: "", + tags: tags?.map((el) => ({ id: el.value, slug: el.label })), + timestamp: Date.now(), + resourceType: "secret" + }; + addPendingChange(pendingSecretCreate, { + workspaceId, + environment, + secretPath + }); + closePopUp(PopUpNames.CreateSecretForm); + reset(); + return; + } await createSecretV3({ environment, workspaceId, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx index b95e73fb8..1ba1e9809 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx @@ -2,6 +2,7 @@ import { subject } from "@casl/ability"; import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -13,6 +14,13 @@ import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; +import { + PendingFolderCreate, + PendingFolderDelete, + PendingFolderUpdate, + useBatchMode, + useBatchModeActions +} from "../../SecretMainPage.store"; import { FolderForm } from "../ActionBar/FolderForm"; type Props = { @@ -42,10 +50,60 @@ export const FolderListView = ({ const { mutateAsync: updateFolder } = useUpdateFolder(); const { mutateAsync: deleteFolder } = useDeleteFolder(); + const { isBatchMode } = useBatchMode(); + const { addPendingChange, removePendingChange } = useBatchModeActions(); - const handleFolderUpdate = async (newFolderName: string, newFolderDescription: string | null) => { + const handleFolderUpdate = async ( + newFolderName: string, + newFolderDescription: string | null, + oldFolderName?: string, + oldFolderDescription?: string + ) => { try { - const { id: folderId } = popUp.updateFolder.data as TSecretFolder; + const { id: folderId, pendingAction, isPending } = popUp.updateFolder.data as TSecretFolder; + + if (isBatchMode) { + const isEditingPendingCreation = isPending && pendingAction === "create"; + + if (isEditingPendingCreation) { + const updatedCreate: PendingFolderCreate = { + id: folderId, + type: "create", + folderName: newFolderName, + description: newFolderDescription || undefined, + parentPath: secretPath, + timestamp: Date.now(), + resourceType: "folder" + }; + + addPendingChange(updatedCreate, { + workspaceId, + environment, + secretPath + }); + } else { + const updateChange: PendingFolderUpdate = { + id: folderId, + type: "update", + originalFolderName: oldFolderName || "", + folderName: newFolderName, + originalDescription: oldFolderDescription, + description: newFolderDescription || undefined, + timestamp: Date.now(), + resourceType: "folder" + }; + + addPendingChange(updateChange, { + workspaceId, + environment, + secretPath + }); + } + + handlePopUpClose("updateFolder"); + return; + } + await updateFolder({ folderId, name: newFolderName, @@ -68,15 +126,45 @@ export const FolderListView = ({ } }; + const handleDeletePending = (id: string) => { + removePendingChange(id, "folder", { + workspaceId, + environment, + secretPath + }); + }; + const handleFolderDelete = async () => { try { - const { id: folderId } = popUp.deleteFolder.data as TSecretFolder; + const folderData = popUp.deleteFolder?.data as TSecretFolder; + + if (isBatchMode) { + const pendingFolderDelete: PendingFolderDelete = { + id: folderData.id, + folderName: folderData.name, + folderPath: secretPath, + resourceType: "folder", + type: "delete", + timestamp: Date.now() + }; + + addPendingChange(pendingFolderDelete, { + workspaceId, + environment, + secretPath + }); + + handlePopUpClose("deleteFolder"); + return; + } + await deleteFolder({ - folderId, + folderId: folderData.id, path: secretPath, environment, projectId: workspaceId }); + handlePopUpClose("deleteFolder"); createNotification({ type: "success", @@ -91,7 +179,10 @@ export const FolderListView = ({ } }; - const handleFolderClick = (name: string) => { + const handleFolderClick = (name: string, isPending?: boolean) => { + if (isPending) { + return; + } const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`; navigate({ search: (el) => ({ ...el, secretPath: path }) @@ -100,10 +191,16 @@ export const FolderListView = ({ return ( <> - {folders.map(({ name, id, description }) => ( + {folders.map(({ name, id, description, pendingAction, isPending }) => (
@@ -113,9 +210,9 @@ export const FolderListView = ({ role="button" tabIndex={0} onKeyDown={(evt) => { - if (evt.key === "Enter") handleFolderClick(name); + if (evt.key === "Enter") handleFolderClick(name, isPending); }} - onClick={() => handleFolderClick(name)} + onClick={() => handleFolderClick(name, isPending)} > {name} {description && ( @@ -128,46 +225,71 @@ export const FolderListView = ({ )}
-
- - {(isAllowed) => ( - handlePopUpOpen("updateFolder", { id, name, description })} - isDisabled={!isAllowed} - > - - - )} - - - {(isAllowed) => ( - handlePopUpOpen("deleteFolder", { id, name })} - isDisabled={!isAllowed} - > - - - )} - -
+ {isPending ? ( +
+ {}} + > + + + + handleDeletePending(id)} + > + + +
+ ) : ( +
+ + {(isAllowed) => ( + handlePopUpOpen("updateFolder", { id, name, description })} + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteFolder", { id, name })} + isDisabled={!isAllowed} + > + + + )} + +
+ )}
))} { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "editSecret" @@ -110,6 +115,15 @@ export const SecretItem = memo( const { currentWorkspace } = useWorkspace(); const { permission } = useProjectPermission(); const { isRotatedSecret } = secret; + const { removePendingChange } = useBatchModeActions(); + + const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => { + removePendingChange(pendingSecret.id, "secret", { + workspaceId: currentWorkspace.id, + environment, + secretPath + }); + }; const canEditSecretValue = permission.can( ProjectPermissionSecretActions.Edit, @@ -273,14 +287,19 @@ export const SecretItem = memo( className={twMerge( "border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700", isDirty && "border-primary-400/50", - isRotatedSecret && "bg-mineshaft-700/60" + isRotatedSecret && "bg-mineshaft-700/60", + isPending && "bg-mineshaft-700/60", + pendingAction === "delete" && "border-l-2 border-l-red-600/75", + pendingAction === "update" && "border-l-2 border-l-yellow-600/75", + pendingAction === "create" && "border-l-2 border-l-green-600/75" )} >
{secret.isRotatedSecret ? ( @@ -364,7 +383,9 @@ export const SecretItem = memo( control={control} render={({ field }) => ( )} -
- - - - - - - {(isAllowed) => ( - - - - - - - - - e.preventDefault()} // prevents secret input from displaying value on open - > - - - - )} - - - - {(isAllowed) => ( - - - - - - - - )} - - - Add tags to this secret - {tags.map((tag) => { - const { id: tagId, slug, color } = tag; - - const isTagSelected = selectedTagsGroupById?.[tagId]; - return ( - handleTagSelect(tag)} - key={`${secret.id}-${tagId}`} - icon={ - isTagSelected && ( - - ) - } - iconPos="right" - > -
-
- {slug} -
- - ); - })} - - - - - - - {(isAllowed) => ( + - - )} - - - - {(isAllowed) => ( - - - - - - - - )} - - - - - - - - -