feat(secrets-ui): Improve SecretDashboard table to support multiple changes on a single commit

This commit is contained in:
Carlos Monastyrski
2025-07-10 11:17:45 -03:00
parent 78da7ec343
commit 246fe81134
24 changed files with 3177 additions and 499 deletions
@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
import { useRouter } from "@tanstack/react-router";
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
@@ -8,6 +9,158 @@ import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
// This is first step and the whole secret crud will be moved to this global page scope state
// this will allow more stuff like undo grouping stuffs etc
// Base interface for all pending changes
export interface BasePendingChange {
id: string;
timestamp: number;
}
// Secret-related change types
export interface PendingSecretCreate extends BasePendingChange {
resourceType: "secret";
type: "create";
secretKey: string;
secretValue: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
tags?: { id: string; slug: string }[];
secretMetadata?: { key: string; value: string }[];
originalKey?: string;
}
export interface PendingSecretUpdate extends BasePendingChange {
resourceType: "secret";
type: "update";
secretKey: string;
newSecretName?: string;
originalValue?: string;
secretValue?: string;
originalComment?: string;
secretComment?: string;
originalSkipMultilineEncoding?: boolean;
skipMultilineEncoding?: boolean;
originalTags?: { id: string; slug: string }[];
tags?: { id: string; slug: string }[];
originalSecretMetadata?: { key: string; value: string }[];
secretMetadata?: { key: string; value: string }[];
}
export interface PendingSecretDelete extends BasePendingChange {
resourceType: "secret";
type: "delete";
secretKey: string;
secretValue: string;
}
// Folder-related change types
export interface PendingFolderCreate extends BasePendingChange {
resourceType: "folder";
type: "create";
id: string;
folderName: string;
description?: string;
parentPath: string;
}
export interface PendingFolderUpdate extends BasePendingChange {
resourceType: "folder";
type: "update";
originalFolderName: string;
folderName: string;
id: string;
originalDescription?: string;
description?: string;
}
export interface PendingFolderDelete extends BasePendingChange {
resourceType: "folder";
type: "delete";
id: string;
folderName: string;
folderPath: string;
}
// Union types for each resource
export type PendingSecretChange = PendingSecretCreate | PendingSecretUpdate | PendingSecretDelete;
export type PendingFolderChange = PendingFolderCreate | PendingFolderUpdate | PendingFolderDelete;
export type PendingChange = PendingSecretChange | PendingFolderChange;
// Grouped changes for better processing
export interface PendingChanges {
secrets: PendingSecretChange[];
folders: PendingFolderChange[];
}
// Context interface for batch operations
export interface BatchContext {
workspaceId: string;
environment: string;
secretPath: string;
}
const STORAGE_KEY = "infisical_pending_changes";
const generateContextKey = (workspaceId: string, environment: string, secretPath: string) => {
return `${workspaceId}_${environment}_${secretPath}`;
};
const savePendingChangesToStorage = (
changes: PendingChanges,
workspaceId: string,
environment: string,
secretPath: string
) => {
const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`;
try {
localStorage.setItem(key, JSON.stringify(changes));
} catch (error) {
console.warn("Failed to save pending changes to localStorage:", error);
}
};
const loadPendingChangesFromStorage = (
workspaceId: string,
environment: string,
secretPath: string
): PendingChanges => {
const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`;
const stored = localStorage.getItem(key);
if (!stored) return { secrets: [], folders: [] };
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return {
secrets: parsed.filter(
(change: any) => !change.resourceType || change.resourceType === "secret"
),
folders: []
};
}
return {
secrets: parsed.secrets || [],
folders: parsed.folders || []
};
} catch (error) {
console.warn("Failed to parse pending changes from localStorage:", error);
return { secrets: [], folders: [] };
}
};
const clearPendingChangesFromStorage = (
workspaceId: string,
environment: string,
secretPath: string
) => {
const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`;
try {
localStorage.removeItem(key);
} catch (error) {
console.warn("Failed to clear pending changes from localStorage:", error);
}
};
type SelectedSecretState = {
selectedSecret: Record<string, SecretV3RawSanitized>;
action: {
@@ -16,14 +169,16 @@ type SelectedSecretState = {
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
};
};
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
const createSelectedSecretStore: StateCreator<CombinedState, [], [], SelectedSecretState> = (
set
) => ({
selectedSecret: {},
action: {
toggle: (secret) =>
set((state) => {
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
const newChecks = { ...state.selectedSecret };
// remove selection if its present else add it
if (isChecked) delete newChecks[secret.id];
else newChecks[secret.id] = secret;
return { selectedSecret: newChecks };
@@ -33,6 +188,332 @@ const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
}
});
const cleanupRevertedFields = (update: PendingSecretUpdate): PendingSecretUpdate => {
const cleaned = { ...update };
if (cleaned.secretValue === cleaned.originalValue) {
cleaned.secretValue = undefined;
}
if (cleaned.secretComment === cleaned.originalComment) {
cleaned.secretComment = undefined;
}
if (cleaned.skipMultilineEncoding === cleaned.originalSkipMultilineEncoding) {
cleaned.skipMultilineEncoding = undefined;
}
// For arrays, compare stringified versions
if (JSON.stringify(cleaned.tags) === JSON.stringify(cleaned.originalTags)) {
cleaned.tags = undefined;
}
if (JSON.stringify(cleaned.secretMetadata) === JSON.stringify(cleaned.originalSecretMetadata)) {
cleaned.secretMetadata = undefined;
}
// If the new name is the same as original key, remove it
if (cleaned.newSecretName === cleaned.secretKey) {
cleaned.newSecretName = undefined;
}
return cleaned;
};
type BatchModeState = {
isBatchMode: boolean;
pendingChanges: PendingChanges;
existingSecretKeys: Set<string>;
existingFolderNames: Set<string>;
currentContext: BatchContext | null;
batchActions: {
addPendingChange: (change: PendingChange, context: BatchContext) => void;
loadPendingChanges: (context: BatchContext) => void;
clearAllPendingChanges: (context: BatchContext) => void;
setExistingKeys: (secretKeys: string[], folderNames: string[]) => void;
getTotalPendingChangesCount: () => number;
removePendingChange: (changeId: string, resourceType: string, context: BatchContext) => void;
};
};
const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState> = (set, get) => ({
isBatchMode: true, // Always enabled by default
pendingChanges: { secrets: [], folders: [] },
currentContext: null,
existingSecretKeys: new Set<string>(),
existingFolderNames: new Set<string>(),
batchActions: {
addPendingChange: (change: PendingChange, context: BatchContext) =>
set((state) => {
const newChanges = { ...state.pendingChanges };
if (change.resourceType === "folder") {
if (
change.type === "create" &&
(state.existingFolderNames.has(change.folderName) ||
newChanges.folders.some((f) => f.folderName === change.folderName))
) {
return { pendingChanges: newChanges };
}
if (
change.type === "update" &&
change.folderName !== change.originalFolderName &&
(state.existingFolderNames.has(change.folderName) ||
newChanges.folders.some((f) => f.folderName === change.folderName))
) {
return { pendingChanges: newChanges };
}
}
if (change.resourceType === "secret") {
if (
change.type === "create" &&
(state.existingSecretKeys.has(change.secretKey) ||
newChanges.secrets.some((s) => s.secretKey === change.secretKey))
) {
return { pendingChanges: newChanges };
}
if (
change.type === "update" &&
change.newSecretName &&
change.newSecretName !== change.secretKey &&
(state.existingSecretKeys.has(change.newSecretName) ||
newChanges.secrets.some(
(s) =>
(s.secretKey === change.newSecretName ||
(s.type === "update" && s.newSecretName === change.newSecretName)) &&
s.id !== change.id
))
) {
return { pendingChanges: newChanges };
}
}
if (change.resourceType === "secret") {
const secretChanges = [...newChanges.secrets];
if (change.type === "create") {
const existingCreateIndex = secretChanges.findIndex(
(c) =>
c.type === "create" &&
(c.secretKey === change.secretKey || c.secretKey === change.originalKey)
);
if (existingCreateIndex >= 0) {
secretChanges[existingCreateIndex] = {
...secretChanges[existingCreateIndex],
...change,
timestamp: Date.now()
};
} else {
secretChanges.push(change);
}
} else if (change.type === "update") {
const existingCreateIndex = secretChanges.findIndex(
(c) => c.type === "create" && c.id === change.id
);
if (existingCreateIndex >= 0) {
const existingCreate = secretChanges[existingCreateIndex] as PendingSecretCreate;
secretChanges[existingCreateIndex] = {
...existingCreate,
secretKey: change.newSecretName || change.secretKey || existingCreate.secretKey,
secretValue:
change.secretValue !== undefined
? change.secretValue
: existingCreate.secretValue,
secretComment:
change.secretComment !== undefined
? change.secretComment
: existingCreate.secretComment,
skipMultilineEncoding:
change.skipMultilineEncoding !== undefined
? change.skipMultilineEncoding
: existingCreate.skipMultilineEncoding,
tags: change.tags !== undefined ? change.tags : existingCreate.tags,
secretMetadata:
change.secretMetadata !== undefined
? change.secretMetadata
: existingCreate.secretMetadata,
timestamp: Date.now()
};
} else {
const existingUpdateIndex = secretChanges.findIndex(
(c) => c.type === "update" && c.id === change.id
);
if (existingUpdateIndex >= 0) {
const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate;
const improvedUpdate: PendingSecretUpdate = {
...existingUpdate,
secretKey: existingUpdate.secretKey,
originalValue: existingUpdate.originalValue,
originalComment: existingUpdate.originalComment,
originalSkipMultilineEncoding: existingUpdate.originalSkipMultilineEncoding,
originalTags: existingUpdate.originalTags,
originalSecretMetadata: existingUpdate.originalSecretMetadata,
newSecretName:
change.newSecretName !== undefined
? change.newSecretName
: existingUpdate.newSecretName,
secretValue:
change.secretValue !== undefined
? change.secretValue
: existingUpdate.secretValue,
secretComment:
change.secretComment !== undefined
? change.secretComment
: existingUpdate.secretComment,
skipMultilineEncoding:
change.skipMultilineEncoding !== undefined
? change.skipMultilineEncoding
: existingUpdate.skipMultilineEncoding,
tags: change.tags !== undefined ? change.tags : existingUpdate.tags,
secretMetadata:
change.secretMetadata !== undefined
? change.secretMetadata
: existingUpdate.secretMetadata,
timestamp: Date.now()
};
const cleanedUpdate = cleanupRevertedFields(improvedUpdate);
secretChanges[existingUpdateIndex] = cleanedUpdate;
} else {
secretChanges.push(change);
}
}
} else {
secretChanges.push(change);
}
newChanges.secrets = secretChanges;
} else if (change.resourceType === "folder") {
const folderChanges = [...newChanges.folders];
if (change.type === "create") {
const existingCreateIndex = folderChanges.findIndex(
(c) => c.type === "create" && c.folderName === change.folderName
);
if (existingCreateIndex >= 0) {
folderChanges[existingCreateIndex] = {
...folderChanges[existingCreateIndex],
...change,
timestamp: Date.now()
};
} else {
folderChanges.push(change);
}
} else if (change.type === "update") {
const existingCreateIndex = folderChanges.findIndex(
(c) => c.type === "create" && c.folderName === change.originalFolderName
);
if (existingCreateIndex >= 0) {
const existingCreate = folderChanges[existingCreateIndex] as PendingFolderCreate;
folderChanges[existingCreateIndex] = {
...existingCreate,
folderName: change.folderName || existingCreate.folderName,
description:
change.description !== undefined
? change.description
: existingCreate.description,
timestamp: Date.now()
};
} else {
const existingUpdateIndex = folderChanges.findIndex(
(c) => c.type === "update" && c.id === change.id
);
if (existingUpdateIndex >= 0) {
const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate;
folderChanges[existingUpdateIndex] = {
...existingUpdate,
originalFolderName: existingUpdate.originalFolderName,
originalDescription: existingUpdate.originalDescription,
folderName:
change.folderName !== undefined ? change.folderName : existingUpdate.folderName,
description:
change.description !== undefined
? change.description
: existingUpdate.description,
timestamp: Date.now()
};
} else {
folderChanges.push(change);
}
}
} else {
folderChanges.push(change);
}
newChanges.folders = folderChanges;
}
savePendingChangesToStorage(
newChanges,
context.workspaceId,
context.environment,
context.secretPath
);
return { pendingChanges: newChanges };
}),
removePendingChange: (changeId: string, resourceType: string, context: BatchContext) =>
set((state) => {
const newChanges = { ...state.pendingChanges };
if (resourceType === "secret") {
newChanges.secrets = newChanges.secrets.filter((c) => c.id !== changeId);
} else if (resourceType === "folder") {
newChanges.folders = newChanges.folders.filter((c) => c.id !== changeId);
}
savePendingChangesToStorage(
newChanges,
context.workspaceId,
context.environment,
context.secretPath
);
return { pendingChanges: newChanges };
}),
loadPendingChanges: (context) => {
const changes = loadPendingChangesFromStorage(
context.workspaceId,
context.environment,
context.secretPath
);
set({ pendingChanges: changes });
},
clearAllPendingChanges: (context) => {
clearPendingChangesFromStorage(context.workspaceId, context.environment, context.secretPath);
set({
pendingChanges: { secrets: [], folders: [] }
});
},
setExistingKeys: (secretKeys, folderNames) =>
set({
existingSecretKeys: new Set(secretKeys),
existingFolderNames: new Set(folderNames)
}),
getTotalPendingChangesCount: () => {
const state = get();
return state.pendingChanges.secrets.length + state.pendingChanges.folders.length;
}
}
});
export enum PopUpNames {
CreateSecretForm = "create-secret-form"
}
@@ -58,13 +539,14 @@ const createPopUpStore: StateCreator<PopUpState> = (set) => ({
}
});
type CombinedState = SelectedSecretState & PopUpState;
type CombinedState = SelectedSecretState & PopUpState & BatchModeState;
const StoreContext = createContext<StoreApi<CombinedState> | null>(null);
export const StoreProvider = ({ children }: { children: ReactNode }) => {
const storeRef = useRef<StoreApi<CombinedState>>(
createStore<CombinedState>((...a) => ({
...createSelectedSecretStore(...a),
...createPopUpStore(...a)
...createPopUpStore(...a),
...createBatchModeStore(...a)
}))
);
const router = useRouter();
@@ -99,3 +581,17 @@ export const useSelectedSecretActions = () => useStoreContext(useShallow((state)
export const usePopUpState = (id: PopUpNames) =>
useStoreContext(useShallow((state) => state.popUp?.[id] || { isOpen: false }));
export const usePopUpAction = () => useStoreContext(useShallow((state) => state.popUpActions));
export const useBatchMode = () =>
useStoreContext(
useShallow((state) => ({
isBatchMode: state.isBatchMode,
pendingChanges: state.pendingChanges,
currentContext: state.currentContext,
totalChangesCount: state.batchActions.getTotalPendingChangesCount(),
secretChangesCount: state.pendingChanges.secrets.length,
folderChangesCount: state.pendingChanges.folders.length
}))
);
export const useBatchModeActions = () => useStoreContext(useShallow((state) => state.batchActions));