diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 804c044db..cfc190995 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -440,46 +440,6 @@ export const useCreateCommit = () => { } >({ mutationFn: async ({ workspaceId, environment, secretPath, pendingChanges, message }) => { - const transformedSecretUpdates = pendingChanges.secrets - .filter((change) => change.type === PendingAction.Update) - .map((change: PendingSecretUpdate) => { - const updatePayload: { - secretKey: string; - newSecretName?: string; - secretValue?: string; - secretComment?: string; - skipMultilineEncoding?: boolean; - tagIds?: string[]; - secretMetadata?: { - key: string; - value: string; - }[]; - } = { - secretKey: change.secretKey - }; - - // Only include fields that actually changed - if (change.newSecretName) { - updatePayload.newSecretName = change.newSecretName; - } - if (change.secretValue !== undefined) { - updatePayload.secretValue = change.secretValue; - } - if (change.secretComment !== undefined) { - updatePayload.secretComment = change.secretComment; - } - if (change.skipMultilineEncoding !== undefined) { - updatePayload.skipMultilineEncoding = change.skipMultilineEncoding; - } - if (change.tags) { - updatePayload.tagIds = change.tags.map((tag) => tag.id); - } - if (change.secretMetadata) { - updatePayload.secretMetadata = change.secretMetadata; - } - - return updatePayload; - }); const { data } = await apiRequest.post("/api/v1/pit/batch/commit", { projectId: workspaceId, environment, @@ -497,7 +457,23 @@ export const useCreateCommit = () => { tagIds: change.tags?.map((tag) => tag.id), secretMetadata: change.secretMetadata })) || [], - update: transformedSecretUpdates || [], + update: + pendingChanges.secrets + .filter((change) => change.type === PendingAction.Update) + .map((change: PendingSecretUpdate) => ({ + secretKey: change.secretKey, + newSecretName: change.newSecretName, + secretValue: change.secretValue || change.existingSecret.value, + 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) || [] }, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 92f0c2d00..b54fce0cc 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -417,7 +417,9 @@ const Page = () => { imports?.length || dynamicSecrets?.length || secretRotations?.length || - noAccessSecretCount + noAccessSecretCount || + pendingChanges.secrets.length || + pendingChanges.folders.length ); useEffect(() => { @@ -604,15 +606,16 @@ const Page = () => { secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata, isPending: true, 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 - })) || [] + 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; @@ -935,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)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx index 71b912dd0..72a70faa1 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx @@ -4,6 +4,7 @@ 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"; @@ -45,6 +46,7 @@ export interface PendingSecretUpdate extends BasePendingChange { tags?: { id: string; slug: string }[]; originalSecretMetadata?: { key: string; value: string }[]; secretMetadata?: { key: string; value: string }[]; + existingSecret: SecretV3RawSanitized; } export interface PendingSecretDelete extends BasePendingChange { @@ -100,68 +102,6 @@ export interface BatchContext { secretPath: string; } -const STORAGE_KEY = "infisical_pending_changes"; - -const generateContextKey = (workspaceId: string, environment: string, secretPath: string) => { - return `${workspaceId}_${environment}_${secretPath}`; -}; - -const savePendingChangesToStorage = ( - changes: PendingChanges, - workspaceId: string, - environment: string, - secretPath: string -) => { - const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; - try { - sessionStorage.setItem(key, JSON.stringify(changes)); - } catch (error) { - console.warn("Failed to save pending changes to sessionStorage:", error); - } -}; - -const loadPendingChangesFromStorage = ( - workspaceId: string, - environment: string, - secretPath: string -): PendingChanges => { - const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; - const stored = sessionStorage.getItem(key); - if (!stored) return { secrets: [], folders: [] }; - - try { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - return { - secrets: parsed.filter( - (change: any) => !change.resourceType || change.resourceType === "secret" - ), - folders: [] - }; - } - return { - secrets: parsed.secrets || [], - folders: parsed.folders || [] - }; - } catch (error) { - console.warn("Failed to parse pending changes from sessionStorage:", error); - return { secrets: [], folders: [] }; - } -}; - -const clearPendingChangesFromStorage = ( - workspaceId: string, - environment: string, - secretPath: string -) => { - const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; - try { - sessionStorage.removeItem(key); - } catch (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; @@ -207,7 +147,8 @@ const cleanupRevertedSecretFields = (update: PendingSecretUpdate): PendingSecret if ( cleaned.secretComment !== undefined && - !areValuesEqual(cleaned.secretComment, cleaned.originalComment) + (!areValuesEqual(cleaned.secretComment, cleaned.originalComment) || + !areValuesEqual(cleaned.secretComment, cleaned.existingSecret.comment)) ) { hasChanges = true; } else { @@ -302,6 +243,7 @@ const createSelectedSecretStore: StateCreator; existingSecretKeys: Set; existingFolderNames: Set; currentContext: BatchContext | null; @@ -315,16 +257,32 @@ type BatchModeState = { }; }; +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 newChanges = { ...state.pendingChanges }; + 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 = @@ -332,6 +290,10 @@ const createBatchModeStore: StateCreator newChanges.folders.some((f) => f.folderName === change.folderName); if (change.type === PendingAction.Create && existingFolder) { + createNotification({ + text: "Another folder with same name already exists", + type: "error" + }); return { pendingChanges: newChanges }; } if ( @@ -339,6 +301,10 @@ const createBatchModeStore: StateCreator change.folderName !== change.originalFolderName && existingFolder ) { + createNotification({ + text: "Another folder with same name already exists", + type: "error" + }); return { pendingChanges: newChanges }; } } @@ -346,9 +312,19 @@ const createBatchModeStore: StateCreator 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)); + 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: "Another secret with same name already exists", + type: "error" + }); return { pendingChanges: newChanges }; } @@ -366,6 +342,10 @@ const createBatchModeStore: StateCreator )); if (existingNewSecretName) { + createNotification({ + text: "Another secret with same name already exists", + type: "error" + }); return { pendingChanges: newChanges }; } } @@ -425,7 +405,6 @@ const createBatchModeStore: StateCreator if (existingUpdateIndex >= 0) { const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate; - const mergedUpdate: PendingSecretUpdate = { ...existingUpdate, secretKey: existingUpdate.secretKey, @@ -456,7 +435,7 @@ const createBatchModeStore: StateCreator change.secretMetadata !== undefined ? change.secretMetadata : existingUpdate.secretMetadata, - + existingSecret: existingUpdate.existingSecret, timestamp: Date.now() }; @@ -566,18 +545,40 @@ const createBatchModeStore: StateCreator newChanges.folders = folderChanges; } - savePendingChangesToStorage( - newChanges, - context.workspaceId, - context.environment, - context.secretPath - ); - return { pendingChanges: newChanges }; + 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 newChanges = { ...state.pendingChanges }; + 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); @@ -585,28 +586,70 @@ const createBatchModeStore: StateCreator newChanges.folders = newChanges.folders.filter((c) => c.id !== changeId); } - savePendingChangesToStorage( - newChanges, - context.workspaceId, - context.environment, - context.secretPath - ); - return { pendingChanges: newChanges }; + 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 changes = loadPendingChangesFromStorage( + const contextKey = generateContextKey( context.workspaceId, context.environment, context.secretPath ); - set({ pendingChanges: changes }); + + set((state) => { + const contextChanges = state.pendingChangesByContext.get(contextKey) || { + secrets: [], + folders: [] + }; + + return { + pendingChanges: contextChanges, + currentContext: context + }; + }); }, clearAllPendingChanges: (context) => { - clearPendingChangesFromStorage(context.workspaceId, context.environment, context.secretPath); - set({ - pendingChanges: { secrets: [], folders: [] } + 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 + }; }); }, 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 3e590d576..f65b2dd10 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -191,7 +191,7 @@ const ChangeTable: React.FC = ({ {hasKeyChange && ( {change.secretKey}} + previousValue={{change.existingSecret.key}} newValue={{change.newSecretName}} /> )} @@ -200,7 +200,7 @@ const ChangeTable: React.FC = ({ label="Value" previousValue={
- {change.originalValue || (empty)} + {change.existingSecret.value || (empty)}
} newValue={ @@ -213,28 +213,32 @@ const ChangeTable: React.FC = ({ {hasCommentChange && ( (empty)} + previousValue={ + change.existingSecret.comment || (empty) + } newValue={change.secretComment || (empty)} /> )} {hasMultilineChange && ( )} {hasTagsChange && ( } + previousValue={} newValue={} /> )} {hasMetadataChange && ( } + previousValue={} newValue={} /> )} 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 ae6f70d61..614e1d35e 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem.tsx @@ -220,7 +220,7 @@ export const SecretItem = memo( } if (isDirty && !isSubmitting && !isAutoSavingRef.current) { - const debounceTime = isPending ? 500 : 1500; + const debounceTime = 600; autoSaveTimeoutRef.current = setTimeout(() => { autoSaveChanges(formValues); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx index cf64bcf73..0bb5fec78 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -86,17 +86,16 @@ export const SecretListView = ({ const selectedSecrets = useSelectedSecrets(); const { toggle: toggleSelectedSecret } = useSelectedSecretActions(); const { isBatchMode, pendingChanges } = useBatchMode(); - useNavigationBlocker( - { - shouldBlock: pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0, - message: "You have unsaved changes. If you leave now, your work will be lost. Do you want to continue?", - context: { - workspaceId, - environment, - secretPath - } + useNavigationBlocker({ + shouldBlock: pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0, + message: + "You have unsaved changes. If you leave now, your work will be lost. Do you want to continue?", + context: { + workspaceId, + environment, + secretPath } - ); + }); const { addPendingChange } = useBatchModeActions(); const handleSecretOperation = async ( @@ -368,7 +367,8 @@ export const SecretListView = ({ }), timestamp: Date.now(), - resourceType: "secret" + resourceType: "secret", + existingSecret: orgSecret }; addPendingChange(updateChange, {