diff --git a/backend/src/ee/routes/v1/pit-router.ts b/backend/src/ee/routes/v1/pit-router.ts index 6688fb266..4b745ca51 100644 --- a/backend/src/ee/routes/v1/pit-router.ts +++ b/backend/src/ee/routes/v1/pit-router.ts @@ -535,7 +535,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - await server.services.pit.processNewCommitRaw({ + const result = await server.services.pit.processNewCommitRaw({ actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, @@ -549,6 +549,24 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { 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, + folderChanges: result.folderChanges, + approvalId: result.approvalId, + projectId: req.body.projectId, + environment: req.body.environment, + secretPath: req.body.secretPath, + message: req.body.message + } + } + }); + 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..451699953 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", @@ -3222,6 +3223,23 @@ interface PitCompareFolderStatesEvent { }; } +interface PitProcessNewCommitRawEvent { + type: EventType.PIT_PROCESS_NEW_COMMIT_RAW; + metadata: { + projectId: string; + environment: string; + secretPath: string; + message: string; + folderChanges: { + create: string[]; + update: string[]; + delete: string[]; + }; + approvalId?: string; + commitId?: string; + }; +} + interface SecretScanningDataSourceListEvent { type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; metadata: { @@ -3658,6 +3676,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 e31ebc638..64afe086b 100644 --- a/backend/src/ee/services/pit/pit-service.ts +++ b/backend/src/ee/services/pit/pit-service.ts @@ -18,7 +18,7 @@ import { import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; -import { SecretProtectionType, TProcessNewCommitRawDTO } from "@app/services/secret/secret-types"; +import { 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"; @@ -545,11 +545,7 @@ export const pitServiceFactory = ({ // Check update operations ...(changes.secrets?.update - ?.filter( - (sec) => - sec.secretKey !== sec.secretKey.toUpperCase() || - (sec.newSecretKey && sec.newSecretKey !== sec.newSecretKey.toUpperCase()) - ) + ?.filter((sec) => sec.newSecretName && sec.newSecretName !== sec.newSecretName.toUpperCase()) .map((sec) => sec.secretKey) ?? []) ]; @@ -562,17 +558,22 @@ export const pitServiceFactory = ({ } } - await folderCommitDAL.transaction(async (trx) => { - const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + return 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) { - await folderService.createManyFolders({ + const createdFolders = await folderService.createManyFolders({ projectId, actor, actorId, @@ -588,10 +589,11 @@ export const pitServiceFactory = ({ tx: trx, commitChanges }); + folderChanges.create.push(...createdFolders.folders.map((folder) => folder.id)); } if ((changes.folders?.update?.length ?? 0) > 0) { - await folderService.updateManyFolders({ + const updatedFolders = await folderService.updateManyFolders({ projectId, actor, actorId, @@ -608,10 +610,11 @@ export const pitServiceFactory = ({ tx: trx, commitChanges }); + folderChanges.update.push(...updatedFolders.newFolders.map((folder) => folder.id)) } if ((changes.folders?.delete?.length ?? 0) > 0) { - await folderService.deleteManyFolders({ + const deletedFolders = await folderService.deleteManyFolders({ projectId, actor, actorId, @@ -626,46 +629,60 @@ export const pitServiceFactory = ({ tx: trx, commitChanges }); + folderChanges.delete.push(...deletedFolders.folders.map((folder) => folder.id)) } if (policy) { - const approval = await secretApprovalRequestService.generateSecretApprovalRequestV2Bridge({ - policy, - secretPath, - environment, - projectId, - actor, - actorId, - actorOrgId, - actorAuthMethod, - data: { - [SecretOperations.Create]: - changes.secrets?.create?.map((el) => ({ - tagIds: el.tagIds, - secretValue: el.secretValue, - secretComment: el.secretComment, - metadata: el.metadata, - skipMultilineEncoding: el.skipMultilineEncoding, - secretKey: el.secretKey, - secretMetadata: el.secretMetadata - })) ?? [], - [SecretOperations.Update]: - changes.secrets?.update?.map((el) => ({ - tagIds: el.tagIds, - secretValue: el.secretValue, - secretComment: el.secretComment, - metadata: el.metadata, - skipMultilineEncoding: el.skipMultilineEncoding, - secretKey: el.secretKey, - secretMetadata: el.secretMetadata - })) ?? [], - [SecretOperations.Delete]: - changes.secrets?.delete?.map((el) => ({ - secretKey: el.secretKey - })) ?? [] - } - }); - return { type: SecretProtectionType.Approval as const, approval }; + if ( + (changes.secrets?.create?.length ?? 0) > 0 || + (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 + }; + } + return { + folderChanges + }; } if ((changes.secrets?.create?.length ?? 0) > 0) { @@ -712,7 +729,7 @@ export const pitServiceFactory = ({ }); } if (commitChanges?.length > 0) { - await folderCommitService.createCommit( + const commit = await folderCommitService.createCommit( { actor: { type: actor || ActorType.PLATFORM, @@ -726,7 +743,14 @@ export const pitServiceFactory = ({ }, trx ); + return { + folderChanges, + commitId: commit?.id + }; } + return { + folderChanges + }; }); }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index a2d49af18..030bbbf09 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -292,7 +292,7 @@ export const secretFolderServiceFactory = ({ }); } - const env = await projectEnvDAL.findOne({ projectId, 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 '${projectId}' not found`, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 963801e50..2d52344e5 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -558,7 +558,7 @@ export type TProcessNewCommitRawDTO = { }[]; update?: { secretKey: string; - newSecretKey?: string; + newSecretName?: string; secretValue?: string; secretComment?: string; skipMultilineEncoding?: boolean; diff --git a/frontend/src/hooks/useNavigationBlocker.tsx b/frontend/src/hooks/useNavigationBlocker.tsx new file mode 100644 index 000000000..442849a37 --- /dev/null +++ b/frontend/src/hooks/useNavigationBlocker.tsx @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { useBlocker } from "@tanstack/react-router"; + +type TNavigationBlockerReturn = { + isBlocked: boolean; +}; + +export const useNavigationBlocker = ( + shouldBlock: boolean, + message: string = "Are you sure you want to leave? You may have unsaved changes." +): TNavigationBlockerReturn => { + const blockerFn = useCallback(() => { + if (!shouldBlock) return false; + + // eslint-disable-next-line no-alert + const confirmed = window.confirm(message); + + return !confirmed; + }, [shouldBlock, message]); + + useBlocker(blockerFn); + + return { + isBlocked: shouldBlock + }; +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 0a66bdfc4..7326876da 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -138,24 +138,6 @@ const Page = () => { } }, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]); - const handleCreateCommit = async (changes: PendingChanges, message: string) => { - try { - await createCommit({ - workspaceId, - environment, - secretPath, - pendingChanges: changes, - message - }); - } catch (error) { - createNotification({ - text: "Failed to commit changes", - type: "error" - }); - console.error(error); - } - }; - const canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, @@ -299,6 +281,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, @@ -538,7 +544,7 @@ const Page = () => { value: change.secretValue, comment: change.secretComment || "", skipMultilineEncoding: change.skipMultilineEncoding || false, - tags: change.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [], + tags: change.tags || [], secretMetadata: change.secretMetadata || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -568,7 +574,16 @@ const Page = () => { : mergedSecrets[updateIndex].skipMultilineEncoding, secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata, isPending: true, - pendingAction: PendingAction.Update + pendingAction: PendingAction.Update, + tags: + change.tags?.map((tag) => ({ + id: tag.id, + slug: tag.slug, + projectId: workspaceId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + __v: 0 + })) || [] }; } break; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx index a13f3d9b1..343596116 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx @@ -114,9 +114,9 @@ const savePendingChangesToStorage = ( ) => { const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; try { - localStorage.setItem(key, JSON.stringify(changes)); + sessionStorage.setItem(key, JSON.stringify(changes)); } catch (error) { - console.warn("Failed to save pending changes to localStorage:", error); + console.warn("Failed to save pending changes to sessionStorage:", error); } }; @@ -126,7 +126,7 @@ const loadPendingChangesFromStorage = ( secretPath: string ): PendingChanges => { const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; - const stored = localStorage.getItem(key); + const stored = sessionStorage.getItem(key); if (!stored) return { secrets: [], folders: [] }; try { @@ -144,7 +144,7 @@ const loadPendingChangesFromStorage = ( folders: parsed.folders || [] }; } catch (error) { - console.warn("Failed to parse pending changes from localStorage:", error); + console.warn("Failed to parse pending changes from sessionStorage:", error); return { secrets: [], folders: [] }; } }; @@ -156,12 +156,122 @@ const clearPendingChangesFromStorage = ( ) => { const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; try { - localStorage.removeItem(key); + sessionStorage.removeItem(key); } catch (error) { - console.warn("Failed to clear pending changes from localStorage:", error); + console.warn("Failed to clear pending changes from sessionStorage:", error); } }; +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) + ) { + 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: { @@ -189,38 +299,6 @@ const createSelectedSecretStore: StateCreator { - const cleaned = { ...update }; - - if (cleaned.secretValue === cleaned.originalValue) { - cleaned.secretValue = undefined; - } - - if (cleaned.secretComment === cleaned.originalComment) { - cleaned.secretComment = undefined; - } - - if (cleaned.skipMultilineEncoding === cleaned.originalSkipMultilineEncoding) { - cleaned.skipMultilineEncoding = undefined; - } - - // For arrays, compare stringified versions - if (JSON.stringify(cleaned.tags) === JSON.stringify(cleaned.originalTags)) { - cleaned.tags = undefined; - } - - if (JSON.stringify(cleaned.secretMetadata) === JSON.stringify(cleaned.originalSecretMetadata)) { - cleaned.secretMetadata = undefined; - } - - // If the new name is the same as original key, remove it - if (cleaned.newSecretName === cleaned.secretKey) { - cleaned.newSecretName = undefined; - } - - return cleaned; -}; - type BatchModeState = { isBatchMode: boolean; pendingChanges: PendingChanges; @@ -348,7 +426,7 @@ const createBatchModeStore: StateCreator if (existingUpdateIndex >= 0) { const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate; - const improvedUpdate: PendingSecretUpdate = { + const mergedUpdate: PendingSecretUpdate = { ...existingUpdate, secretKey: existingUpdate.secretKey, originalValue: existingUpdate.originalValue, @@ -382,11 +460,23 @@ const createBatchModeStore: StateCreator timestamp: Date.now() }; - const cleanedUpdate = cleanupRevertedFields(improvedUpdate); + // Clean up reverted fields and check if any changes remain + const cleanedUpdate = cleanupRevertedSecretFields(mergedUpdate); - secretChanges[existingUpdateIndex] = cleanedUpdate; + if (cleanedUpdate) { + // Still has changes, keep the update + secretChanges[existingUpdateIndex] = cleanedUpdate; + } else { + // No changes remain, remove the pending update + secretChanges.splice(existingUpdateIndex, 1); + } } else { - secretChanges.push(change); + // 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 { @@ -435,7 +525,7 @@ const createBatchModeStore: StateCreator if (existingUpdateIndex >= 0) { const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate; - folderChanges[existingUpdateIndex] = { + const mergedUpdate: PendingFolderUpdate = { ...existingUpdate, originalFolderName: existingUpdate.originalFolderName, originalDescription: existingUpdate.originalDescription, @@ -449,8 +539,24 @@ const createBatchModeStore: StateCreator 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 { - folderChanges.push(change); + // 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 { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx index 40b3ca3a0..3e590d576 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -1,17 +1,9 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ import React, { useState } from "react"; -import { faCodeCommit, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { faCodeCommit, faEye, faFolder, faKey, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Badge, - Button, - FontAwesomeSymbol, - IconButton, - Input, - Modal, - ModalContent -} from "@app/components/v2"; +import { Badge, Button, IconButton, Input, Modal, ModalContent, Tooltip } from "@app/components/v2"; import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { @@ -20,7 +12,6 @@ import { useBatchMode, useBatchModeActions } from "../../SecretMainPage.store"; -import { FontAwesomeSpriteName } from "../SecretListView/SecretListView.utils"; interface CommitFormProps { onCommit: (changes: PendingChanges, commitMessage: string) => Promise; @@ -384,15 +375,17 @@ const ChangeTable: React.FC = ({ {getChangeName()} {getChangeBadge(change.type)} - handleDeletePending(change.resourceType, change.id)} - > - - + + handleDeletePending(change.resourceType, change.id)} + > + + + {change.resourceType === "secret" ? renderSecretChanges() : renderFolderChanges()} @@ -434,7 +427,7 @@ export const CommitForm: React.FC = ({ <> {/* Floating Panel */} {!isModalOpen && ( -
+
@@ -447,14 +440,22 @@ export const CommitForm: React.FC = ({
-
+
+
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 62b684882..dd41ebafa 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, useEffect, useRef, useCallback } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; import { @@ -118,6 +118,9 @@ export const SecretItem = memo( const { isRotatedSecret } = secret; const { removePendingChange } = useBatchModeActions(); + const autoSaveTimeoutRef = useRef(); + const isAutoSavingRef = useRef(false); + const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => { removePendingChange(pendingSecret.id, "secret", { workspaceId: currentWorkspace.id, @@ -166,7 +169,6 @@ export const SecretItem = memo( }); const secretName = watch("key"); - const overrideAction = watch("overrideAction"); const hasComment = Boolean(watch("comment")); @@ -182,6 +184,68 @@ 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 { + const hasDirectReferences = importedBy?.some(({ folders }) => + folders?.some(({ secrets }) => + secrets?.some(({ referencedSecretKey }) => referencedSecretKey === secret.key) + ) + ); + + if (hasDirectReferences) { + await onSaveSecret(secret, { ...secret, ...data }, () => { + reset(); + }); + } else { + 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 = isPending ? 500 : 1500; + + autoSaveTimeoutRef.current = setTimeout(() => { + autoSaveChanges(formValues); + }, debounceTime); + } + + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + }; + }, [formValues, isDirty, isSubmitting, autoSaveChanges, isPending]); + const isReadOnly = hasSecretReadValueOrDescribePermission( permission, @@ -202,6 +266,7 @@ export const SecretItem = memo( secretTags: selectedTagSlugs }) ); + const isReadOnlySecret = isReadOnly || isRotatedSecret || (isPending && pendingAction !== PendingAction.Update); @@ -216,10 +281,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 @@ -284,6 +345,8 @@ export const SecretItem = memo( setIsSecValueCopied.on(); }; + const isInAutoSaveMode = isDirty && !isSubmitting && !isOverriden; + return (
+ {isInAutoSaveMode && ( + +
+ +
+
+ )} + {secretValueHidden && !isOverriden && ( )} - {/* Only allow to open the side panel if the secret is not in a pending create or delete state */} {pendingAction !== PendingAction.Create && pendingAction !== PendingAction.Delete && (
e.preventDefault()} // prevents secret input from displaying value on open + onOpenAutoFocus={(e) => e.preventDefault()} > - {!isDirty ? ( + {isInAutoSaveMode ? ( + + +
+ +
+
+
+ ) : !isDirty ? ( isPending ? ( {(isAllowed) => ( 0 || pendingChanges.folders.length > 0, + "You have unsaved work. Are you sure you want to leave?" + ); const { addPendingChange } = useBatchModeActions(); const handleSecretOperation = async ( @@ -226,7 +231,7 @@ export const SecretListView = ({ async ( orgSecret: SecretV3RawSanitized, modSecret: Omit & { - tags?: { id: string; name?: string }[]; + tags?: { id: string; name?: string; slug?: string }[]; secretMetadata?: { key: string; value: string }[]; }, cb?: () => void @@ -347,7 +352,7 @@ export const SecretListView = ({ ...(!isSameTags && { originalTags: trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [], - tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || "" })) || [] + tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || [] }), ...(JSON.stringify(secretMetadata) !== JSON.stringify(trueOriginalSecret.secretMetadata) && {