mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 10:28:54 +00:00
feat(secrets-ui): Improve SecretDashboard table to support multiple changes on a single commit
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user