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-case-declarations */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
@@ -46,12 +47,14 @@ 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 { useCreateCommit } from "@app/hooks/api/secrets/mutations";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView";
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";
@@ -61,8 +64,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,
@@ -90,8 +96,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,
@@ -121,6 +130,22 @@ 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 handleCreateCommit = (changes: PendingChanges, message: string) => {
createCommit({
workspaceId,
environment,
secretPath,
pendingChanges: changes,
message
});
};
const canReadSecret = hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.DescribeSecret,
@@ -350,6 +375,16 @@ const Page = () => {
noAccessSecretCount
);
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
@@ -476,6 +511,134 @@ const Page = () => {
setFilter(defaultFilterState);
setDebouncedSearchFilter("");
};
const getMergedSecretsWithPending = () => {
if (!isBatchMode || pendingChanges.secrets.length === 0) {
return secrets;
}
const mergedSecrets = [...(secrets || [])];
pendingChanges.secrets.forEach((change) => {
switch (change.type) {
case "create":
mergedSecrets.unshift({
id: change.id,
key: change.secretKey,
value: change.secretValue,
comment: change.secretComment || "",
skipMultilineEncoding: change.skipMultilineEncoding || false,
tags: change.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
secretMetadata: change.secretMetadata || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1,
isPending: true,
pendingAction: "create"
} as any);
break;
case "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: "update"
};
}
break;
case "delete":
const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey);
if (deleteIndex >= 0) {
mergedSecrets[deleteIndex] = {
...mergedSecrets[deleteIndex],
isPending: true,
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 "create":
mergedFolders.unshift({
id: change.id,
name: change.folderName,
description: change.description,
parentId: null,
isPending: true,
pendingAction: "create"
} as any);
break;
case "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: "update"
};
}
break;
case "delete":
const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id);
if (deleteIndex >= 0) {
mergedFolders[deleteIndex] = {
...mergedFolders[deleteIndex],
isPending: true,
pendingAction: "delete"
};
}
break;
default:
break;
}
});
return mergedFolders;
};
const mergedSecrets = getMergedSecretsWithPending();
const mergedFolders = getMergedFoldersWithPending();
return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
@@ -487,6 +650,7 @@ const Page = () => {
projectSlug={projectSlug}
secretPath={secretPath}
isVisible={isVisible}
isBatchMode={isBatchMode}
filter={filter}
tags={tags}
onVisibilityToggle={handleToggleVisibility}
@@ -559,9 +723,9 @@ const Page = () => {
importedSecrets={importedSecrets}
/>
)}
{Boolean(folders?.length) && (
{Boolean(mergedFolders?.length) && (
<FolderListView
folders={folders}
folders={mergedFolders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
@@ -579,9 +743,9 @@ const Page = () => {
{canReadSecretRotations && Boolean(secretRotations?.length) && (
<SecretRotationListView secretRotations={secretRotations} />
)}
{canReadSecret && Boolean(secrets?.length) && (
{canReadSecret && Boolean(mergedSecrets?.length) && (
<SecretListView
secrets={secrets}
secrets={mergedSecrets}
tags={tags}
isVisible={isVisible}
environment={environment}
@@ -592,6 +756,17 @@ const Page = () => {
usedBySecretSyncs={usedBySecretSyncs}
/>
)}
{(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && (
<CommitForm
onCommit={async (changes, commitMessage) => {
await handleCreateCommit(changes, commitMessage);
}}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isCommitting={false}
/>
)}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret &&
!canReadDynamicSecret &&
@@ -633,6 +808,7 @@ const Page = () => {
secretPath={secretPath}
autoCapitalize={currentWorkspace?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
isBatchMode={isBatchMode}
/>
</ModalContent>
</Modal>