diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 444614b82..786b2b43a 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -75,7 +75,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit< }; export type TDashboardProjectSecretsQuickSearchResponse = { - folders: (TSecretFolder & { environment: string; path: string })[]; + folders: (TSecretFolder & { envId: string; path: string })[]; dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[]; secrets: SecretV3Raw[]; }; @@ -83,7 +83,7 @@ export type TDashboardProjectSecretsQuickSearchResponse = { export type TDashboardProjectSecretsQuickSearch = { folders: Record; secrets: Record; - dynamicSecrets: Record; + dynamicSecrets: Record; }; export type TGetDashboardProjectSecretsQuickSearchDTO = { diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx index b2ea0e917..4738e411e 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -1,5 +1,5 @@ import { subject } from "@casl/ability"; -import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faAnglesRight, faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -19,6 +19,7 @@ import { TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; +import { MoveSecretsModal } from "@app/pages/secret-manager/OverviewPage/components/SelectionPanel/components"; export enum EntryType { FOLDER = "folder", @@ -38,7 +39,8 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri const { permission } = useProjectPermission(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ - "bulkDeleteEntries" + "bulkDeleteEntries", + "bulkMoveSecrets" ] as const); const selectedFolderCount = Object.keys(selectedEntries.folder).length; @@ -165,6 +167,8 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri } }; + const areFoldersSelected = Boolean(Object.keys(selectedEntries[EntryType.FOLDER]).length); + return ( <>
{selectedCount} Selected
{shouldShowDelete && ( - + <> + +
+ +
+
+ + )}
+ handlePopUpToggle("bulkMoveSecrets", isOpen)} + environments={userAvailableEnvs} + projectId={workspaceId} + projectSlug={currentWorkspace.slug} + sourceSecretPath={secretPath} + secrets={selectedEntries[EntryType.SECRET]} + onComplete={resetSelectedEntries} + /> void; + environments: WorkspaceEnv[]; + projectId: string; + projectSlug: string; + sourceSecretPath: string; + secrets: Record>; + onComplete: () => void; +}; + +type ContentProps = Omit; + +type OptionValue = { secretPath: string }; + +enum MoveResult { + Success = "success", + Info = "info", + Error = "error" +} + +type MoveResults = { + status: MoveResult; + name: string; + id: string; + message: string; +}[]; + +const Content = ({ + onComplete, + secrets, + projectSlug, + environments, + projectId, + sourceSecretPath +}: ContentProps) => { + const [search, setSearch] = useState(sourceSecretPath); + const [debouncedSearch] = useDebounce(search); + const [value, setValue] = useState({ secretPath: sourceSecretPath }); + const [previousValue, setPreviousValue] = useState(value); + const moveSecrets = useMoveSecrets(); + const [shouldOverwrite, setShouldOverwrite] = useState(false); + const { permission } = useProjectPermission(); + const [moveResults, setMoveResults] = useState(null); + + const { data, isPending, isLoading, isFetching } = useGetProjectSecretsQuickSearch({ + secretPath: "/", + environments: environments.map((env) => env.slug), + projectId, + search: debouncedSearch, + tags: {} + }); + + const { folders = {} } = data ?? {}; + + const folderEnvironments = value && folders[value.secretPath]?.map((folder) => folder.envId); + + const moveSecretsEligibility = useMemo(() => { + return Object.fromEntries( + environments.map((env) => [ + env.slug, + { + missingPermissions: permission.cannot( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment: env.slug, + secretPath: sourceSecretPath, + secretName: "*", + secretTags: ["*"] + }) + ), + missingPath: folderEnvironments && !folderEnvironments?.includes(env.id) + } + ]) + ); + }, [permission, folderEnvironments]); + + const destinationSelected = Boolean(value?.secretPath) && sourceSecretPath !== value?.secretPath; + + const environmentsToBeSkipped = useMemo(() => { + if (!destinationSelected) return []; + + const environmentWarnings: { type: "permission" | "missing"; message: string; id: string }[] = + []; + + environments.forEach((env) => { + if (moveSecretsEligibility[env.slug].missingPermissions) { + environmentWarnings.push({ + id: env.id, + type: "permission", + message: `${env.name}: You do not have permission to remove secrets from this environment` + }); + return; + } + + if (moveSecretsEligibility[env.slug].missingPath) { + environmentWarnings.push({ + id: env.id, + type: "missing", + message: `${env.name}: Secret path does not exist in environment` + }); + } + }); + + return environmentWarnings; + }, [moveSecretsEligibility]); + + const handleMoveSecrets = async () => { + if (!value) { + createNotification({ + text: "error", + title: "You must specify a secret path to move the selected secrets to" + }); + return; + } + + const results: MoveResults = []; + + const secretsByEnv: Record = Object.fromEntries( + environments.map((env) => [env.slug, []]) + ); + + Object.values(secrets).forEach((secretRecord) => + Object.entries(secretRecord).map(([env, secret]) => secretsByEnv[env].push(secret)) + ); + + // eslint-disable-next-line no-restricted-syntax + for await (const environment of environments) { + const envSlug = environment.slug; + + const secretsToMove = secretsByEnv[envSlug]; + + if ( + moveSecretsEligibility[envSlug].missingPermissions || + moveSecretsEligibility[envSlug].missingPath + ) { + // eslint-disable-next-line no-continue + continue; + } + + if (!secretsToMove.length) { + results.push({ + name: environment.name, + message: "No secrets selected in environment", + status: MoveResult.Info, + id: environment.id + }); + // eslint-disable-next-line no-continue + continue; + } + + try { + const { isDestinationUpdated, isSourceUpdated } = await moveSecrets.mutateAsync({ + projectSlug, + shouldOverwrite, + sourceEnvironment: environment.slug, + sourceSecretPath, + destinationEnvironment: environment.slug, + destinationSecretPath: value.secretPath, + projectId, + secretIds: secretsToMove.map((sec) => sec.id) + }); + + let message = ""; + let status: MoveResult = MoveResult.Info; + + if (isDestinationUpdated && isSourceUpdated) { + message = "Successfully moved selected secrets"; + status = MoveResult.Success; + } else if (isDestinationUpdated) { + message = + "Successfully created secrets in destination. A secret approval request has been generated for the source."; + } else if (isSourceUpdated) { + message = "A secret approval request has been generated in the destination"; + } else { + message = + "A secret approval request has been generated in both the source and the destination."; + } + + results.push({ + name: environment.name, + message, + status, + id: environment.id + }); + } catch (error) { + let errorMessage = (error as Error)?.message ?? "Failed to move secrets"; + if (axios.isAxiosError(error)) { + const { message } = error?.response?.data as { message: string }; + if (message) errorMessage = message; + } + + results.push({ + name: environment.name, + message: errorMessage, + status: MoveResult.Error, + id: environment.id + }); + } + } + + setMoveResults(results); + }; + + useEffect(() => { + return () => { + if (moveResults) onComplete(); + }; + }, [moveResults]); + + if (moveResults) { + return ( +
+
Results
+
+ {moveResults.map(({ id, name, status, message }) => { + let className: string; + let icon: IconDefinition; + + switch (status) { + case MoveResult.Success: + icon = faCheckCircle; + className = "text-green"; + break; + case MoveResult.Info: + icon = faInfoCircle; + className = "text-blue-500"; + break; + case MoveResult.Error: + default: + icon = faExclamationCircle; + className = "text-red"; + } + + return ( +
+ {name}:{" "} + {message} +
+ ); + })} +
+ + + +
+ ); + } + + if (moveSecrets.isPending) { + return ( +
+ +

Moving secrets...

+
+ ); + } + + return ( + <> + + ({ + secretPath + }))} + onMenuOpen={() => { + setPreviousValue(value); + setSearch(value?.secretPath ?? "/"); + setValue(null); + }} + onMenuClose={() => { + if (!value) setValue(previousValue); + }} + inputValue={search} + onInputChange={setSearch} + value={value} + onChange={(newValue) => { + setPreviousValue(value); + setValue(newValue as SingleValue); + }} + getOptionLabel={(option) => option.secretPath} + getOptionValue={(option) => option.secretPath} + /> + + {Boolean(environmentsToBeSkipped.length) && ( +
+ + The following environments will + not be affected + + {environmentsToBeSkipped.map((env) => ( +
+ + {env.message} +
+ ))} +
+ )} + + +

Overwrite Existing Secrets

+
+
+
+ + + + +
+ + ); +}; + +export const MoveSecretsModal = ({ isOpen, onOpenChange, ...props }: Props) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/index.ts b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/index.ts new file mode 100644 index 000000000..7d6328e39 --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/MoveSecretsDialog/index.ts @@ -0,0 +1 @@ +export * from "./MoveSecretsDialog"; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/index.ts b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/index.ts new file mode 100644 index 000000000..7d6328e39 --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/components/index.ts @@ -0,0 +1 @@ +export * from "./MoveSecretsDialog";