Merge pull request #3081 from Infisical/secrets-overview-page-move-secrets

Feature: Secrets Overview Page Move Secrets
This commit is contained in:
Scott Wilson
2025-02-05 08:54:06 -08:00
committed by GitHub
5 changed files with 442 additions and 14 deletions

View File

@@ -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 = {

View File

@@ -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"

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export * from "./MoveSecretsDialog";

View File

@@ -0,0 +1 @@
export * from "./MoveSecretsDialog";