mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #3081 from Infisical/secrets-overview-page-move-secrets
Feature: Secrets Overview Page Move Secrets
This commit is contained in:
@@ -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<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
|
||||
secrets: Record<string, SecretV3RawSanitized[]>;
|
||||
dynamicSecrets: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
|
||||
dynamicSecrets: Record<string, TDashboardProjectSecretsQuickSearchResponse["dynamicSecrets"]>;
|
||||
};
|
||||
|
||||
export type TGetDashboardProjectSecretsQuickSearchDTO = {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
@@ -181,19 +185,46 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
|
||||
</Tooltip>
|
||||
<div className="ml-1 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||
{shouldShowDelete && (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<>
|
||||
<Tooltip content={areFoldersSelected ? "Moving folders is not supported" : undefined}>
|
||||
<div>
|
||||
<Button
|
||||
isDisabled={areFoldersSelected}
|
||||
variant="outline_bg"
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faAnglesRight} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("bulkMoveSecrets")}
|
||||
size="xs"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MoveSecretsModal
|
||||
isOpen={popUp.bulkMoveSecrets.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("bulkMoveSecrets", isOpen)}
|
||||
environments={userAvailableEnvs}
|
||||
projectId={workspaceId}
|
||||
projectSlug={currentWorkspace.slug}
|
||||
sourceSecretPath={secretPath}
|
||||
secrets={selectedEntries[EntryType.SECRET]}
|
||||
onComplete={resetSelectedEntries}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||
deleteKey="delete"
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SingleValue } from "react-select";
|
||||
import { subject } from "@casl/ability";
|
||||
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faBan,
|
||||
faCheckCircle,
|
||||
faExclamationCircle,
|
||||
faEyeSlash,
|
||||
faInfoCircle,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import axios from "axios";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Spinner,
|
||||
Switch
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useMoveSecrets } from "@app/hooks/api";
|
||||
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
|
||||
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
environments: WorkspaceEnv[];
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
sourceSecretPath: string;
|
||||
secrets: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
type ContentProps = Omit<Props, "isOpen" | "onOpenChange">;
|
||||
|
||||
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<OptionValue | null>({ secretPath: sourceSecretPath });
|
||||
const [previousValue, setPreviousValue] = useState<OptionValue | null>(value);
|
||||
const moveSecrets = useMoveSecrets();
|
||||
const [shouldOverwrite, setShouldOverwrite] = useState(false);
|
||||
const { permission } = useProjectPermission();
|
||||
const [moveResults, setMoveResults] = useState<MoveResults | null>(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<string, SecretV3RawSanitized[]> = 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 (
|
||||
<div className="w-full">
|
||||
<div className="mb-2">Results</div>
|
||||
<div className="mb-4 flex flex-col divide-y divide-mineshaft-600 rounded bg-mineshaft-900 px-3 py-2">
|
||||
{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 (
|
||||
<div key={id} className="p-2 text-sm">
|
||||
<FontAwesomeIcon className={twMerge(className, "mr-1")} icon={icon} /> {name}:{" "}
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ModalClose asChild>
|
||||
<Button size="sm" colorSchema="secondary" onClick={() => onComplete()}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (moveSecrets.isPending) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center py-2.5">
|
||||
<Spinner size="lg" className="text-mineshaft-500" />
|
||||
<p className="mt-4 text-sm text-mineshaft-400">Moving secrets...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
label="Select New Location"
|
||||
helperText="Nested folders will be displayed as secret path is typed"
|
||||
>
|
||||
<FilterableSelect
|
||||
isLoading={isPending || isLoading || isFetching || search !== debouncedSearch}
|
||||
options={Object.keys(folders).map((secretPath) => ({
|
||||
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<OptionValue>);
|
||||
}}
|
||||
getOptionLabel={(option) => option.secretPath}
|
||||
getOptionValue={(option) => option.secretPath}
|
||||
/>
|
||||
</FormControl>
|
||||
{Boolean(environmentsToBeSkipped.length) && (
|
||||
<div className="rounded bg-mineshaft-900 px-3 py-2">
|
||||
<span className="text-sm text-yellow">
|
||||
<FontAwesomeIcon icon={faWarning} className="mr-0.5" /> The following environments will
|
||||
not be affected
|
||||
</span>
|
||||
{environmentsToBeSkipped.map((env) => (
|
||||
<div
|
||||
key={env.id}
|
||||
className={`${env.type === "permission" ? "text-red" : "text-mineshaft-300"} mb-0.5 flex items-start gap-2 text-sm`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mt-1"
|
||||
icon={env.type === "permission" ? faBan : faEyeSlash}
|
||||
/>
|
||||
<span>{env.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormControl
|
||||
className="my-4"
|
||||
helperText={
|
||||
shouldOverwrite
|
||||
? "Secrets with conflicting keys at the destination will be overwritten"
|
||||
: "Secrets with conflicting keys at the destination will not be overwritten"
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-yellow/80"
|
||||
id="overwrite-existing-secrets"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
onCheckedChange={setShouldOverwrite}
|
||||
isChecked={shouldOverwrite}
|
||||
>
|
||||
<p className="w-[11rem]">Overwrite Existing Secrets</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
isDisabled={!destinationSelected}
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="secondary"
|
||||
onClick={handleMoveSecrets}
|
||||
>
|
||||
Move Secrets
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MoveSecretsModal = ({ isOpen, onOpenChange, ...props }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Move Secrets Folder Location"
|
||||
subTitle="Move the selected secrets across all environments to a new folder location"
|
||||
>
|
||||
<Content {...props} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MoveSecretsDialog";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MoveSecretsDialog";
|
||||
Reference in New Issue
Block a user