diff --git a/backend/src/ee/routes/v1/pit-router.ts b/backend/src/ee/routes/v1/pit-router.ts index f993e31d7..14a82bce4 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,166 @@ 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({ + projectId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + message: z + .string() + .trim() + .min(1) + .max(255) + .refine((message) => message.trim() !== "", { + message: "Commit message cannot be empty" + }), + 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().nullable().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) => { + const result = await server.services.pit.processNewCommitRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + message: req.body.message, + changes: { + secrets: req.body.changes.secrets, + folders: req.body.changes.folders + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.PIT_PROCESS_NEW_COMMIT_RAW, + metadata: { + commitId: result.commitId, + approvalId: result.approvalId, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + message: req.body.message + } + } + }); + + for await (const event of result.secretMutationEvents) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.body.projectId, + event + }); + } + + return { message: "success" }; + } + }); }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 8f3f84e00..83458e691 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -449,6 +449,7 @@ export enum EventType { PIT_REVERT_COMMIT = "pit-revert-commit", PIT_GET_FOLDER_STATE = "pit-get-folder-state", PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states", + PIT_PROCESS_NEW_COMMIT_RAW = "pit-process-new-commit-raw", SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list", SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create", SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update", @@ -1546,8 +1547,9 @@ interface UpdateFolderEvent { metadata: { environment: string; folderId: string; - oldFolderName: string; + oldFolderName?: string; newFolderName: string; + newFolderDescription?: string; folderPath: string; }; } @@ -3222,6 +3224,18 @@ interface PitCompareFolderStatesEvent { }; } +interface PitProcessNewCommitRawEvent { + type: EventType.PIT_PROCESS_NEW_COMMIT_RAW; + metadata: { + projectId: string; + environment: string; + secretPath: string; + message: string; + approvalId?: string; + commitId?: string; + }; +} + interface SecretScanningDataSourceListEvent { type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; metadata: { @@ -3658,6 +3672,7 @@ export type Event = | PitRevertCommitEvent | PitCompareFolderStatesEvent | PitGetFolderStateEvent + | PitProcessNewCommitRawEvent | SecretScanningDataSourceListEvent | SecretScanningDataSourceGetEvent | SecretScanningDataSourceCreateEvent diff --git a/backend/src/ee/services/pit/pit-service.ts b/backend/src/ee/services/pit/pit-service.ts index c2a485b35..782827c98 100644 --- a/backend/src/ee/services/pit/pit-service.ts +++ b/backend/src/ee/services/pit/pit-service.ts @@ -1,29 +1,52 @@ /* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; +import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types"; 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, + TCommitResourceChangeDTO, + 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 { 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 +57,12 @@ export const pitServiceFactory = ({ folderService, permissionService, folderDAL, - projectEnvDAL + projectEnvDAL, + secretApprovalRequestService, + secretApprovalPolicyService, + projectDAL, + secretV2BridgeService, + folderCommitDAL }: TPitServiceFactoryDep) => { const getCommitsCount = async ({ actor, @@ -471,6 +499,347 @@ export const pitServiceFactory = ({ }); }; + const processNewCommitRaw = async ({ + actorId, + projectId, + environment, + actor, + actorOrgId, + actorAuthMethod, + secretPath, + message, + changes = { + secrets: { + create: [], + update: [], + delete: [] + }, + folders: { + create: [], + update: [], + delete: [] + } + } + }: { + actorId: string; + projectId: string; + environment: string; + actor: ActorType; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + secretPath: string; + message: string; + changes: TProcessNewCommitRawDTO; + }) => { + const policy = + actor === ActorType.USER + ? await secretApprovalPolicyService.getSecretApprovalPolicy(projectId, environment, secretPath) + : undefined; + const secretMutationEvents: Event[] = []; + + 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.newSecretName && sec.newSecretName !== sec.newSecretName.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` + }); + } + } + + const response = await folderCommitDAL.transaction(async (trx) => { + const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, trx); + if (!targetFolder) + throw new NotFoundError({ + message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`, + name: "CreateManySecret" + }); + const commitChanges: TCommitResourceChangeDTO[] = []; + const folderChanges: { create: string[]; update: string[]; delete: string[] } = { + create: [], + update: [], + delete: [] + }; + + if ((changes.folders?.create?.length ?? 0) > 0) { + const createdFolders = 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 + }); + const newFolderEvents = createdFolders.folders.map( + (folder) => + ({ + type: EventType.CREATE_FOLDER, + metadata: { + environment, + folderId: folder.id, + folderName: folder.name, + folderPath: secretPath, + ...(folder.description ? { description: folder.description } : {}) + } + }) as Event + ); + secretMutationEvents.push(...newFolderEvents); + folderChanges.create.push(...createdFolders.folders.map((folder) => folder.id)); + } + + if ((changes.folders?.update?.length ?? 0) > 0) { + const updatedFolders = await folderService.updateManyFolders({ + projectId, + 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 + }); + + const updatedFolderEvents = updatedFolders.newFolders.map( + (folder) => + ({ + type: EventType.UPDATE_FOLDER, + metadata: { + environment, + folderId: folder.id, + folderPath: secretPath, + newFolderName: folder.name, + newFolderDescription: folder.description + } + }) as Event + ); + secretMutationEvents.push(...updatedFolderEvents); + folderChanges.update.push(...updatedFolders.newFolders.map((folder) => folder.id)); + } + + if ((changes.folders?.delete?.length ?? 0) > 0) { + const deletedFolders = await folderService.deleteManyFolders({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + folders: + changes.folders?.delete?.map((folder) => ({ + environment, + path: secretPath, + idOrName: folder.id + })) ?? [], + tx: trx, + commitChanges + }); + const deletedFolderEvents = deletedFolders.folders.map( + (folder) => + ({ + type: EventType.DELETE_FOLDER, + metadata: { + environment, + folderId: folder.id, + folderPath: secretPath, + folderName: folder.name + } + }) as Event + ); + secretMutationEvents.push(...deletedFolderEvents); + folderChanges.delete.push(...deletedFolders.folders.map((folder) => folder.id)); + } + + if (policy) { + if ( + (changes.secrets?.create?.length ?? 0) > 0 || + (changes.secrets?.update?.length ?? 0) > 0 || + (changes.secrets?.delete?.length ?? 0) > 0 + ) { + 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, + newSecretName: el.newSecretName, + 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 { + approvalId: approval.id, + folderChanges, + secretMutationEvents + }; + } + return { + folderChanges, + secretMutationEvents + }; + } + + if ((changes.secrets?.create?.length ?? 0) > 0) { + const newSecrets = await secretV2BridgeService.createManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: changes.secrets?.create ?? [], + tx: trx, + commitChanges + }); + secretMutationEvents.push({ + type: EventType.CREATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: newSecrets.map((secret) => ({ + secretId: secret.id, + secretKey: secret.secretKey, + secretVersion: secret.version + })) + } + }); + } + if ((changes.secrets?.update?.length ?? 0) > 0) { + const updatedSecrets = await secretV2BridgeService.updateManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: changes.secrets?.update ?? [], + mode: SecretUpdateMode.FailOnNotFound, + tx: trx, + commitChanges + }); + secretMutationEvents.push({ + type: EventType.UPDATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: updatedSecrets.map((secret) => ({ + secretId: secret.id, + secretKey: secret.secretKey, + secretVersion: secret.version + })) + } + }); + } + if ((changes.secrets?.delete?.length ?? 0) > 0) { + const deletedSecrets = await secretV2BridgeService.deleteManySecret({ + secretPath, + environment, + projectId, + actorAuthMethod, + actorOrgId, + actor, + actorId, + secrets: changes.secrets?.delete ?? [], + tx: trx, + commitChanges + }); + secretMutationEvents.push({ + type: EventType.DELETE_SECRETS, + metadata: { + environment, + secretPath, + secrets: deletedSecrets.map((secret) => ({ + secretId: secret.id, + secretKey: secret.secretKey, + secretVersion: secret.version + })) + } + }); + } + if (commitChanges?.length > 0) { + const commit = await folderCommitService.createCommit( + { + actor: { + type: actor || ActorType.PLATFORM, + metadata: { + id: actorId + } + }, + message, + folderId: targetFolder.id, + changes: commitChanges + }, + trx + ); + return { + folderChanges, + commitId: commit?.id, + secretMutationEvents + }; + } + return { + folderChanges, + secretMutationEvents + }; + }); + + return response; + }; + return { getCommitsCount, getCommitsForFolder, @@ -478,6 +847,7 @@ export const pitServiceFactory = ({ compareCommitChanges, rollbackToCommit, revertCommit, - getFolderStateAtCommit + getFolderStateAtCommit, + processNewCommitRaw }; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 2a755f9a6..f7c1d4b1b 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -1,5 +1,6 @@ /* eslint-disable no-nested-ternary */ import { ForbiddenError, subject } from "@casl/ability"; +import { Knex } from "knex"; import { ProjectMembershipRole, @@ -1368,8 +1369,9 @@ export const secretApprovalRequestServiceFactory = ({ policy, projectId, secretPath, - environment - }: TGenerateSecretApprovalRequestV2BridgeDTO) => { + environment, + trx: providedTx + }: TGenerateSecretApprovalRequestV2BridgeDTO & { trx?: Knex }) => { if (actor === ActorType.SERVICE || actor === ActorType.Machine) throw new BadRequestError({ message: "Cannot use service token or machine token over protected branches" }); @@ -1595,7 +1597,7 @@ export const secretApprovalRequestServiceFactory = ({ ); }); - const secretApprovalRequest = await secretApprovalRequestDAL.transaction(async (tx) => { + const executeApprovalRequestCreation = async (tx: Knex) => { const doc = await secretApprovalRequestDAL.create( { folderId, @@ -1657,7 +1659,11 @@ export const secretApprovalRequestServiceFactory = ({ } return { ...doc, commits: approvalCommits }; - }); + }; + + const secretApprovalRequest = providedTx + ? await executeApprovalRequestCreation(providedTx) + : await secretApprovalRequestDAL.transaction(executeApprovalRequestCreation); const user = await userDAL.findById(actorId); const env = await projectEnvDAL.findOne({ id: policy.envId }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4f7ebeed9..a1e336844 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1538,7 +1538,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..3612cc005 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 TCommitResourceChangeDTO = { + 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: TCommitResourceChangeDTO[]; omitIgnoreFilter?: boolean; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index e8d0b05e5..030bbbf09 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, + TCommitResourceChangeDTO, + 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,29 @@ 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?: TCommitResourceChangeDTO[]; projectId?: string }) => { + let projectId = providedProjectId; + if (!projectId && projectSlug) { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) { + throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + } + projectId = project.id; + } + if (!projectId) { + throw new BadRequestError({ message: "Must provide either project slug or projectId" }); } const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: project.id, + projectId, actorAuthMethod, actorOrgId }); @@ -260,12 +279,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 +292,10 @@ export const secretFolderServiceFactory = ({ }); } - const env = await projectEnvDAL.findOne({ projectId: project.id, slug: environment }); + const env = await projectEnvDAL.findOne({ projectId, slug: environment }, tx); 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 +342,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 +379,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 +1004,361 @@ export const secretFolderServiceFactory = ({ })); }; + const createManyFolders = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + folders, + tx: providedTx, + commitChanges + }: TCreateManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => { + 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?: TCommitResourceChangeDTO[] }) => { + 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 +1371,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..ae8e2c5dc 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_" } @@ -21,7 +23,7 @@ export type TUpdateFolderDTO = { } & TProjectPermission; export type TUpdateManyFoldersDTO = { - projectSlug: string; + projectSlug?: string; folders: { environment: string; path: string; @@ -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 | null; + }>; +}; + +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 9f62b6d57..fae3a07d0 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, @@ -233,6 +239,7 @@ export const fnSecretBulkUpdate = async ({ userId, encryptedComment, metadata, + secretMetadata, reminderNote, reminderRepeatDays } @@ -244,7 +251,7 @@ export const fnSecretBulkUpdate = async ({ key, userId, encryptedComment, - metadata, + metadata: JSON.stringify(metadata || secretMetadata || []), reminderNote, encryptedValue, reminderRepeatDays @@ -359,28 +366,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 +406,8 @@ export const fnSecretBulkDelete = async ({ secretDAL, secretQueueService, folderCommitService, - secretVersionDAL + secretVersionDAL, + commitChanges }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretKey }) => ({ @@ -421,27 +433,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 cab41933f..8f082df03 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 { TCommitResourceChangeDTO, 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"; @@ -1505,8 +1505,10 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, environment, projectId, - secrets: inputSecrets - }: TCreateManySecretDTO) => { + secrets: inputSecrets, + tx: providedTx, + commitChanges + }: TCreateManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -1589,8 +1591,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; @@ -1612,6 +1614,7 @@ export const secretV2BridgeServiceFactory = ({ }; }), folderId, + commitChanges, orgId: actorOrgId, secretDAL, resourceMetadataDAL, @@ -1624,8 +1627,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({ @@ -1672,8 +1680,10 @@ export const secretV2BridgeServiceFactory = ({ projectId, secretPath: defaultSecretPath = "/", secrets: inputSecrets, - mode: updateMode - }: TUpdateManySecretDTO) => { + mode: updateMode, + tx: providedTx, + commitChanges + }: TUpdateManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -1702,18 +1712,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" }); @@ -1832,7 +1844,7 @@ export const secretV2BridgeServiceFactory = ({ { operator: "eq", field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey + value: el.newSecretName as string }, { operator: "eq", @@ -1886,6 +1898,7 @@ export const secretV2BridgeServiceFactory = ({ orgId: actorOrgId, folderCommitService, tx, + commitChanges, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; const encryptedValue = @@ -1965,7 +1978,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))); @@ -2022,8 +2041,10 @@ export const secretV2BridgeServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId - }: TDeleteManySecretDTO) => { + actorOrgId, + tx: providedTx, + commitChanges + }: TDeleteManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -2082,24 +2103,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..825e7c805 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 { TCommitResourceChangeDTO, 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?: TCommitResourceChangeDTO[]; inputSecrets: Array< Omit & { tagIds?: string[]; @@ -214,6 +215,7 @@ export type TFnSecretBulkUpdate = { actorId?: string; }; tx?: Knex; + commitChanges?: TCommitResourceChangeDTO[]; }; export type TFnSecretBulkDelete = { @@ -223,6 +225,7 @@ export type TFnSecretBulkDelete = { actorId: string; actorType?: string; tx?: Knex; + commitChanges?: TCommitResourceChangeDTO[]; 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..2d52344e5 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; + newSecretName?: 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 | null; id: string }[]; + delete?: { folderName: string; id: string }[]; + }; +}; diff --git a/frontend/src/components/v2/CopyButton/CopyButton.tsx b/frontend/src/components/v2/CopyButton/CopyButton.tsx index ff1161ca9..135dec366 100644 --- a/frontend/src/components/v2/CopyButton/CopyButton.tsx +++ b/frontend/src/components/v2/CopyButton/CopyButton.tsx @@ -41,7 +41,8 @@ export const CopyButton = ({ variant={variant} className={twMerge("group relative", color)} size={size} - onClick={() => { + onClick={(e) => { + e.stopPropagation(); handleCopyText(); }} > diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 33653ff72..dc9bce70f 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -4,11 +4,19 @@ export enum ReservedFolders { SecretReplication = "__reserve_replication_" } +export enum PendingAction { + Create = "create", + Update = "update", + Delete = "delete" +} + export type TSecretFolder = { id: string; name: string; description?: string; parentId?: string | null; + isPending?: boolean; + pendingAction?: PendingAction; }; 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..11ebad4b1 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -2,9 +2,14 @@ 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"; +import { PendingAction } from "../secretFolders/types"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretKeys } from "./queries"; import { @@ -420,3 +425,102 @@ 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 { data } = await apiRequest.post("/api/v1/pit/batch/commit", { + projectId: workspaceId, + environment, + secretPath, + changes: { + secrets: { + create: + pendingChanges.secrets + .filter((change) => change.type === PendingAction.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: + pendingChanges.secrets + .filter((change) => change.type === PendingAction.Update) + .map((change: PendingSecretUpdate) => ({ + secretKey: change.secretKey, + newSecretName: change.newSecretName, + secretValue: + change.secretValue === "" + ? "" + : change.secretValue || change.existingSecret.value, + secretComment: + change.secretComment === "" + ? "" + : change.secretComment || change.existingSecret.comment, + skipMultilineEncoding: + change.skipMultilineEncoding !== undefined + ? change.skipMultilineEncoding + : change.existingSecret.skipMultilineEncoding, + tagIds: + change.tags?.map((tag) => tag.id) || + change.existingSecret.tags?.map((tag) => tag.id), + secretMetadata: change.secretMetadata || change.existingSecret.secretMetadata + })) || [], + delete: + pendingChanges.secrets.filter((change) => change.type === PendingAction.Delete) || [] + }, + folders: { + create: + pendingChanges.folders.filter((change) => change.type === PendingAction.Create) || [], + update: + pendingChanges.folders + .filter((change) => change.type === PendingAction.Update) + .map((change) => ({ + ...change, + description: change.description || null + })) || [], + delete: + pendingChanges.folders.filter((change) => change.type === PendingAction.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..29da8e578 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -1,5 +1,6 @@ import { ProjectPermissionActions } from "@app/context"; +import { PendingAction } from "../secretFolders/types"; import type { WsTag } from "../tags/types"; export enum SecretType { @@ -66,6 +67,8 @@ export type SecretV3RawSanitized = { isRotatedSecret?: boolean; secretReminderRecipients?: SecretReminderRecipient[]; rotationId?: string; + isPending?: boolean; + pendingAction?: PendingAction; }; export type SecretV3Raw = { diff --git a/frontend/src/hooks/useNavigationBlocker.tsx b/frontend/src/hooks/useNavigationBlocker.tsx new file mode 100644 index 000000000..e427d6a7d --- /dev/null +++ b/frontend/src/hooks/useNavigationBlocker.tsx @@ -0,0 +1,40 @@ +import { useCallback } from "react"; +import { useBlocker } from "@tanstack/react-router"; + +import { + BatchContext, + useBatchModeActions +} from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.store"; + +type TNavigationBlockerReturn = { + isBlocked: boolean; +}; + +export const useNavigationBlocker = ({ + shouldBlock = false, + message = "Are you sure you want to leave? You may have unsaved changes.", + context +}: { + shouldBlock: boolean; + message: string; + context: BatchContext; +}): TNavigationBlockerReturn => { + const { clearAllPendingChanges } = useBatchModeActions(); + const blockerFn = useCallback(() => { + if (!shouldBlock) return false; + + // eslint-disable-next-line no-alert + const confirmed = window.confirm(message); + if (confirmed) { + clearAllPendingChanges(context); + } + + return !confirmed; + }, [shouldBlock, message, context]); + + useBlocker(blockerFn, shouldBlock); + + return { + isBlocked: shouldBlock + }; +}; diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx index a1d058ea3..789a17f0f 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx @@ -1,9 +1,11 @@ /* eslint-disable no-nested-ternary */ import { useCallback, useRef, useState } from "react"; -import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; +import { faChevronDown, faChevronUp, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { IconButton, Tooltip } from "@app/components/v2"; + export interface Version { id?: string; version: number; @@ -33,6 +35,7 @@ interface SecretVersionDiffViewProps { showHeader?: boolean; customHeader?: JSX.Element; excludedFieldsHighlight?: string[]; + onDiscard?: VoidFunction; } const isObject = (obj: JsonValue): obj is JsonObject => { @@ -409,7 +412,6 @@ const renderJsonWithDiffs = (
{indent} {"}"} - {comma}
@@ -465,7 +467,9 @@ const formatDeletedJson = (json: JsonValue): JSX.Element => { const cleanVersionForComparison = (version: Version): JsonValue => { const { id, version: versionNumber, ...cleanVersion } = version; - return cleanVersion; + return Object.fromEntries( + Object.entries(cleanVersion).filter((entry) => typeof entry[1] !== "undefined") + ); }; export const SecretVersionDiffView = ({ @@ -474,7 +478,8 @@ export const SecretVersionDiffView = ({ onToggleCollapse, showHeader = true, customHeader, - excludedFieldsHighlight = ["metadata", "tags"] + excludedFieldsHighlight = ["metadata", "tags"], + onDiscard }: SecretVersionDiffViewProps) => { const oldContainerRef = useRef(null); const newContainerRef = useRef(null); @@ -609,6 +614,20 @@ export const SecretVersionDiffView = ({

{key}

{changeBadge} + {onDiscard && ( + + + + + + )}
Tags
-
+
{(secretVersion?.tags?.length ?? 0) ? ( secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
- {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 1c95a202f..b54fce0cc 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"; @@ -48,6 +49,9 @@ 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 { PendingAction } from "@app/hooks/api/secretFolders/types"; +import { useCreateCommit } from "@app/hooks/api/secrets/mutations"; +import { SecretV3RawSanitized } from "@app/hooks/api/types"; import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal"; @@ -56,6 +60,7 @@ import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboar 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"; @@ -65,8 +70,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, @@ -94,8 +102,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, @@ -128,6 +139,12 @@ 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 canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, @@ -293,6 +310,30 @@ const Page = () => { }); const isProtectedBranch = Boolean(boardPolicy); + const handleCreateCommit = async (changes: PendingChanges, message: string) => { + try { + await createCommit({ + workspaceId, + environment, + secretPath, + pendingChanges: changes, + message + }); + createNotification({ + text: isProtectedBranch + ? "Requested changes have been sent for review" + : "Changes committed successfully", + type: "success" + }); + } catch (error) { + createNotification({ + text: "Failed to commit changes", + type: "error" + }); + console.error(error); + } + }; + const { data: snapshotList, isFetchingNextPage: isFetchingNextSnapshotList, @@ -376,9 +417,21 @@ const Page = () => { imports?.length || dynamicSecrets?.length || secretRotations?.length || - noAccessSecretCount + noAccessSecretCount || + pendingChanges.secrets.length || + pendingChanges.folders.length ); + 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 @@ -506,6 +559,143 @@ const Page = () => { setDebouncedSearchFilter(""); }; + const getMergedSecretsWithPending = () => { + if (!isBatchMode || pendingChanges.secrets.length === 0) { + return secrets; + } + + const mergedSecrets = [...(secrets || [])]; + + pendingChanges.secrets.forEach((change) => { + switch (change.type) { + case PendingAction.Create: + mergedSecrets.unshift({ + id: change.id, + key: change.secretKey, + value: change.secretValue, + comment: change.secretComment || "", + skipMultilineEncoding: change.skipMultilineEncoding || false, + tags: change.tags || [], + secretMetadata: change.secretMetadata || [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + isPending: true, + pendingAction: PendingAction.Create + } as unknown as SecretV3RawSanitized); + break; + + case PendingAction.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: PendingAction.Update, + tags: change.tags + ? change.tags?.map((tag) => ({ + id: tag.id, + slug: tag.slug, + projectId: workspaceId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + __v: 0 + })) || [] + : mergedSecrets[updateIndex].tags + }; + } + break; + + case PendingAction.Delete: + const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey); + if (deleteIndex >= 0) { + mergedSecrets[deleteIndex] = { + ...mergedSecrets[deleteIndex], + isPending: true, + pendingAction: 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 PendingAction.Create: + mergedFolders.unshift({ + id: change.id, + name: change.folderName, + description: change.description, + parentId: null, + isPending: true, + pendingAction: PendingAction.Create + } as any); + break; + + case PendingAction.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: PendingAction.Update + }; + } + break; + + case PendingAction.Delete: + const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id); + if (deleteIndex >= 0) { + mergedFolders[deleteIndex] = { + ...mergedFolders[deleteIndex], + isPending: true, + pendingAction: PendingAction.Delete + }; + } + break; + + default: + break; + } + }); + + return mergedFolders; + }; + + const mergedSecrets = getMergedSecretsWithPending(); + const mergedFolders = getMergedFoldersWithPending(); return (
{ projectSlug={projectSlug} secretPath={secretPath} isVisible={isVisible} + isBatchMode={isBatchMode} filter={filter} tags={tags} onVisibilityToggle={handleToggleVisibility} @@ -698,9 +889,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) && ( + + )} {noAccessSecretCount > 0 && } {!canReadSecret && !canReadDynamicSecret && @@ -738,25 +938,34 @@ const Page = () => { folders?.length === 0 && }
- {!isDetailsLoading && totalCount > 0 && ( - - } - className="rounded-b-md border-t border-solid border-t-mineshaft-600" - count={totalCount} - page={page} - perPage={perPage} - onChangePage={(newPage) => setPage(newPage)} - onChangePerPage={handlePerPageChange} - /> - )} + {!isDetailsLoading && + (totalCount > 0 || + pendingChanges.secrets.length > 0 || + pendingChanges.folders.length > 0) && ( + s.type === PendingAction.Create).length + } + folderCount={ + totalFolderCount + + pendingChanges.folders.filter((f) => f.type === PendingAction.Create).length + } + secretRotationCount={totalSecretRotationCount} + /> + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount + pendingChanges.secrets.length + pendingChanges.folders.length} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )} togglePopUp(PopUpNames.CreateSecretForm, state)} @@ -772,6 +981,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..15ee3b9c6 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx @@ -1,13 +1,218 @@ +/* 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"; import { useShallow } from "zustand/react/shallow"; +import { createNotification } from "@app/components/notifications"; +import { PendingAction } from "@app/hooks/api/secretFolders/types"; 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: PendingAction.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: PendingAction.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 }[]; + existingSecret: SecretV3RawSanitized; +} + +export interface PendingSecretDelete extends BasePendingChange { + resourceType: "secret"; + type: PendingAction.Delete; + secretKey: string; + secretValue: string; +} + +// Folder-related change types +export interface PendingFolderCreate extends BasePendingChange { + resourceType: "folder"; + type: PendingAction.Create; + id: string; + folderName: string; + description?: string; + parentPath: string; +} + +export interface PendingFolderUpdate extends BasePendingChange { + resourceType: "folder"; + type: PendingAction.Update; + originalFolderName: string; + folderName: string; + id: string; + originalDescription?: string; + description?: string; +} + +export interface PendingFolderDelete extends BasePendingChange { + resourceType: "folder"; + type: PendingAction.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 normalizeValue = (value: any): string | boolean | undefined => { + if (value === null || value === undefined || value === "") { + return undefined; + } + return value; +}; + +const areValuesEqual = (value1: any, value2: any): boolean => { + const normalized1 = normalizeValue(value1); + const normalized2 = normalizeValue(value2); + + if (normalized1 === undefined && normalized2 === undefined) { + return true; + } + + return normalized1 === normalized2; +}; + +const areArraysEqual = (arr1: any[] | undefined, arr2: any[] | undefined): boolean => { + // Handle undefined/null arrays + if (!arr1 && !arr2) return true; + if (!arr1 || !arr2) return false; + + // Compare lengths + if (arr1.length !== arr2.length) return false; + + // Deep comparison using JSON stringify (for simple objects) + return JSON.stringify(arr1.sort()) === JSON.stringify(arr2.sort()); +}; + +const cleanupRevertedSecretFields = (update: PendingSecretUpdate): PendingSecretUpdate | null => { + const cleaned = { ...update }; + let hasChanges = false; + + if ( + cleaned.secretValue !== undefined && + !areValuesEqual(cleaned.secretValue, cleaned.originalValue) + ) { + hasChanges = true; + } else { + cleaned.secretValue = undefined; + } + + if ( + cleaned.secretComment !== undefined && + (!areValuesEqual(cleaned.secretComment, cleaned.originalComment) || + !areValuesEqual(cleaned.secretComment, cleaned.existingSecret.comment)) + ) { + hasChanges = true; + } else { + cleaned.secretComment = undefined; + } + + if ( + cleaned.skipMultilineEncoding !== undefined && + cleaned.skipMultilineEncoding !== cleaned.originalSkipMultilineEncoding + ) { + hasChanges = true; + } else { + cleaned.skipMultilineEncoding = undefined; + } + + if (cleaned.tags !== undefined && !areArraysEqual(cleaned.tags, cleaned.originalTags)) { + hasChanges = true; + } else { + cleaned.tags = undefined; + } + + if ( + cleaned.secretMetadata !== undefined && + !areArraysEqual(cleaned.secretMetadata, cleaned.originalSecretMetadata) + ) { + hasChanges = true; + } else { + cleaned.secretMetadata = undefined; + } + + if (cleaned.newSecretName !== undefined && cleaned.newSecretName !== cleaned.secretKey) { + hasChanges = true; + } else { + cleaned.newSecretName = undefined; + } + + // If no changes remain, return null to indicate this update should be removed + return hasChanges ? cleaned : null; +}; + +const cleanupRevertedFolderFields = (update: PendingFolderUpdate): PendingFolderUpdate | null => { + const cleaned = { ...update }; + let hasChanges = false; + + if ( + cleaned.folderName !== undefined && + !areValuesEqual(cleaned.folderName, cleaned.originalFolderName) + ) { + hasChanges = true; + } + + if ( + cleaned.description !== undefined && + !areValuesEqual(cleaned.description, cleaned.originalDescription) + ) { + hasChanges = true; + } else { + cleaned.description = undefined; + } + + // If no changes remain, return null to indicate this update should be removed + return hasChanges ? cleaned : null; +}; + type SelectedSecretState = { selectedSecret: Record; action: { @@ -16,14 +221,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 +240,432 @@ const createSelectedSecretStore: StateCreator = (set) => ({ } }); +type BatchModeState = { + isBatchMode: boolean; + pendingChanges: PendingChanges; + pendingChangesByContext: Map; + 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 generateContextKey = (workspaceId: string, environment: string, secretPath: string) => { + return `${workspaceId}_${environment}_${secretPath}`; +}; + +const createBatchModeStore: StateCreator = (set, get) => ({ + isBatchMode: true, // Always enabled by default + pendingChanges: { secrets: [], folders: [] }, + pendingChangesByContext: new Map(), + currentContext: null, + existingSecretKeys: new Set(), + existingFolderNames: new Set(), + batchActions: { + addPendingChange: (change: PendingChange, context: BatchContext) => + set((state) => { + const contextKey = generateContextKey( + context.workspaceId, + context.environment, + context.secretPath + ); + + // Get existing changes for this context or create new empty state + const existingChanges = state.pendingChangesByContext.get(contextKey) || { + secrets: [], + folders: [] + }; + const newChanges = { ...existingChanges }; + + if (change.resourceType === "folder") { + const existingFolder = + state.existingFolderNames.has(change.folderName) || + newChanges.folders.some((f) => f.folderName === change.folderName); + + if (change.type === PendingAction.Create && existingFolder) { + createNotification({ + text: "A folder with this name already exists", + type: "error" + }); + return { pendingChanges: newChanges }; + } + if ( + change.type === PendingAction.Update && + change.folderName !== change.originalFolderName && + existingFolder + ) { + createNotification({ + text: "A folder with this name already exists", + type: "error" + }); + return { pendingChanges: newChanges }; + } + } + + if (change.resourceType === "secret") { + const existingSecret = + state.existingSecretKeys.has(change.secretKey) || + newChanges.secrets.some( + (s) => + (s.secretKey === change.secretKey && s.type !== PendingAction.Create) || + (change.type === PendingAction.Create && + change.originalKey !== change.secretKey && + s.secretKey === change.secretKey) + ); + + if (change.type === PendingAction.Create && existingSecret) { + createNotification({ + text: "A secret with this name already exists", + type: "error" + }); + return { pendingChanges: newChanges }; + } + + const existingNewSecretName = + change.type === PendingAction.Update && + change.newSecretName && + change.newSecretName !== change.secretKey && + (state.existingSecretKeys.has(change.newSecretName) || + newChanges.secrets.some( + (s) => + (s.secretKey === change.newSecretName || + (s.type === PendingAction.Update && + s.newSecretName === change.newSecretName)) && + s.id !== change.id + )); + + if (existingNewSecretName) { + createNotification({ + text: "A secret with this name already exists", + type: "error" + }); + return { pendingChanges: newChanges }; + } + } + + if (change.resourceType === "secret") { + const secretChanges = [...newChanges.secrets]; + + if (change.type === PendingAction.Create) { + const existingCreateIndex = secretChanges.findIndex( + (c) => + c.type === PendingAction.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 === PendingAction.Update) { + const existingCreateIndex = secretChanges.findIndex( + (c) => c.type === PendingAction.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 === PendingAction.Update && c.id === change.id + ); + + if (existingUpdateIndex >= 0) { + const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate; + const mergedUpdate: 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, + existingSecret: existingUpdate.existingSecret, + timestamp: Date.now() + }; + + // Clean up reverted fields and check if any changes remain + const cleanedUpdate = cleanupRevertedSecretFields(mergedUpdate); + + if (cleanedUpdate) { + // Still has changes, keep the update + secretChanges[existingUpdateIndex] = cleanedUpdate; + } else { + // No changes remain, remove the pending update + secretChanges.splice(existingUpdateIndex, 1); + } + } else { + // New update - clean it up before adding + const cleanedUpdate = cleanupRevertedSecretFields(change); + if (cleanedUpdate) { + secretChanges.push(cleanedUpdate); + } + // If cleanedUpdate is null, don't add it (no actual changes) + } + } + } else { + secretChanges.push(change); + } + + newChanges.secrets = secretChanges; + } else if (change.resourceType === "folder") { + const folderChanges = [...newChanges.folders]; + + if (change.type === PendingAction.Create) { + const existingCreateIndex = folderChanges.findIndex( + (c) => c.type === PendingAction.Create && c.folderName === change.folderName + ); + + if (existingCreateIndex >= 0) { + folderChanges[existingCreateIndex] = { + ...folderChanges[existingCreateIndex], + ...change, + timestamp: Date.now() + }; + } else { + folderChanges.push(change); + } + } else if (change.type === PendingAction.Update) { + const existingCreateIndex = folderChanges.findIndex( + (c) => c.type === PendingAction.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 === PendingAction.Update && c.id === change.id + ); + + if (existingUpdateIndex >= 0) { + const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate; + + const mergedUpdate: PendingFolderUpdate = { + ...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() + }; + + // Clean up reverted fields and check if any changes remain + const cleanedUpdate = cleanupRevertedFolderFields(mergedUpdate); + + if (cleanedUpdate) { + // Still has changes, keep the update + folderChanges[existingUpdateIndex] = cleanedUpdate; + } else { + // No changes remain, remove the pending update + folderChanges.splice(existingUpdateIndex, 1); + } + } else { + // New update - clean it up before adding + const cleanedUpdate = cleanupRevertedFolderFields(change); + if (cleanedUpdate) { + folderChanges.push(cleanedUpdate); + } + // If cleanedUpdate is null, don't add it (no actual changes) + } + } + } else { + folderChanges.push(change); + } + + newChanges.folders = folderChanges; + } + + const updatedContextMap = new Map(state.pendingChangesByContext); + updatedContextMap.set(contextKey, newChanges); + + const currentChanges = + contextKey === + generateContextKey( + state.currentContext?.workspaceId || context.workspaceId, + state.currentContext?.environment || context.environment, + state.currentContext?.secretPath || context.secretPath + ) + ? newChanges + : state.pendingChanges; + + return { + pendingChangesByContext: updatedContextMap, + pendingChanges: currentChanges, + currentContext: context + }; + }), + + removePendingChange: (changeId: string, resourceType: string, context: BatchContext) => + set((state) => { + const contextKey = generateContextKey( + context.workspaceId, + context.environment, + context.secretPath + ); + + // Get existing changes for this context + const existingChanges = state.pendingChangesByContext.get(contextKey) || { + secrets: [], + folders: [] + }; + const newChanges = { ...existingChanges }; + + 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); + } + + const updatedContextMap = new Map(state.pendingChangesByContext); + updatedContextMap.set(contextKey, newChanges); + + const isCurrentContext = + state.currentContext && + contextKey === + generateContextKey( + state.currentContext.workspaceId, + state.currentContext.environment, + state.currentContext.secretPath + ); + + return { + pendingChangesByContext: updatedContextMap, + pendingChanges: isCurrentContext ? newChanges : state.pendingChanges + }; + }), + + loadPendingChanges: (context) => { + const contextKey = generateContextKey( + context.workspaceId, + context.environment, + context.secretPath + ); + + set((state) => { + const contextChanges = state.pendingChangesByContext.get(contextKey) || { + secrets: [], + folders: [] + }; + + return { + pendingChanges: contextChanges, + currentContext: context + }; + }); + }, + + clearAllPendingChanges: (context) => { + const contextKey = generateContextKey( + context.workspaceId, + context.environment, + context.secretPath + ); + + set((state) => { + // Clear changes for this specific context + const updatedContextMap = new Map(state.pendingChangesByContext); + updatedContextMap.delete(contextKey); + + // If this is the current context, also clear the active pending changes + const isCurrentContext = + state.currentContext && + contextKey === + generateContextKey( + state.currentContext.workspaceId, + state.currentContext.environment, + state.currentContext.secretPath + ); + + return { + pendingChangesByContext: updatedContextMap, + pendingChanges: isCurrentContext ? { secrets: [], folders: [] } : state.pendingChanges + }; + }); + }, + + 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 +691,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 +733,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 e2680dbde..ea82cefb0 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -77,12 +77,15 @@ import { } from "@app/hooks/api/dashboard/queries"; import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; +import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries"; import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types"; import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput"; import { + PendingFolderCreate, PopUpNames, + useBatchModeActions, usePopUpAction, useSelectedSecretActions, useSelectedSecrets @@ -112,6 +115,7 @@ type Props = { filter: Filter; tags?: WsTag[]; isVisible?: boolean; + isBatchMode?: boolean; snapshotCount: number; isSnapshotCountLoading?: boolean; protectedBranchPolicyName?: string; @@ -142,6 +146,7 @@ export const ActionBar = ({ filter, tags = [], isVisible, + isBatchMode, snapshotCount, isSnapshotCountLoading, onSearchChange, @@ -182,6 +187,7 @@ export const ActionBar = ({ options: { onSuccess: undefined } }); const queryClient = useQueryClient(); + const { addPendingChange } = useBatchModeActions(); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); @@ -192,6 +198,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: PendingAction.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..b961775d3 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -0,0 +1,439 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useCallback, useState } from "react"; +import { faCodeCommit, faEye, faFolder, faKey, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2"; +import { PendingAction } from "@app/hooks/api/secretFolders/types"; +import { SecretVersionDiffView } from "@app/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView"; + +import { + PendingChange, + PendingChanges, + useBatchMode, + useBatchModeActions +} from "../../SecretMainPage.store"; + +interface CommitFormProps { + onCommit: (changes: PendingChanges, commitMessage: string) => Promise; + isCommitting?: boolean; + environment: string; + workspaceId: string; + secretPath: string; +} + +interface ResourceChangeProps { + change: PendingChange; + environment: string; + workspaceId: string; + secretPath: string; +} + +type RenderResourceProps = { + onDiscard: () => void; + change: PendingChange; +}; + +const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => { + if (change.resourceType !== "secret") return null; + + if (change.type === PendingAction.Create) { + return ( + + ); + } + + if (change.type === PendingAction.Update) { + const { existingSecret } = change; + + 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 ( + tag.slug) ?? []) : undefined, + secretMetadata: change.secretMetadata ? existingSecret.secretMetadata : undefined, + skipMultilineEncoding: + typeof change.skipMultilineEncoding === "boolean" + ? existingSecret.skipMultilineEncoding + : undefined, + comment: change.secretComment !== undefined ? existingSecret.comment : undefined + }, + { + version: 2, // placeholder, not used + secretKey: change.newSecretName, + secretValue: change.secretValue, + tags: change.tags?.map((tag) => tag.slug), + secretMetadata: change.secretMetadata, + skipMultilineEncoding: change.skipMultilineEncoding, + comment: change.secretComment + } + ] + }} + /> + ); + } + + if (change.type === PendingAction.Delete) { + const { secretKey, secretValue } = change; + return ( + + ); + } + + return null; +}; + +const RenderFolderChanges = ({ onDiscard, change }: RenderResourceProps) => { + if (change.resourceType !== "folder") return null; + + if (change.type === PendingAction.Create) { + return ( + + ); + } + + if (change.type === PendingAction.Update) { + const hasNameChange = change.folderName !== change.originalFolderName; + const hasDescriptionChange = change.description !== change.originalDescription; + + const hasChanges = [hasNameChange, hasDescriptionChange].some(Boolean); + + if (!hasChanges) return null; + + return ( + + ); + } + + if (change.type === PendingAction.Delete) { + return ( + + ); + } + + return null; +}; + +const ResourceChange: React.FC = ({ + change, + environment, + workspaceId, + secretPath +}) => { + const { removePendingChange } = useBatchModeActions(); + + const handleDeletePending = useCallback( + (changeType: string, id: string) => { + removePendingChange(id, changeType, { + workspaceId, + environment, + secretPath + }); + }, + [change.resourceType, change.id] + ); + + return change.resourceType === "secret" ? ( + handleDeletePending(change.resourceType, change.id)} + /> + ) : ( + handleDeletePending(change.resourceType, change.id)} + /> + ); +}; + +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; + } + await onCommit(pendingChanges, commitMessage); + clearAllPendingChanges({ + workspaceId, + environment, + secretPath + }); + setIsModalOpen(false); + setCommitMessage(""); + }; + + return ( + <> + {/* Floating Panel */} + {!isModalOpen && ( +
+
+
+
+ + Pending Changes +
+ + {totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""} + +
+
+ +
+ + +
+
+ )} + + {/* Commit Modal */} + + + + Commit Changes + + {totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""} + +
+ } + subTitle={"Write a commit message and review the changes you're about to commit."} + className="max-h-[90vh] max-w-[95%] md:max-w-7xl" + > +
+ {/* 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..9b43d820d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -12,9 +12,15 @@ import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { getKeyValue } from "@app/helpers/parseEnvVar"; import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; +import { PendingAction } from "@app/hooks/api/secretFolders/types"; 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 +37,7 @@ type Props = { // modal props autoCapitalize?: boolean; isProtectedBranch?: boolean; + isBatchMode?: boolean; }; export const CreateSecretForm = ({ @@ -38,7 +45,8 @@ export const CreateSecretForm = ({ workspaceId, secretPath = "/", autoCapitalize = true, - isProtectedBranch = false + isProtectedBranch = false, + isBatchMode = false }: Props) => { const { register, @@ -53,6 +61,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 +94,26 @@ export const CreateSecretForm = ({ const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => { try { + if (isBatchMode) { + const pendingSecretCreate: PendingSecretCreate = { + id: key, + type: PendingAction.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/DynamicSecretListView/DynamicSecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx index d652d49ad..57ede8d0c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretListView.tsx @@ -1,10 +1,5 @@ import { subject } from "@casl/ability"; -import { - faClose, - faFingerprint, - faPencilSquare, - faWarning -} from "@fortawesome/free-solid-svg-icons"; +import { faEdit, faFingerprint, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -187,7 +182,7 @@ export const DynamicSecretListView = ({ )}
-
+
- + )} @@ -228,6 +223,7 @@ export const DynamicSecretListView = ({ { @@ -236,7 +232,7 @@ export const DynamicSecretListView = ({ }} isDisabled={!isAllowed || isRevoking} > - + )} 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..e0cc3f78b 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx @@ -1,7 +1,15 @@ import { subject } from "@casl/ability"; -import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons"; +import { + faClose, + faEdit, + faFolder, + faInfoCircle, + faPencilSquare, + faTrash +} 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"; @@ -11,8 +19,15 @@ import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; -import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; +import { PendingAction, 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 +57,62 @@ 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 updateFolderData = popUp.updateFolder.data; + if (!updateFolderData) throw new Error("Update folder data is required"); + const { id: folderId, pendingAction, isPending } = updateFolderData as TSecretFolder; + + if (isBatchMode) { + const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create; + + if (isEditingPendingCreation) { + const updatedCreate: PendingFolderCreate = { + id: folderId, + type: PendingAction.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: PendingAction.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 +135,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: PendingAction.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 +188,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 +200,16 @@ export const FolderListView = ({ return ( <> - {folders.map(({ name, id, description }) => ( + {folders.map(({ name, id, description, pendingAction, isPending }) => (
@@ -113,9 +219,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 +234,72 @@ 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} + > + + + )} + +
+ )}
))} )}
-
+
- + )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 6e501a06d..142bd3216 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -1088,7 +1088,7 @@ export const SecretDetailSidebar = ({ isDisabled={isSubmitting || !isDirty || !isAllowed} isLoading={isSubmitting} > - Save Changes + Apply Changes )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx index bee8266f6..707284fcf 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx @@ -38,7 +38,7 @@ import { WsTag } from "@app/hooks/api/types"; import { subject } from "@casl/ability"; import { zodResolver } from "@hookform/resolvers/zod"; import { AnimatePresence, motion } from "framer-motion"; -import { memo, useEffect } from "react"; +import { memo, useCallback, useEffect, useRef } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; import { @@ -50,6 +50,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons"; +import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { FontAwesomeSpriteName, formSchema, @@ -57,6 +58,7 @@ import { TFormSchema } from "./SecretListView.utils"; import { CollapsibleSecretImports } from "./CollapsibleSecretImports"; +import { useBatchModeActions } from "../../SecretMainPage.store"; export const HIDDEN_SECRET_VALUE = "******"; export const HIDDEN_SECRET_VALUE_API_MASK = ""; @@ -86,6 +88,8 @@ type Props = { isImported: boolean; }[]; }[]; + isPending?: boolean; + pendingAction?: PendingAction; }; export const SecretItem = memo( @@ -102,7 +106,9 @@ export const SecretItem = memo( environment, secretPath, handleSecretShare, - importedBy + importedBy, + isPending, + pendingAction }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "editSecret" @@ -110,6 +116,18 @@ export const SecretItem = memo( const { currentWorkspace } = useWorkspace(); const { permission } = useProjectPermission(); const { isRotatedSecret } = secret; + const { removePendingChange } = useBatchModeActions(); + + const autoSaveTimeoutRef = useRef(); + const isAutoSavingRef = useRef(false); + + const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => { + removePendingChange(pendingSecret.id, "secret", { + workspaceId: currentWorkspace.id, + environment, + secretPath + }); + }; const canEditSecretValue = permission.can( ProjectPermissionSecretActions.Edit, @@ -151,7 +169,6 @@ export const SecretItem = memo( }); const secretName = watch("key"); - const overrideAction = watch("overrideAction"); const hasComment = Boolean(watch("comment")); @@ -167,6 +184,56 @@ export const SecretItem = memo( name: "tags" }); + const isOverriden = + overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified; + const hasTagsApplied = Boolean(fields.length); + + const autoSaveChanges = useCallback( + async (data: TFormSchema) => { + if (isAutoSavingRef.current) return; + if ( + data.overrideAction === SecretActionType.Created || + data.overrideAction === SecretActionType.Modified + ) { + return; + } + + isAutoSavingRef.current = true; + try { + await onSaveSecret(secret, { ...secret, ...data }, () => { + reset(); + }); + } catch (error) { + console.error("Auto-save failed:", error); + } finally { + isAutoSavingRef.current = false; + } + }, + [secret, onSaveSecret, importedBy, reset] + ); + + const formValues = watch(); + + useEffect(() => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + + if (isDirty && !isSubmitting && !isAutoSavingRef.current) { + const debounceTime = 600; + + autoSaveTimeoutRef.current = setTimeout(() => { + autoSaveChanges(formValues); + }, debounceTime); + } + + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + }; + }, [formValues, isDirty, isSubmitting, autoSaveChanges, isPending]); + const isReadOnly = hasSecretReadValueOrDescribePermission( permission, @@ -188,6 +255,9 @@ export const SecretItem = memo( }) ); + const isReadOnlySecret = + isReadOnly || isRotatedSecret || (isPending && pendingAction === PendingAction.Delete); + const { secretValueHidden } = secret; const [isSecValueCopied, setIsSecValueCopied] = useToggle(false); @@ -199,10 +269,6 @@ export const SecretItem = memo( return () => clearTimeout(timer); }, [isSecValueCopied]); - const isOverriden = - overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified; - const hasTagsApplied = Boolean(fields.length); - const handleOverrideClick = () => { if (isOverriden) { // override need not be flagged delete if it was never saved in server @@ -267,20 +333,27 @@ export const SecretItem = memo( setIsSecValueCopied.on(); }; + const isInAutoSaveMode = isDirty && !isSubmitting && !isOverriden; + return (
{secret.isRotatedSecret ? ( @@ -364,7 +437,7 @@ 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) => ( - - - - - - - - )} - - - - - - - - -