mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 03:50:10 +00:00
Merge branch 'main' into feat/remindersImprovement
This commit is contained in:
@@ -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";
|
||||
@@ -48,6 +49,9 @@ 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 { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
|
||||
import { SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal";
|
||||
@@ -56,6 +60,7 @@ import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboar
|
||||
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";
|
||||
@@ -65,8 +70,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,
|
||||
@@ -94,8 +102,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,
|
||||
@@ -128,6 +139,12 @@ 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 canReadSecret = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
@@ -217,7 +234,7 @@ const Page = () => {
|
||||
type: "error"
|
||||
});
|
||||
navigate({
|
||||
to: "/projects/$projectId/secret-manager/overview",
|
||||
to: "/projects/secret-management/$projectId/overview",
|
||||
params: {
|
||||
projectId: workspaceId
|
||||
}
|
||||
@@ -293,6 +310,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,
|
||||
@@ -345,7 +386,7 @@ const Page = () => {
|
||||
const handleOnClickRollbackMode = () => {
|
||||
if (isPITEnabled) {
|
||||
navigate({
|
||||
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
|
||||
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
|
||||
params: {
|
||||
projectId: workspaceId,
|
||||
folderId,
|
||||
@@ -376,9 +417,21 @@ const Page = () => {
|
||||
imports?.length ||
|
||||
dynamicSecrets?.length ||
|
||||
secretRotations?.length ||
|
||||
noAccessSecretCount
|
||||
noAccessSecretCount ||
|
||||
pendingChanges.secrets.length ||
|
||||
pendingChanges.folders.length
|
||||
);
|
||||
|
||||
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
|
||||
@@ -506,6 +559,143 @@ const Page = () => {
|
||||
setDebouncedSearchFilter("");
|
||||
};
|
||||
|
||||
const getMergedSecretsWithPending = () => {
|
||||
if (!isBatchMode || pendingChanges.secrets.length === 0) {
|
||||
return secrets;
|
||||
}
|
||||
|
||||
const mergedSecrets = [...(secrets || [])];
|
||||
|
||||
pendingChanges.secrets.forEach((change) => {
|
||||
switch (change.type) {
|
||||
case PendingAction.Create:
|
||||
mergedSecrets.unshift({
|
||||
id: change.id,
|
||||
key: change.secretKey,
|
||||
value: change.secretValue,
|
||||
comment: change.secretComment || "",
|
||||
skipMultilineEncoding: change.skipMultilineEncoding || false,
|
||||
tags: change.tags || [],
|
||||
secretMetadata: change.secretMetadata || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
isPending: true,
|
||||
pendingAction: PendingAction.Create
|
||||
} as unknown as SecretV3RawSanitized);
|
||||
break;
|
||||
|
||||
case PendingAction.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: PendingAction.Update,
|
||||
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;
|
||||
|
||||
case PendingAction.Delete:
|
||||
const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey);
|
||||
if (deleteIndex >= 0) {
|
||||
mergedSecrets[deleteIndex] = {
|
||||
...mergedSecrets[deleteIndex],
|
||||
isPending: true,
|
||||
pendingAction: 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 PendingAction.Create:
|
||||
mergedFolders.unshift({
|
||||
id: change.id,
|
||||
name: change.folderName,
|
||||
description: change.description,
|
||||
parentId: null,
|
||||
isPending: true,
|
||||
pendingAction: PendingAction.Create
|
||||
} as any);
|
||||
break;
|
||||
|
||||
case PendingAction.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: PendingAction.Update
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case PendingAction.Delete:
|
||||
const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id);
|
||||
if (deleteIndex >= 0) {
|
||||
mergedFolders[deleteIndex] = {
|
||||
...mergedFolders[deleteIndex],
|
||||
isPending: true,
|
||||
pendingAction: 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]">
|
||||
<PageHeader
|
||||
@@ -563,6 +753,7 @@ const Page = () => {
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
isVisible={isVisible}
|
||||
isBatchMode={isBatchMode}
|
||||
filter={filter}
|
||||
tags={tags}
|
||||
onVisibilityToggle={handleToggleVisibility}
|
||||
@@ -698,9 +889,9 @@ const Page = () => {
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
{Boolean(folders?.length) && (
|
||||
{Boolean(mergedFolders?.length) && (
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
folders={mergedFolders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
@@ -718,9 +909,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}
|
||||
@@ -731,6 +922,15 @@ const Page = () => {
|
||||
usedBySecretSyncs={usedBySecretSyncs}
|
||||
/>
|
||||
)}
|
||||
{(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && (
|
||||
<CommitForm
|
||||
onCommit={handleCreateCommit}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isCommitting={false}
|
||||
/>
|
||||
)}
|
||||
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||
{!canReadSecret &&
|
||||
!canReadDynamicSecret &&
|
||||
@@ -738,25 +938,34 @@ const Page = () => {
|
||||
folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
</div>
|
||||
{!isDetailsLoading && totalCount > 0 && (
|
||||
<Pagination
|
||||
startAdornment={
|
||||
<SecretTableResourceCount
|
||||
dynamicSecretCount={totalDynamicSecretCount}
|
||||
importCount={totalImportCount}
|
||||
secretCount={totalSecretCount}
|
||||
folderCount={totalFolderCount}
|
||||
secretRotationCount={totalSecretRotationCount}
|
||||
/>
|
||||
}
|
||||
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) && (
|
||||
<Pagination
|
||||
startAdornment={
|
||||
<SecretTableResourceCount
|
||||
dynamicSecretCount={totalDynamicSecretCount}
|
||||
importCount={totalImportCount}
|
||||
secretCount={
|
||||
totalSecretCount +
|
||||
pendingChanges.secrets.filter((s) => 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}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={createSecretPopUp.isOpen}
|
||||
onOpenChange={(state) => togglePopUp(PopUpNames.CreateSecretForm, state)}
|
||||
@@ -772,6 +981,7 @@ const Page = () => {
|
||||
secretPath={secretPath}
|
||||
autoCapitalize={currentWorkspace?.autoCapitalization}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
isBatchMode={isBatchMode}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -1,13 +1,218 @@
|
||||
/* 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";
|
||||
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";
|
||||
|
||||
// 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: PendingAction.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: PendingAction.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 }[];
|
||||
existingSecret: SecretV3RawSanitized;
|
||||
}
|
||||
|
||||
export interface PendingSecretDelete extends BasePendingChange {
|
||||
resourceType: "secret";
|
||||
type: PendingAction.Delete;
|
||||
secretKey: string;
|
||||
secretValue: string;
|
||||
}
|
||||
|
||||
// Folder-related change types
|
||||
export interface PendingFolderCreate extends BasePendingChange {
|
||||
resourceType: "folder";
|
||||
type: PendingAction.Create;
|
||||
id: string;
|
||||
folderName: string;
|
||||
description?: string;
|
||||
parentPath: string;
|
||||
}
|
||||
|
||||
export interface PendingFolderUpdate extends BasePendingChange {
|
||||
resourceType: "folder";
|
||||
type: PendingAction.Update;
|
||||
originalFolderName: string;
|
||||
folderName: string;
|
||||
id: string;
|
||||
originalDescription?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PendingFolderDelete extends BasePendingChange {
|
||||
resourceType: "folder";
|
||||
type: PendingAction.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 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) ||
|
||||
!areValuesEqual(cleaned.secretComment, cleaned.existingSecret.comment))
|
||||
) {
|
||||
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<string, SecretV3RawSanitized>;
|
||||
action: {
|
||||
@@ -16,14 +221,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 +240,432 @@ const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
||||
}
|
||||
});
|
||||
|
||||
type BatchModeState = {
|
||||
isBatchMode: boolean;
|
||||
pendingChanges: PendingChanges;
|
||||
pendingChangesByContext: Map<string, 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 generateContextKey = (workspaceId: string, environment: string, secretPath: string) => {
|
||||
return `${workspaceId}_${environment}_${secretPath}`;
|
||||
};
|
||||
|
||||
const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState> = (set, get) => ({
|
||||
isBatchMode: true, // Always enabled by default
|
||||
pendingChanges: { secrets: [], folders: [] },
|
||||
pendingChangesByContext: new Map<string, PendingChanges>(),
|
||||
currentContext: null,
|
||||
existingSecretKeys: new Set<string>(),
|
||||
existingFolderNames: new Set<string>(),
|
||||
batchActions: {
|
||||
addPendingChange: (change: PendingChange, context: BatchContext) =>
|
||||
set((state) => {
|
||||
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 =
|
||||
state.existingFolderNames.has(change.folderName) ||
|
||||
newChanges.folders.some((f) => f.folderName === change.folderName);
|
||||
|
||||
if (change.type === PendingAction.Create && existingFolder) {
|
||||
createNotification({
|
||||
text: "A folder with this name already exists",
|
||||
type: "error"
|
||||
});
|
||||
return { pendingChanges: newChanges };
|
||||
}
|
||||
if (
|
||||
change.type === PendingAction.Update &&
|
||||
change.folderName !== change.originalFolderName &&
|
||||
existingFolder
|
||||
) {
|
||||
createNotification({
|
||||
text: "A folder with this name already exists",
|
||||
type: "error"
|
||||
});
|
||||
return { pendingChanges: newChanges };
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
if (change.type === PendingAction.Create && existingSecret) {
|
||||
createNotification({
|
||||
text: "A secret with this name already exists",
|
||||
type: "error"
|
||||
});
|
||||
return { pendingChanges: newChanges };
|
||||
}
|
||||
|
||||
const existingNewSecretName =
|
||||
change.type === PendingAction.Update &&
|
||||
change.newSecretName &&
|
||||
change.newSecretName !== change.secretKey &&
|
||||
(state.existingSecretKeys.has(change.newSecretName) ||
|
||||
newChanges.secrets.some(
|
||||
(s) =>
|
||||
(s.secretKey === change.newSecretName ||
|
||||
(s.type === PendingAction.Update &&
|
||||
s.newSecretName === change.newSecretName)) &&
|
||||
s.id !== change.id
|
||||
));
|
||||
|
||||
if (existingNewSecretName) {
|
||||
createNotification({
|
||||
text: "A secret with this name already exists",
|
||||
type: "error"
|
||||
});
|
||||
return { pendingChanges: newChanges };
|
||||
}
|
||||
}
|
||||
|
||||
if (change.resourceType === "secret") {
|
||||
const secretChanges = [...newChanges.secrets];
|
||||
|
||||
if (change.type === PendingAction.Create) {
|
||||
const existingCreateIndex = secretChanges.findIndex(
|
||||
(c) =>
|
||||
c.type === PendingAction.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 === PendingAction.Update) {
|
||||
const existingCreateIndex = secretChanges.findIndex(
|
||||
(c) => c.type === PendingAction.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 === PendingAction.Update && c.id === change.id
|
||||
);
|
||||
|
||||
if (existingUpdateIndex >= 0) {
|
||||
const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate;
|
||||
const mergedUpdate: 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,
|
||||
existingSecret: existingUpdate.existingSecret,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Clean up reverted fields and check if any changes remain
|
||||
const cleanedUpdate = cleanupRevertedSecretFields(mergedUpdate);
|
||||
|
||||
if (cleanedUpdate) {
|
||||
// Still has changes, keep the update
|
||||
secretChanges[existingUpdateIndex] = cleanedUpdate;
|
||||
} else {
|
||||
// No changes remain, remove the pending update
|
||||
secretChanges.splice(existingUpdateIndex, 1);
|
||||
}
|
||||
} else {
|
||||
// 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 {
|
||||
secretChanges.push(change);
|
||||
}
|
||||
|
||||
newChanges.secrets = secretChanges;
|
||||
} else if (change.resourceType === "folder") {
|
||||
const folderChanges = [...newChanges.folders];
|
||||
|
||||
if (change.type === PendingAction.Create) {
|
||||
const existingCreateIndex = folderChanges.findIndex(
|
||||
(c) => c.type === PendingAction.Create && c.folderName === change.folderName
|
||||
);
|
||||
|
||||
if (existingCreateIndex >= 0) {
|
||||
folderChanges[existingCreateIndex] = {
|
||||
...folderChanges[existingCreateIndex],
|
||||
...change,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} else {
|
||||
folderChanges.push(change);
|
||||
}
|
||||
} else if (change.type === PendingAction.Update) {
|
||||
const existingCreateIndex = folderChanges.findIndex(
|
||||
(c) => c.type === PendingAction.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 === PendingAction.Update && c.id === change.id
|
||||
);
|
||||
|
||||
if (existingUpdateIndex >= 0) {
|
||||
const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate;
|
||||
|
||||
const mergedUpdate: PendingFolderUpdate = {
|
||||
...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()
|
||||
};
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
folderChanges.push(change);
|
||||
}
|
||||
|
||||
newChanges.folders = folderChanges;
|
||||
}
|
||||
|
||||
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 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);
|
||||
} else if (resourceType === "folder") {
|
||||
newChanges.folders = newChanges.folders.filter((c) => c.id !== changeId);
|
||||
}
|
||||
|
||||
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 contextKey = generateContextKey(
|
||||
context.workspaceId,
|
||||
context.environment,
|
||||
context.secretPath
|
||||
);
|
||||
|
||||
set((state) => {
|
||||
const contextChanges = state.pendingChangesByContext.get(contextKey) || {
|
||||
secrets: [],
|
||||
folders: []
|
||||
};
|
||||
|
||||
return {
|
||||
pendingChanges: contextChanges,
|
||||
currentContext: context
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearAllPendingChanges: (context) => {
|
||||
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
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
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 +691,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 +733,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));
|
||||
|
||||
+28
@@ -77,12 +77,15 @@ import {
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
||||
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
||||
|
||||
import {
|
||||
PendingFolderCreate,
|
||||
PopUpNames,
|
||||
useBatchModeActions,
|
||||
usePopUpAction,
|
||||
useSelectedSecretActions,
|
||||
useSelectedSecrets
|
||||
@@ -112,6 +115,7 @@ type Props = {
|
||||
filter: Filter;
|
||||
tags?: WsTag[];
|
||||
isVisible?: boolean;
|
||||
isBatchMode?: boolean;
|
||||
snapshotCount: number;
|
||||
isSnapshotCountLoading?: boolean;
|
||||
protectedBranchPolicyName?: string;
|
||||
@@ -142,6 +146,7 @@ export const ActionBar = ({
|
||||
filter,
|
||||
tags = [],
|
||||
isVisible,
|
||||
isBatchMode,
|
||||
snapshotCount,
|
||||
isSnapshotCountLoading,
|
||||
onSearchChange,
|
||||
@@ -182,6 +187,7 @@ export const ActionBar = ({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||
@@ -192,6 +198,28 @@ export const ActionBar = ({
|
||||
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
try {
|
||||
if (isBatchMode) {
|
||||
const folderId = `${folderName}`;
|
||||
const pendingFolderCreate: PendingFolderCreate = {
|
||||
id: folderId,
|
||||
resourceType: "folder",
|
||||
type: PendingAction.Create,
|
||||
folderName,
|
||||
description: description || undefined,
|
||||
parentPath: secretPath,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
addPendingChange(pendingFolderCreate, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
handlePopUpClose("addFolder");
|
||||
return;
|
||||
}
|
||||
|
||||
await createFolder({
|
||||
name: folderName,
|
||||
path: secretPath,
|
||||
|
||||
+7
-2
@@ -8,7 +8,12 @@ import { TextArea } from "@app/components/v2/TextArea/TextArea";
|
||||
|
||||
type Props = {
|
||||
onCreateFolder?: (folderName: string, description: string | null) => Promise<void>;
|
||||
onUpdateFolder?: (folderName: string, description: string | null) => Promise<void>;
|
||||
onUpdateFolder?: (
|
||||
folderName: string,
|
||||
description: string | null,
|
||||
oldFolderName?: string,
|
||||
oldFolderDescription?: string
|
||||
) => Promise<void>;
|
||||
isEdit?: boolean;
|
||||
defaultFolderName?: string;
|
||||
defaultDescription?: string;
|
||||
@@ -69,7 +74,7 @@ export const FolderForm = ({
|
||||
const descriptionShaped = description && description.trim() !== "" ? description : null;
|
||||
|
||||
if (isEdit) {
|
||||
await onUpdateFolder?.(name, descriptionShaped);
|
||||
await onUpdateFolder?.(name, descriptionShaped, defaultFolderName, defaultDescription);
|
||||
} else {
|
||||
await onCreateFolder?.(name, descriptionShaped);
|
||||
}
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { faCodeCommit, faEye, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { SecretVersionDiffView } from "@app/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView";
|
||||
|
||||
import {
|
||||
PendingChange,
|
||||
PendingChanges,
|
||||
useBatchMode,
|
||||
useBatchModeActions
|
||||
} from "../../SecretMainPage.store";
|
||||
|
||||
interface CommitFormProps {
|
||||
onCommit: (changes: PendingChanges, commitMessage: string) => Promise<void>;
|
||||
isCommitting?: boolean;
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath: string;
|
||||
}
|
||||
|
||||
interface ResourceChangeProps {
|
||||
change: PendingChange;
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath: string;
|
||||
}
|
||||
|
||||
type RenderResourceProps = {
|
||||
onDiscard: () => void;
|
||||
change: PendingChange;
|
||||
};
|
||||
|
||||
const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => {
|
||||
if (change.resourceType !== "secret") return null;
|
||||
|
||||
if (change.type === PendingAction.Create) {
|
||||
return (
|
||||
<SecretVersionDiffView
|
||||
onDiscard={onDiscard}
|
||||
item={{
|
||||
secretKey: change.secretKey,
|
||||
isAdded: true,
|
||||
type: "secret",
|
||||
id: change.id,
|
||||
versions: [
|
||||
{
|
||||
version: 1, // placeholder, not used
|
||||
secretKey: change.secretKey,
|
||||
secretValue: change.secretValue,
|
||||
tags: change.tags,
|
||||
secretMetadata: change.secretMetadata,
|
||||
skipMultilineEncoding: change.skipMultilineEncoding,
|
||||
comment: change.secretComment
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (change.type === PendingAction.Update) {
|
||||
const { existingSecret } = change;
|
||||
|
||||
const hasKeyChange = change.newSecretName && change.secretKey !== change.newSecretName;
|
||||
const hasValueChange = change.secretValue !== change.originalValue;
|
||||
const hasCommentChange = change.secretComment !== change.originalComment;
|
||||
const hasMultilineChange =
|
||||
change.skipMultilineEncoding !== change.originalSkipMultilineEncoding;
|
||||
const hasTagsChange = JSON.stringify(change.tags) !== JSON.stringify(change.originalTags);
|
||||
const hasMetadataChange =
|
||||
JSON.stringify(change.secretMetadata) !== JSON.stringify(change.originalSecretMetadata);
|
||||
|
||||
const hasChanges = [
|
||||
hasKeyChange,
|
||||
hasValueChange,
|
||||
hasCommentChange,
|
||||
hasMultilineChange,
|
||||
hasTagsChange,
|
||||
hasMetadataChange
|
||||
].some(Boolean);
|
||||
|
||||
if (!hasChanges) return null;
|
||||
|
||||
return (
|
||||
<SecretVersionDiffView
|
||||
onDiscard={onDiscard}
|
||||
item={{
|
||||
secretKey: change.secretKey,
|
||||
isUpdated: true,
|
||||
type: "secret",
|
||||
id: change.id,
|
||||
versions: [
|
||||
{
|
||||
version: 1, // placeholder, not used
|
||||
secretKey: change.newSecretName ? existingSecret.key : undefined,
|
||||
secretValue:
|
||||
change.secretValue !== undefined ? (existingSecret.value ?? "") : undefined,
|
||||
tags: change.tags ? (existingSecret.tags?.map((tag) => tag.slug) ?? []) : undefined,
|
||||
secretMetadata: change.secretMetadata ? existingSecret.secretMetadata : undefined,
|
||||
skipMultilineEncoding:
|
||||
typeof change.skipMultilineEncoding === "boolean"
|
||||
? existingSecret.skipMultilineEncoding
|
||||
: undefined,
|
||||
comment: change.secretComment !== undefined ? existingSecret.comment : undefined
|
||||
},
|
||||
{
|
||||
version: 2, // placeholder, not used
|
||||
secretKey: change.newSecretName,
|
||||
secretValue: change.secretValue,
|
||||
tags: change.tags?.map((tag) => tag.slug),
|
||||
secretMetadata: change.secretMetadata,
|
||||
skipMultilineEncoding: change.skipMultilineEncoding,
|
||||
comment: change.secretComment
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (change.type === PendingAction.Delete) {
|
||||
const { secretKey, secretValue } = change;
|
||||
return (
|
||||
<SecretVersionDiffView
|
||||
onDiscard={onDiscard}
|
||||
item={{
|
||||
secretKey: change.secretKey,
|
||||
isDeleted: true,
|
||||
type: "secret",
|
||||
id: change.id,
|
||||
versions: [
|
||||
{
|
||||
version: 1, // placeholder, not used
|
||||
secretKey,
|
||||
secretValue
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const RenderFolderChanges = ({ onDiscard, change }: RenderResourceProps) => {
|
||||
if (change.resourceType !== "folder") return null;
|
||||
|
||||
if (change.type === PendingAction.Create) {
|
||||
return (
|
||||
<SecretVersionDiffView
|
||||
onDiscard={onDiscard}
|
||||
item={{
|
||||
folderName: change.folderName,
|
||||
isAdded: true,
|
||||
type: "folder",
|
||||
id: change.id,
|
||||
versions: [
|
||||
{
|
||||
version: 1,
|
||||
name: change.folderName,
|
||||
description: change.description
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (change.type === PendingAction.Update) {
|
||||
const hasNameChange = change.folderName !== change.originalFolderName;
|
||||
const hasDescriptionChange = change.description !== change.originalDescription;
|
||||
|
||||
const hasChanges = [hasNameChange, hasDescriptionChange].some(Boolean);
|
||||
|
||||
if (!hasChanges) return null;
|
||||
|
||||
return (
|
||||
<SecretVersionDiffView
|
||||
onDiscard={onDiscard}
|
||||
item={{
|
||||
folderName: change.folderName,
|
||||
isUpdated: true,
|
||||
type: "folder",
|
||||
id: change.id,
|
||||
versions: [
|
||||
{
|
||||
version: 1, // placeholder, not used
|
||||
name: change.folderName ? change.originalFolderName : undefined,
|
||||
description: change.description ? change.originalDescription : undefined
|
||||
},
|
||||
{
|
||||
version: 2, // placeholder, not used
|
||||
name: change.folderName,
|
||||
description: change.description
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (change.type === PendingAction.Delete) {
|
||||
return (
|
||||
<SecretVersionDiffView
|
||||
onDiscard={onDiscard}
|
||||
item={{
|
||||
folderName: change.folderName,
|
||||
isDeleted: true,
|
||||
type: "folder",
|
||||
id: change.id,
|
||||
versions: [
|
||||
{
|
||||
version: 1,
|
||||
name: change.folderName
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ResourceChange: React.FC<ResourceChangeProps> = ({
|
||||
change,
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath
|
||||
}) => {
|
||||
const { removePendingChange } = useBatchModeActions();
|
||||
|
||||
const handleDeletePending = useCallback(
|
||||
(changeType: string, id: string) => {
|
||||
removePendingChange(id, changeType, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
},
|
||||
[change.resourceType, change.id]
|
||||
);
|
||||
|
||||
return change.resourceType === "secret" ? (
|
||||
<RenderSecretChanges
|
||||
key={change.id}
|
||||
change={change}
|
||||
onDiscard={() => handleDeletePending(change.resourceType, change.id)}
|
||||
/>
|
||||
) : (
|
||||
<RenderFolderChanges
|
||||
key={change.id}
|
||||
change={change}
|
||||
onDiscard={() => handleDeletePending(change.resourceType, change.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommitForm: React.FC<CommitFormProps> = ({
|
||||
onCommit,
|
||||
isCommitting = false,
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath
|
||||
}) => {
|
||||
const { isBatchMode, pendingChanges, totalChangesCount } = useBatchMode();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const { clearAllPendingChanges } = useBatchModeActions();
|
||||
|
||||
if (!isBatchMode || totalChangesCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!commitMessage.trim()) {
|
||||
return;
|
||||
}
|
||||
await onCommit(pendingChanges, commitMessage);
|
||||
clearAllPendingChanges({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
setIsModalOpen(false);
|
||||
setCommitMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Panel */}
|
||||
{!isModalOpen && (
|
||||
<div className="fixed bottom-4 left-1/2 z-40 w-full max-w-3xl -translate-x-1/2 self-center rounded-lg border border-yellow/30 bg-mineshaft-800 shadow-2xl lg:left-auto lg:translate-x-0">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{/* Left Content */}
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||
<span className="font-medium text-mineshaft-100">Pending Changes</span>
|
||||
<Badge variant="primary" className="text-xs">
|
||||
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm leading-5 text-mineshaft-400">
|
||||
Review pending changes and commit them to apply the updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Buttons */}
|
||||
<div className="ml-6 mt-0.5 flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => clearAllPendingChanges({ workspaceId, environment, secretPath })}
|
||||
isDisabled={totalChangesCount === 0}
|
||||
variant="outline_bg"
|
||||
className="px-4 hover:border-red/40 hover:bg-red/[0.1]"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
leftIcon={<FontAwesomeIcon icon={faEye} />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
isDisabled={totalChangesCount === 0}
|
||||
className="px-6"
|
||||
>
|
||||
Review Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit Modal */}
|
||||
<Modal isOpen={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<ModalContent
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
|
||||
Commit Changes
|
||||
<Badge variant="primary" className="mt-[0.05rem]">
|
||||
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
subTitle={"Write a commit message and review the changes you're about to commit."}
|
||||
className="max-h-[90vh] max-w-[95%] md:max-w-7xl"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Changes List */}
|
||||
<div className="space-y-6">
|
||||
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
|
||||
{/* Folder Changes */}
|
||||
{pendingChanges.folders.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-4 flex items-center gap-2 border-b border-mineshaft-700 pb-2 text-sm font-semibold text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-mineshaft-300" />
|
||||
Folders ({pendingChanges.folders.length})
|
||||
</h4>
|
||||
<div>
|
||||
{pendingChanges.folders.map((change) => (
|
||||
<ResourceChange
|
||||
key={change.id}
|
||||
change={change}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secret Changes */}
|
||||
{pendingChanges.secrets.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-4 flex items-center gap-2 border-b border-mineshaft-700 pb-2 text-sm font-semibold text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faKey} className="mr-1 text-mineshaft-300" />
|
||||
Secrets ({pendingChanges.secrets.length})
|
||||
</h4>
|
||||
<div>
|
||||
{pendingChanges.secrets.map((change) => (
|
||||
<ResourceChange
|
||||
key={change.id}
|
||||
change={change}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit Message */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-mineshaft-200">
|
||||
Commit Message <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Describe your changes..."
|
||||
className="w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
isDisabled={isCommitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
isLoading={isCommitting}
|
||||
isDisabled={isCommitting || !commitMessage.trim()}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
>
|
||||
{isCommitting ? "Committing..." : "Commit Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CommitForm } from "./CommitForm";
|
||||
+31
-2
@@ -12,9 +12,15 @@ import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
|
||||
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
|
||||
import {
|
||||
PendingSecretCreate,
|
||||
PopUpNames,
|
||||
useBatchModeActions,
|
||||
usePopUpAction
|
||||
} from "../../SecretMainPage.store";
|
||||
|
||||
const typeSchema = z.object({
|
||||
key: z.string().trim().min(1, { message: "Secret key is required" }),
|
||||
@@ -31,6 +37,7 @@ type Props = {
|
||||
// modal props
|
||||
autoCapitalize?: boolean;
|
||||
isProtectedBranch?: boolean;
|
||||
isBatchMode?: boolean;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({
|
||||
@@ -38,7 +45,8 @@ export const CreateSecretForm = ({
|
||||
workspaceId,
|
||||
secretPath = "/",
|
||||
autoCapitalize = true,
|
||||
isProtectedBranch = false
|
||||
isProtectedBranch = false,
|
||||
isBatchMode = false
|
||||
}: Props) => {
|
||||
const {
|
||||
register,
|
||||
@@ -53,6 +61,7 @@ export const CreateSecretForm = ({
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
const createWsTag = useCreateWsTag();
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
||||
@@ -85,6 +94,26 @@ export const CreateSecretForm = ({
|
||||
|
||||
const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => {
|
||||
try {
|
||||
if (isBatchMode) {
|
||||
const pendingSecretCreate: PendingSecretCreate = {
|
||||
id: key,
|
||||
type: PendingAction.Create,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
secretComment: "",
|
||||
tags: tags?.map((el) => ({ id: el.value, slug: el.label })),
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret"
|
||||
};
|
||||
addPendingChange(pendingSecretCreate, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
closePopUp(PopUpNames.CreateSecretForm);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
await createSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
|
||||
+5
-9
@@ -1,10 +1,5 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faClose,
|
||||
faFingerprint,
|
||||
faPencilSquare,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faEdit, faFingerprint, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -187,7 +182,7 @@ export const DynamicSecretListView = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-3 py-3">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
|
||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||
@@ -210,7 +205,7 @@ export const DynamicSecretListView = ({
|
||||
}}
|
||||
isDisabled={!isAllowed || isRevoking}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
@@ -228,6 +223,7 @@ export const DynamicSecretListView = ({
|
||||
<IconButton
|
||||
ariaLabel="delete-dynamic-secret"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(evt) => {
|
||||
@@ -236,7 +232,7 @@ export const DynamicSecretListView = ({
|
||||
}}
|
||||
isDisabled={!isAllowed || isRevoking}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
+183
-51
@@ -1,7 +1,15 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faClose,
|
||||
faEdit,
|
||||
faFolder,
|
||||
faInfoCircle,
|
||||
faPencilSquare,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
@@ -11,8 +19,15 @@ import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
import {
|
||||
PendingFolderCreate,
|
||||
PendingFolderDelete,
|
||||
PendingFolderUpdate,
|
||||
useBatchMode,
|
||||
useBatchModeActions
|
||||
} from "../../SecretMainPage.store";
|
||||
import { FolderForm } from "../ActionBar/FolderForm";
|
||||
|
||||
type Props = {
|
||||
@@ -42,10 +57,62 @@ export const FolderListView = ({
|
||||
|
||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||
const { isBatchMode } = useBatchMode();
|
||||
const { addPendingChange, removePendingChange } = useBatchModeActions();
|
||||
|
||||
const handleFolderUpdate = async (newFolderName: string, newFolderDescription: string | null) => {
|
||||
const handleFolderUpdate = async (
|
||||
newFolderName: string,
|
||||
newFolderDescription: string | null,
|
||||
oldFolderName?: string,
|
||||
oldFolderDescription?: string
|
||||
) => {
|
||||
try {
|
||||
const { id: folderId } = popUp.updateFolder.data as TSecretFolder;
|
||||
const updateFolderData = popUp.updateFolder.data;
|
||||
if (!updateFolderData) throw new Error("Update folder data is required");
|
||||
const { id: folderId, pendingAction, isPending } = updateFolderData as TSecretFolder;
|
||||
|
||||
if (isBatchMode) {
|
||||
const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create;
|
||||
|
||||
if (isEditingPendingCreation) {
|
||||
const updatedCreate: PendingFolderCreate = {
|
||||
id: folderId,
|
||||
type: PendingAction.Create,
|
||||
folderName: newFolderName,
|
||||
description: newFolderDescription || undefined,
|
||||
parentPath: secretPath,
|
||||
timestamp: Date.now(),
|
||||
resourceType: "folder"
|
||||
};
|
||||
|
||||
addPendingChange(updatedCreate, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
} else {
|
||||
const updateChange: PendingFolderUpdate = {
|
||||
id: folderId,
|
||||
type: PendingAction.Update,
|
||||
originalFolderName: oldFolderName || "",
|
||||
folderName: newFolderName,
|
||||
originalDescription: oldFolderDescription,
|
||||
description: newFolderDescription || undefined,
|
||||
timestamp: Date.now(),
|
||||
resourceType: "folder"
|
||||
};
|
||||
|
||||
addPendingChange(updateChange, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("updateFolder");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateFolder({
|
||||
folderId,
|
||||
name: newFolderName,
|
||||
@@ -68,15 +135,45 @@ export const FolderListView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePending = (id: string) => {
|
||||
removePendingChange(id, "folder", {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
};
|
||||
|
||||
const handleFolderDelete = async () => {
|
||||
try {
|
||||
const { id: folderId } = popUp.deleteFolder.data as TSecretFolder;
|
||||
const folderData = popUp.deleteFolder?.data as TSecretFolder;
|
||||
|
||||
if (isBatchMode) {
|
||||
const pendingFolderDelete: PendingFolderDelete = {
|
||||
id: folderData.id,
|
||||
folderName: folderData.name,
|
||||
folderPath: secretPath,
|
||||
resourceType: "folder",
|
||||
type: PendingAction.Delete,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
addPendingChange(pendingFolderDelete, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteFolder");
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteFolder({
|
||||
folderId,
|
||||
folderId: folderData.id,
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId: workspaceId
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
@@ -91,7 +188,10 @@ export const FolderListView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (name: string) => {
|
||||
const handleFolderClick = (name: string, isPending?: boolean) => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`;
|
||||
navigate({
|
||||
search: (el) => ({ ...el, secretPath: path })
|
||||
@@ -100,10 +200,16 @@ export const FolderListView = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{folders.map(({ name, id, description }) => (
|
||||
{folders.map(({ name, id, description, pendingAction, isPending }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
|
||||
className={twMerge(
|
||||
"group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700",
|
||||
isPending && "bg-mineshaft-700/60",
|
||||
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
|
||||
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
|
||||
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
|
||||
<FontAwesomeIcon icon={faFolder} />
|
||||
@@ -113,9 +219,9 @@ export const FolderListView = ({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handleFolderClick(name);
|
||||
if (evt.key === "Enter") handleFolderClick(name, isPending);
|
||||
}}
|
||||
onClick={() => handleFolderClick(name)}
|
||||
onClick={() => handleFolderClick(name, isPending)}
|
||||
>
|
||||
{name}
|
||||
{description && (
|
||||
@@ -128,46 +234,72 @@ export const FolderListView = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="edit-folder"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-folder"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||
<IconButton
|
||||
ariaLabel="edit-folder"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
isDisabled
|
||||
onClick={() => {}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
ariaLabel="delete-folder"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handleDeletePending(id)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-3 py-3">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="edit-folder"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-folder"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Modal
|
||||
|
||||
+3
-3
@@ -3,13 +3,13 @@ import { subject } from "@casl/ability";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faCalendarCheck,
|
||||
faClose,
|
||||
faFileImport,
|
||||
faFolder,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faRotate,
|
||||
faSearch,
|
||||
faTrash,
|
||||
faUpDown,
|
||||
faWarning,
|
||||
faXmark
|
||||
@@ -237,7 +237,7 @@ export const SecretImportItem = ({
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
|
||||
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 py-3.5 pl-4 pr-3">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.SecretImports, {
|
||||
@@ -281,7 +281,7 @@ export const SecretImportItem = ({
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
+8
-3
@@ -52,6 +52,7 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { getProjectBaseURL } from "@app/helpers/project";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useGetSecretVersion } from "@app/hooks/api";
|
||||
import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
@@ -980,7 +981,9 @@ export const SecretDetailSidebar = ({
|
||||
className="z-[100] capitalize"
|
||||
>
|
||||
<Link
|
||||
to="/projects/$projectId/members/$membershipId"
|
||||
to={
|
||||
`${getProjectBaseURL(currentWorkspace.type)}/members/$membershipId` as const
|
||||
}
|
||||
params={{
|
||||
projectId: currentWorkspace.id,
|
||||
membershipId: user.membershipId
|
||||
@@ -1011,7 +1014,9 @@ export const SecretDetailSidebar = ({
|
||||
className="z-[100]"
|
||||
>
|
||||
<Link
|
||||
to="/projects/$projectId/identities/$identityId"
|
||||
to={
|
||||
`${getProjectBaseURL(currentWorkspace.type)}/identities/$identityId` as const
|
||||
}
|
||||
params={{
|
||||
projectId: currentWorkspace.id,
|
||||
identityId: identity.id
|
||||
@@ -1078,7 +1083,7 @@ export const SecretDetailSidebar = ({
|
||||
isDisabled={isSubmitting || !isDirty || !isAllowed}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save Changes
|
||||
Apply Changes
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
+491
-276
@@ -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, useCallback, useEffect, useRef } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import {
|
||||
FontAwesomeSpriteName,
|
||||
formSchema,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
TFormSchema
|
||||
} from "./SecretListView.utils";
|
||||
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
||||
import { useBatchModeActions } from "../../SecretMainPage.store";
|
||||
|
||||
export const HIDDEN_SECRET_VALUE = "******";
|
||||
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
|
||||
@@ -86,6 +88,8 @@ type Props = {
|
||||
isImported: boolean;
|
||||
}[];
|
||||
}[];
|
||||
isPending?: boolean;
|
||||
pendingAction?: PendingAction;
|
||||
};
|
||||
|
||||
export const SecretItem = memo(
|
||||
@@ -102,7 +106,9 @@ export const SecretItem = memo(
|
||||
environment,
|
||||
secretPath,
|
||||
handleSecretShare,
|
||||
importedBy
|
||||
importedBy,
|
||||
isPending,
|
||||
pendingAction
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"editSecret"
|
||||
@@ -110,6 +116,18 @@ export const SecretItem = memo(
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
const { isRotatedSecret } = secret;
|
||||
const { removePendingChange } = useBatchModeActions();
|
||||
|
||||
const autoSaveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const isAutoSavingRef = useRef(false);
|
||||
|
||||
const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => {
|
||||
removePendingChange(pendingSecret.id, "secret", {
|
||||
workspaceId: currentWorkspace.id,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
};
|
||||
|
||||
const canEditSecretValue = permission.can(
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
@@ -151,7 +169,6 @@ export const SecretItem = memo(
|
||||
});
|
||||
|
||||
const secretName = watch("key");
|
||||
|
||||
const overrideAction = watch("overrideAction");
|
||||
const hasComment = Boolean(watch("comment"));
|
||||
|
||||
@@ -167,6 +184,56 @@ 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 {
|
||||
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 = 600;
|
||||
|
||||
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||
autoSaveChanges(formValues);
|
||||
}, debounceTime);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [formValues, isDirty, isSubmitting, autoSaveChanges, isPending]);
|
||||
|
||||
const isReadOnly =
|
||||
hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
@@ -188,6 +255,9 @@ export const SecretItem = memo(
|
||||
})
|
||||
);
|
||||
|
||||
const isReadOnlySecret =
|
||||
isReadOnly || isRotatedSecret || (isPending && pendingAction === PendingAction.Delete);
|
||||
|
||||
const { secretValueHidden } = secret;
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
@@ -199,10 +269,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
|
||||
@@ -267,20 +333,27 @@ export const SecretItem = memo(
|
||||
setIsSecValueCopied.on();
|
||||
};
|
||||
|
||||
const isInAutoSaveMode = isDirty && !isSubmitting && !isOverriden;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
|
||||
isDirty && "border-primary-400/50",
|
||||
isRotatedSecret && "bg-mineshaft-700/60"
|
||||
isRotatedSecret && "bg-mineshaft-700/60",
|
||||
isPending && "bg-mineshaft-700/60",
|
||||
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
|
||||
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
|
||||
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
|
||||
)}
|
||||
>
|
||||
<div className="group flex">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-11 w-11 items-center justify-center px-4 py-3 text-mineshaft-300",
|
||||
isDirty && "text-primary"
|
||||
isDirty && "text-primary",
|
||||
isPending && "ml-[-2px]"
|
||||
)}
|
||||
>
|
||||
{secret.isRotatedSecret ? (
|
||||
@@ -364,7 +437,7 @@ export const SecretItem = memo(
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<InfisicalSecretInput
|
||||
isReadOnly={isReadOnly || isRotatedSecret}
|
||||
isReadOnly={isReadOnlySecret}
|
||||
key="secret-value"
|
||||
isVisible={isVisible && !secretValueHidden}
|
||||
canEditButNotView={secretValueHidden && !isOverriden}
|
||||
@@ -377,275 +450,160 @@ export const SecretItem = memo(
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
key="actions"
|
||||
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
||||
>
|
||||
<Tooltip content="Copy secret">
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden}
|
||||
ariaLabel="copy-value"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3"
|
||||
symbolName={
|
||||
isSecValueCopied
|
||||
? FontAwesomeSpriteName.Check
|
||||
: FontAwesomeSpriteName.ClipboardCopy
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
{pendingAction !== PendingAction.Create && pendingAction !== PendingAction.Delete && (
|
||||
<div
|
||||
key="actions"
|
||||
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>
|
||||
<IconButton
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="reference-tree"
|
||||
isDisabled={!isAllowed || !hasSecretReference(secret?.value)}
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
hasSecretReference(secret?.value)
|
||||
? "Secret Reference Tree"
|
||||
: "Secret does not contain references"
|
||||
}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.SecretReferenceTree}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
title="Secret Reference Details"
|
||||
subTitle="Visual breakdown of secrets referenced by this secret."
|
||||
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
|
||||
>
|
||||
<SecretReferenceTree
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
secretKey={secret?.key}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
ariaLabel="tags"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
||||
hasTagsApplied && "w-5 text-primary"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Tags">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.Tags}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { id: tagId, slug, color } = tag;
|
||||
|
||||
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={`${secret.id}-${tagId}`}
|
||||
icon={
|
||||
isTagSelected && (
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-2 h-2 w-2 rounded-full"
|
||||
style={{ background: color || "#bec2c8" }}
|
||||
/>
|
||||
{slug}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem className="px-1.5" asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
leftIcon={
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Tags}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
}
|
||||
onClick={onCreateTag}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Override"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Tooltip content="Copy secret">
|
||||
<IconButton
|
||||
ariaLabel="override-value"
|
||||
isDisabled={!isAllowed}
|
||||
isDisabled={secret.secretValueHidden}
|
||||
ariaLabel="copy-value"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
onClick={handleOverrideClick}
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||
isOverriden && "w-5 text-primary"
|
||||
)}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Override}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Popover>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<PopoverTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||
hasComment && "w-5 text-primary"
|
||||
)}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-comment"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Comment">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.Comment}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="share-secret"
|
||||
onClick={handleSecretShare}
|
||||
>
|
||||
<Tooltip content="Share Secret">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.ShareSecret}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<PopoverContent
|
||||
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
||||
sticky="always"
|
||||
>
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
rows={8}
|
||||
cols={30}
|
||||
{...register("comment")}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{!isDirty ? (
|
||||
<motion.div
|
||||
key="options"
|
||||
className="flex h-10 flex-shrink-0 items-center space-x-4 px-[0.64rem]"
|
||||
initial={{ x: 0, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="More">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onDetailViewSecret(secret)}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.More}
|
||||
className="h-5 w-4"
|
||||
className="h-3.5 w-3"
|
||||
symbolName={
|
||||
isSecValueCopied
|
||||
? FontAwesomeSpriteName.Check
|
||||
: FontAwesomeSpriteName.ClipboardCopy
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>
|
||||
<IconButton
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="reference-tree"
|
||||
isDisabled={!isAllowed || !hasSecretReference(secret?.value)}
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
hasSecretReference(secret?.value)
|
||||
? "Secret Reference Tree"
|
||||
: "Secret does not contain references"
|
||||
}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.SecretReferenceTree}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
title="Secret Reference Details"
|
||||
subTitle="Visual breakdown of secrets referenced by this secret."
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<SecretReferenceTree
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
secretKey={secret?.key}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
ariaLabel="tags"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
||||
hasTagsApplied && "w-5 text-primary"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Tags">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.Tags}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { id: tagId, slug, color } = tag;
|
||||
|
||||
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={`${secret.id}-${tagId}`}
|
||||
icon={
|
||||
isTagSelected && (
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-2 h-2 w-2 rounded-full"
|
||||
style={{ background: color || "#bec2c8" }}
|
||||
/>
|
||||
{slug}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem className="px-1.5" asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
leftIcon={
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Tags}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
}
|
||||
onClick={onCreateTag}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
@@ -653,30 +611,287 @@ export const SecretItem = memo(
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"} // just using label for isRotatedSecret, disabled below
|
||||
allowedLabel="Override"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="override-value"
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="sm"
|
||||
onClick={handleOverrideClick}
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||
isOverriden && "w-5 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Override}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Popover>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<PopoverTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||
hasComment && "w-5 text-primary"
|
||||
)}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-comment"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Comment">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.Comment}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="share-secret"
|
||||
onClick={handleSecretShare}
|
||||
>
|
||||
<Tooltip content="Share Secret">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.ShareSecret}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<PopoverContent
|
||||
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
||||
sticky="always"
|
||||
>
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
rows={8}
|
||||
cols={30}
|
||||
{...register("comment")}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{pendingAction === PendingAction.Create && (
|
||||
<div
|
||||
key="actions"
|
||||
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
ariaLabel="tags"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
||||
hasTagsApplied && "w-5 text-primary"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Tags">
|
||||
<FontAwesomeSymbol
|
||||
className="h-3.5 w-3.5"
|
||||
symbolName={FontAwesomeSpriteName.Tags}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { id: tagId, slug, color } = tag;
|
||||
|
||||
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={`${secret.id}-${tagId}`}
|
||||
icon={
|
||||
isTagSelected && (
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-2 h-2 w-2 rounded-full"
|
||||
style={{ background: color || "#bec2c8" }}
|
||||
/>
|
||||
{slug}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem className="px-1.5" asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
leftIcon={
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Tags}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
}
|
||||
onClick={onCreateTag}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{isInAutoSaveMode ? (
|
||||
<motion.div
|
||||
key="auto-save-mode"
|
||||
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -10, opacity: 0 }}
|
||||
>
|
||||
<div className="h-10 w-12" />
|
||||
</motion.div>
|
||||
) : !isDirty ? (
|
||||
isPending ? (
|
||||
<motion.div
|
||||
key="options"
|
||||
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||
initial={{ x: 0, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="More">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onDetailViewSecret(secret)}
|
||||
isDisabled={pendingAction !== "update"}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.More}
|
||||
className="h-5 w-4"
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Discard">
|
||||
<IconButton
|
||||
ariaLabel="delete-value"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onDeleteSecret(secret)}
|
||||
isDisabled={!isAllowed || isRotatedSecret}
|
||||
onClick={() => handleDeletePending(secret)}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Close}
|
||||
className="h-5 w-4"
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="options"
|
||||
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||
initial={{ x: 0, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="More">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onDetailViewSecret(secret)}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.More}
|
||||
className="h-5 w-4"
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-value"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onDeleteSecret(secret)}
|
||||
isDisabled={!isAllowed || isRotatedSecret}
|
||||
>
|
||||
<FontAwesomeSymbol
|
||||
symbolName={FontAwesomeSpriteName.Trash}
|
||||
className="h-4 w-3"
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</motion.div>
|
||||
)
|
||||
) : (
|
||||
<motion.div
|
||||
key="options-save"
|
||||
className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
|
||||
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -10, opacity: 0 }}
|
||||
|
||||
+188
-15
@@ -11,13 +11,24 @@ import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||
import { commitKeys } from "@app/hooks/api/folderCommits/queries";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
import { useNavigationBlocker } from "@app/hooks/useNavigationBlocker";
|
||||
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
|
||||
|
||||
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
||||
import {
|
||||
PendingSecretChange,
|
||||
PendingSecretCreate,
|
||||
PendingSecretDelete,
|
||||
PendingSecretUpdate,
|
||||
useBatchMode,
|
||||
useBatchModeActions,
|
||||
useSelectedSecretActions,
|
||||
useSelectedSecrets
|
||||
} from "../../SecretMainPage.store";
|
||||
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
||||
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
||||
import { HIDDEN_SECRET_VALUE, HIDDEN_SECRET_VALUE_API_MASK, SecretItem } from "./SecretItem";
|
||||
@@ -63,22 +74,29 @@ export const SecretListView = ({
|
||||
|
||||
// strip of side effect queries
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
const handleSecretOperation = async (
|
||||
operation: "create" | "update" | "delete",
|
||||
@@ -170,11 +188,56 @@ export const SecretListView = ({
|
||||
);
|
||||
};
|
||||
|
||||
function getTrueOriginalSecret(
|
||||
currentSecret: SecretV3RawSanitized,
|
||||
pendingSecrets: PendingSecretChange[]
|
||||
): Omit<SecretV3RawSanitized, "tags"> & {
|
||||
tags?: { id: string; slug: string }[];
|
||||
} {
|
||||
// Find if there's already a pending change for this secret
|
||||
const existingChange = pendingSecrets.find((change) => change.id === currentSecret.id);
|
||||
|
||||
if (!existingChange || existingChange.type === "create") {
|
||||
// If no existing change or it's a creation, current secret is the original
|
||||
return {
|
||||
...currentSecret,
|
||||
tags: currentSecret.tags?.map((tag) => ({
|
||||
id: tag.id,
|
||||
slug: tag.slug
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
if (existingChange.type === "update") {
|
||||
// If there's an existing update, reconstruct the original from the stored original values
|
||||
return {
|
||||
...currentSecret,
|
||||
key: existingChange.secretKey, // Original key
|
||||
value: existingChange.originalValue || currentSecret.value,
|
||||
comment: existingChange.originalComment || currentSecret.comment,
|
||||
skipMultilineEncoding:
|
||||
existingChange.originalSkipMultilineEncoding ?? currentSecret.skipMultilineEncoding,
|
||||
tags:
|
||||
existingChange.originalTags?.map((tag) => ({
|
||||
id: tag.id,
|
||||
slug: tag.slug
|
||||
})) || currentSecret.tags,
|
||||
secretMetadata: existingChange.originalSecretMetadata || currentSecret.secretMetadata
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...currentSecret,
|
||||
tags: currentSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug }))
|
||||
};
|
||||
}
|
||||
|
||||
// Improved handleSaveSecret function
|
||||
const handleSaveSecret = useCallback(
|
||||
async (
|
||||
orgSecret: SecretV3RawSanitized,
|
||||
modSecret: Omit<SecretV3RawSanitized, "tags"> & {
|
||||
tags?: { id: string }[];
|
||||
tags?: { id: string; name?: string; slug?: string }[];
|
||||
secretMetadata?: { key: string; value: string }[];
|
||||
},
|
||||
cb?: () => void
|
||||
@@ -192,7 +255,9 @@ export const SecretListView = ({
|
||||
reminderNote,
|
||||
reminderRecipients,
|
||||
secretMetadata,
|
||||
isReminderEvent
|
||||
isReminderEvent,
|
||||
isPending,
|
||||
pendingAction
|
||||
} = modSecret;
|
||||
const hasKeyChanged = oldKey !== key && key;
|
||||
|
||||
@@ -246,6 +311,80 @@ export const SecretListView = ({
|
||||
|
||||
// shared secret change
|
||||
if (!isSharedSecUnchanged && !personalAction) {
|
||||
if (isBatchMode) {
|
||||
const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create;
|
||||
|
||||
if (isEditingPendingCreation) {
|
||||
const updatedCreate: PendingSecretCreate = {
|
||||
id: orgSecret.id,
|
||||
type: PendingAction.Create,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
secretComment: comment || "",
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding || false,
|
||||
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || [],
|
||||
secretMetadata: secretMetadata || [],
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret",
|
||||
originalKey: oldKey
|
||||
};
|
||||
|
||||
addPendingChange(updatedCreate, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
} else {
|
||||
const trueOriginalSecret = getTrueOriginalSecret(orgSecret, pendingChanges.secrets);
|
||||
|
||||
const updateChange: PendingSecretUpdate = {
|
||||
id: orgSecret.id,
|
||||
type: PendingAction.Update,
|
||||
secretKey: trueOriginalSecret.key,
|
||||
...(key !== trueOriginalSecret.key && { newSecretName: key }),
|
||||
...(value !== trueOriginalSecret.value && {
|
||||
originalValue: trueOriginalSecret.value,
|
||||
secretValue: value
|
||||
}),
|
||||
...(comment !== trueOriginalSecret.comment && {
|
||||
originalComment: trueOriginalSecret.comment,
|
||||
secretComment: comment
|
||||
}),
|
||||
...(modSecret.skipMultilineEncoding !==
|
||||
trueOriginalSecret.skipMultilineEncoding && {
|
||||
originalSkipMultilineEncoding: trueOriginalSecret.skipMultilineEncoding,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding
|
||||
}),
|
||||
...(!isSameTags && {
|
||||
originalTags:
|
||||
trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
|
||||
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || []
|
||||
}),
|
||||
...(JSON.stringify(secretMetadata) !==
|
||||
JSON.stringify(trueOriginalSecret.secretMetadata) && {
|
||||
originalSecretMetadata: trueOriginalSecret.secretMetadata || [],
|
||||
secretMetadata: secretMetadata || []
|
||||
}),
|
||||
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret",
|
||||
existingSecret: orgSecret
|
||||
};
|
||||
|
||||
addPendingChange(updateChange, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
}
|
||||
|
||||
if (!isReminderEvent) {
|
||||
handlePopUpClose("secretDetail");
|
||||
}
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSecretOperation("update", SecretType.Shared, oldKey, {
|
||||
value,
|
||||
tags: tagIds,
|
||||
@@ -314,12 +453,41 @@ export const SecretListView = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[environment, secretPath, isProtectedBranch]
|
||||
[
|
||||
environment,
|
||||
secretPath,
|
||||
isProtectedBranch,
|
||||
isBatchMode,
|
||||
workspaceId,
|
||||
addPendingChange,
|
||||
pendingChanges.secrets
|
||||
]
|
||||
);
|
||||
|
||||
const handleSecretDelete = useCallback(async () => {
|
||||
const { key, id: secretId } = popUp.deleteSecret?.data as SecretV3RawSanitized;
|
||||
const { key, id: secretId, value } = popUp.deleteSecret?.data as SecretV3RawSanitized;
|
||||
try {
|
||||
if (isBatchMode) {
|
||||
const deleteChange: PendingSecretDelete = {
|
||||
id: `${secretId}`,
|
||||
type: PendingAction.Delete,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
timestamp: Date.now(),
|
||||
resourceType: "secret"
|
||||
};
|
||||
|
||||
addPendingChange(deleteChange, {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteSecret");
|
||||
handlePopUpClose("secretDetail");
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
|
||||
// wrap this in another function and then reuse
|
||||
queryClient.invalidateQueries({
|
||||
@@ -362,7 +530,10 @@ export const SecretListView = ({
|
||||
(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key,
|
||||
environment,
|
||||
secretPath,
|
||||
isProtectedBranch
|
||||
isProtectedBranch,
|
||||
isBatchMode,
|
||||
workspaceId,
|
||||
addPendingChange
|
||||
]);
|
||||
|
||||
// for optimization on minimise re-rendering of secret items
|
||||
@@ -401,6 +572,8 @@ export const SecretListView = ({
|
||||
value: secret.valueOverride ?? secret.value
|
||||
})
|
||||
}
|
||||
isPending={secret.isPending}
|
||||
pendingAction={secret.pendingAction}
|
||||
/>
|
||||
))}
|
||||
<DeleteActionModal
|
||||
|
||||
+6
-3
@@ -13,7 +13,8 @@ import {
|
||||
faLock,
|
||||
faProjectDiagram,
|
||||
faShare,
|
||||
faTags
|
||||
faTags,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -82,7 +83,8 @@ export enum FontAwesomeSpriteName {
|
||||
ReplicatedSecretKey = "secret-replicated",
|
||||
ShareSecret = "share-secret",
|
||||
KeyLock = "key-lock",
|
||||
SecretReferenceTree = "secret-reference-tree"
|
||||
SecretReferenceTree = "secret-reference-tree",
|
||||
Trash = "trash"
|
||||
}
|
||||
|
||||
// this is an optimization technique
|
||||
@@ -101,5 +103,6 @@ export const FontAwesomeSpriteSymbols = [
|
||||
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey },
|
||||
{ icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret },
|
||||
{ icon: faLock, symbol: FontAwesomeSpriteName.KeyLock },
|
||||
{ icon: faProjectDiagram, symbol: FontAwesomeSpriteName.SecretReferenceTree }
|
||||
{ icon: faProjectDiagram, symbol: FontAwesomeSpriteName.SecretReferenceTree },
|
||||
{ icon: faTrash, symbol: FontAwesomeSpriteName.Trash }
|
||||
];
|
||||
|
||||
+5
-7
@@ -2,11 +2,11 @@ import { useState } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAsterisk,
|
||||
faClose,
|
||||
faEdit,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faRotate
|
||||
faRotate,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -48,7 +48,7 @@ export const SecretRotationItem = ({
|
||||
<div className="text- flex w-11 items-center py-2 pl-5 text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faRotate} />
|
||||
</div>
|
||||
<div className="flex flex-grow items-center border-r border-mineshaft-600 py-2 pl-4 pr-2">
|
||||
<div className="flex flex-grow items-center py-2 pl-4 pr-2">
|
||||
<div className="flex w-full flex-wrap items-center">
|
||||
<span>{name}</span>
|
||||
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
|
||||
@@ -141,7 +141,7 @@ export const SecretRotationItem = ({
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="options"
|
||||
className="flex h-10 flex-shrink-0 items-center space-x-1 px-[0.665rem]"
|
||||
className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-2 py-3"
|
||||
initial={{ x: 0, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 10, opacity: 0 }}
|
||||
@@ -159,7 +159,6 @@ export const SecretRotationItem = ({
|
||||
<IconButton
|
||||
ariaLabel="Edit rotation"
|
||||
variant="plain"
|
||||
size="md"
|
||||
isDisabled={!isAllowed}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
@@ -185,7 +184,6 @@ export const SecretRotationItem = ({
|
||||
ariaLabel="Delete rotation"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -193,7 +191,7 @@ export const SecretRotationItem = ({
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
@@ -13,7 +13,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({
|
||||
tags: z.string().catch("")
|
||||
});
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/secrets/$envSlug"
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/secrets/$envSlug"
|
||||
)({
|
||||
component: SecretDashboardPage,
|
||||
validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema),
|
||||
@@ -28,7 +28,7 @@ export const Route = createFileRoute(
|
||||
{
|
||||
label: "Secrets",
|
||||
link: linkOptions({
|
||||
to: "/projects/$projectId/secret-manager/overview",
|
||||
to: "/projects/secret-management/$projectId/overview",
|
||||
params
|
||||
})
|
||||
},
|
||||
@@ -39,7 +39,7 @@ export const Route = createFileRoute(
|
||||
links: context.project.environments.map((el) => ({
|
||||
label: el.name,
|
||||
link: linkOptions({
|
||||
to: "/projects/$projectId/secret-manager/secrets/$envSlug",
|
||||
to: "/projects/secret-management/$projectId/secrets/$envSlug",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
envSlug: el.slug
|
||||
|
||||
Reference in New Issue
Block a user