import { TypeOptions } from "react-toastify"; import { subject } from "@casl/ability"; import { faAngleDown, faAnglesRight, faCheckCircle, faChevronRight, faCodeCommit, faDownload, faEye, faEyeSlash, faFileImport, faFilter, faFingerprint, faFolder, faFolderPlus, faKey, faLock, faMinusSquare, faPaste, faPlus, faRotate, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import FileSaver from "file-saver"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2"; import { Button, DeleteActionModal, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, DropdownSubMenu, DropdownSubMenuContent, DropdownSubMenuTrigger, IconButton, Modal, ModalContent, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionDynamicSecretActions, ProjectPermissionSub, useSubscription, useWorkspace } from "@app/context"; import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; import { usePopUp } from "@app/hooks"; import { useCreateFolder, useCreateSecretBatch, useDeleteSecretBatch, useMoveSecrets, useUpdateSecretBatch } from "@app/hooks/api"; import { dashboardKeys, fetchDashboardProjectSecretsByKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; 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 { PopUpNames, usePopUpAction, useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; import { Filter, RowType } from "../../SecretMainPage.types"; import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; import { MoveSecretsModal } from "./MoveSecretsModal"; type TParsedEnv = Record; type TParsedFolderEnv = Record< string, Record >; type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv }; type Props = { // switch the secrets type as it gets decrypted after api call environment: string; // @depreciated will be moving all these details to zustand workspaceId: string; projectSlug: string; secretPath?: string; filter: Filter; tags?: WsTag[]; isVisible?: boolean; snapshotCount: number; isSnapshotCountLoading?: boolean; protectedBranchPolicyName?: string; onSearchChange: (term: string) => void; onToggleTagFilter: (tagId: string) => void; onVisibilityToggle: () => void; onToggleRowType: (rowType: RowType) => void; onClickRollbackMode: () => void; }; export const ActionBar = ({ environment, workspaceId, projectSlug, secretPath = "/", filter, tags = [], isVisible, snapshotCount, isSnapshotCountLoading, onSearchChange, onToggleTagFilter, onVisibilityToggle, onClickRollbackMode, onToggleRowType, protectedBranchPolicyName }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", "addSecretRotation", "moveSecrets", "misc", "upgradePlan", "replicateFolder", "confirmUpload" ] as const); const isProtectedBranch = Boolean(protectedBranchPolicyName); const { subscription } = useSubscription(); const { openPopUp } = usePopUpAction(); const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); const { mutateAsync: moveSecrets } = useMoveSecrets(); const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({ options: { onSuccess: undefined } }); const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({ options: { onSuccess: undefined } }); const queryClient = useQueryClient(); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const { currentWorkspace } = useWorkspace(); const handleFolderCreate = async (folderName: string, description: string | null) => { try { await createFolder({ name: folderName, path: secretPath, environment, projectId: workspaceId, description }); handlePopUpClose("addFolder"); createNotification({ type: "success", text: "Successfully created folder" }); } catch (error) { console.log(error); createNotification({ type: "error", text: "Failed to create folder" }); } }; const handleSecretDownload = async () => { try { const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({ workspaceId, expandSecretReferences: true, includeImports: true, environment, secretPath }); const secretsPicked = new Set(); const secretsToDownload: { key: string; value?: string; comment?: string }[] = []; localSecrets.forEach((el) => { secretsPicked.add(el.secretKey); secretsToDownload.push({ key: el.secretKey, value: el.secretValue, comment: el.secretComment }); }); for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) { for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) { const secret = localImportedSecrets[i].secrets[j]; if (!secretsPicked.has(secret.secretKey)) { secretsToDownload.push({ key: secret.secretKey, value: secret.secretValue, comment: secret.secretComment }); } secretsPicked.add(secret.secretKey); } } const file = secretsToDownload .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) .reduce( (prev, { key, comment, value }, index) => prev + (comment ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` : `${key}=${value}\n`), "" ); const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, `${environment}.env`); } catch (err) { if (err instanceof AxiosError) { const error = err?.response?.data as TApiErrors; if (error?.error === ApiErrorTypes.ForbiddenError && error.message.includes("readValue")) { createNotification({ title: "You don't have permission to download secrets", text: "You don't have permission to view one or more of the secrets in the current folder. Please contact your administrator.", type: "error" }); return; } } createNotification({ title: "Failed to download secrets", text: "Please try again later.", type: "error" }); } }; const handleSecretBulkDelete = async () => { const bulkDeletedSecrets = Object.values(selectedSecrets); try { await deleteBatchSecretV3({ secretPath, workspaceId, environment, secrets: bulkDeletedSecrets.map(({ key }) => ({ secretKey: key, type: SecretType.Shared })) }); resetSelectedSecret(); handlePopUpClose("bulkDeleteSecrets"); createNotification({ type: "success", text: "Successfully deleted secrets" }); } catch (error) { console.log(error); createNotification({ type: "error", text: "Failed to delete secrets" }); } }; const handleSecretsMove = async ({ destinationEnvironment, destinationSecretPath, shouldOverwrite }: { destinationEnvironment: string; destinationSecretPath: string; shouldOverwrite: boolean; }) => { try { const secretsToMove = Object.values(selectedSecrets); const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({ projectSlug, shouldOverwrite, sourceEnvironment: environment, sourceSecretPath: secretPath, destinationEnvironment, destinationSecretPath, projectId: workspaceId, secretIds: secretsToMove.map((sec) => sec.id) }); let notificationMessage = ""; let notificationType: TypeOptions = "info"; if (isDestinationUpdated && isSourceUpdated) { notificationMessage = "Successfully moved selected secrets"; notificationType = "success"; } else if (isDestinationUpdated) { notificationMessage = "Successfully created secrets in destination. A secret approval request has been generated for the source."; } else if (isSourceUpdated) { notificationMessage = "A secret approval request has been generated in the destination"; } else { notificationMessage = "A secret approval request has been generated in both the source and the destination."; } createNotification({ type: notificationType, text: notificationMessage }); resetSelectedSecret(); } catch (error) { console.error(error); } }; // Replicate Folder Logic const createSecretCount = Object.keys( (popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {} ).length; const updateSecretCount = Object.keys( (popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {} ).length; const isNonConflictingUpload = !updateSecretCount; const isSubmitting = isCreatingSecrets || isUpdatingSecrets; const handleParsedEnvMultiFolder = async (envByPath: TParsedFolderEnv) => { if (Object.keys(envByPath).length === 0) { createNotification({ type: "error", text: "Failed to find secrets" }); return; } try { const allUpdateSecrets: TParsedEnv = {}; const allCreateSecrets: TParsedEnv = {}; await Promise.all( Object.entries(envByPath).map(async ([folderPath, secrets]) => { // Normalize the path let normalizedPath = folderPath; // If the path is "/", use the current secretPath if (normalizedPath === "/") { normalizedPath = secretPath; } else { // Otherwise, concatenate with the current secretPath, avoiding double slashes const baseSecretPath = secretPath.endsWith("/") ? secretPath.slice(0, -1) : secretPath; // Remove leading slash from folder path if present to avoid double slashes const cleanFolderPath = folderPath.startsWith("/") ? folderPath.substring(1) : folderPath; normalizedPath = `${baseSecretPath}/${cleanFolderPath}`; } const secretFolderKeys = Object.keys(secrets); if (secretFolderKeys.length === 0) return; // Check which secrets already exist in this path const batchSize = 50; const secretBatches = Array.from( { length: Math.ceil(secretFolderKeys.length / batchSize) }, (_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize) ); const existingSecretLookup: Record = {}; const processBatches = async () => { await secretBatches.reduce(async (previous, batch) => { await previous; const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({ secretPath: normalizedPath, environment, projectId: workspaceId, keys: batch }); batchSecrets.forEach((secret) => { existingSecretLookup[secret.secretKey] = true; }); }, Promise.resolve()); }; await processBatches(); // Categorize each secret as update or create secretFolderKeys.forEach((secretKey) => { const secretData = secrets[secretKey]; // Store the path with the secret for later batch processing const secretWithPath = { ...secretData, secretPath: normalizedPath }; if (existingSecretLookup[secretKey]) { allUpdateSecrets[secretKey] = secretWithPath; } else { allCreateSecrets[secretKey] = secretWithPath; } }); }) ); handlePopUpOpen("confirmUpload", { update: allUpdateSecrets, create: allCreateSecrets }); } catch (e) { console.error(e); createNotification({ text: "Failed to check for secret conflicts", type: "error" }); handlePopUpClose("confirmUpload"); } }; const handleSaveFolderImport = async () => { const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt; try { // Group secrets by their path for batch operations const groupedCreateSecrets: Record< string, Array<{ type: SecretType; secretComment: string; secretValue: string; secretKey: string; }> > = {}; const groupedUpdateSecrets: Record< string, Array<{ type: SecretType; secretComment: string; secretValue: string; secretKey: string; }> > = {}; // Collect all unique paths that need folders to be created const allPaths = new Set(); // Add paths from create secrets Object.values(create || {}).forEach((secData) => { if (secData.secretPath && secData.secretPath !== secretPath) { allPaths.add(secData.secretPath); } }); // Create a map of folder paths to their folder name (last segment) const folderPaths = Array.from(allPaths).map((path) => { // Remove trailing slash if it exists const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path; // Split by '/' to get path segments const segments = normalizedPath.split("/"); // Get the last segment as the folder name const folderName = segments[segments.length - 1]; // Get the parent path (everything except the last segment) const parentPath = segments.slice(0, -1).join("/"); return { folderName, fullPath: normalizedPath, parentPath: parentPath || "/" }; }); // Sort paths by depth (shortest first) to ensure parent folders are created before children folderPaths.sort( (a, b) => (a.fullPath.match(/\//g) || []).length - (b.fullPath.match(/\//g) || []).length ); // Track created folders to avoid duplicates const createdFolders = new Set(); // Create all necessary folders in order using Promise.all and reduce await folderPaths.reduce(async (previousPromise, { folderName, fullPath, parentPath }) => { // Wait for the previous promise to complete await previousPromise; // Skip if we've already created this folder if (createdFolders.has(fullPath)) return Promise.resolve(); try { await createFolder({ name: folderName, path: parentPath, environment, projectId: workspaceId }); createdFolders.add(fullPath); } catch (err) { console.log(`Folder ${folderName} may already exist:`, err); } return Promise.resolve(); }, Promise.resolve()); if (Object.keys(create || {}).length > 0) { Object.entries(create).forEach(([secretKey, secData]) => { // Use the stored secretPath or fall back to the current secretPath const path = secData.secretPath || secretPath; if (!groupedCreateSecrets[path]) { groupedCreateSecrets[path] = []; } groupedCreateSecrets[path].push({ type: SecretType.Shared, secretComment: secData.comments.join("\n"), secretValue: secData.value, secretKey }); }); await Promise.all( Object.entries(groupedCreateSecrets).map(([path, secrets]) => createSecretBatch({ secretPath: path, workspaceId, environment, secrets }) ) ); } if (Object.keys(update || {}).length > 0) { Object.entries(update).forEach(([secretKey, secData]) => { // Use the stored secretPath or fall back to the current secretPath const path = secData.secretPath || secretPath; if (!groupedUpdateSecrets[path]) { groupedUpdateSecrets[path] = []; } groupedUpdateSecrets[path].push({ type: SecretType.Shared, secretComment: secData.comments.join("\n"), secretValue: secData.value, secretKey }); }); // Update secrets for each path in parallel await Promise.all( Object.entries(groupedUpdateSecrets).map(([path, secrets]) => updateSecretBatch({ secretPath: path, workspaceId, environment, secrets }) ) ); } // Invalidate appropriate queries to refresh UI queryClient.invalidateQueries({ queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) }); queryClient.invalidateQueries({ queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); // Close the modal and show notification handlePopUpClose("confirmUpload"); createNotification({ type: "success", text: isProtectedBranch ? "Uploaded changes have been sent for review" : "Successfully uploaded secrets" }); } catch (err) { console.log(err); createNotification({ type: "error", text: "Failed to upload secrets" }); } }; return ( <>
env.slug === environment)!]} projectId={workspaceId} tags={tags} />
!include).length) && "border-primary/50 text-primary" )} > Filter By { e.preventDefault(); onToggleRowType(RowType.Import); }} icon={filter?.include[RowType.Import] && } iconPos="right" >
Imports
{ e.preventDefault(); onToggleRowType(RowType.Folder); }} icon={filter?.include[RowType.Folder] && } iconPos="right" >
Folders
{ e.preventDefault(); onToggleRowType(RowType.DynamicSecret); }} icon={ filter?.include[RowType.DynamicSecret] && } iconPos="right" >
Dynamic Secrets
{ e.preventDefault(); onToggleRowType(RowType.SecretRotation); }} icon={ filter?.include[RowType.SecretRotation] && ( ) } iconPos="right" >
Secret Rotations
{ e.preventDefault(); onToggleRowType(RowType.Secret); }} icon={filter?.include[RowType.Secret] && } iconPos="right" >
Secrets
} > Tags Apply Tags to Filter Secrets {tags.map(({ id, slug, color }) => ( { evt.preventDefault(); onToggleTagFilter(slug); }} key={id} icon={filter?.tags[slug] && } iconPos="right" >
{slug}
))}
{isProtectedBranch && ( )}
{(isAllowed) => ( )}
{(isAllowed) => ( )} handlePopUpToggle("misc", isOpen)} >
{(isAllowed) => ( )} {(isAllowed) => ( )} {(isAllowed) => ( )} {(isAllowed) => ( )} {(isAllowed) => ( )}
{Object.keys(selectedSecrets).length} Selected
{(isAllowed) => ( )} {(isAllowed) => ( )}
{/* all the side triggers from actions like modals etc */} handlePopUpOpen("upgradePlan")} isOpen={popUp.addSecretImport.isOpen} onClose={() => handlePopUpClose("addSecretImport")} onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} /> handlePopUpToggle("addDynamicSecret", isOpen)} projectSlug={projectSlug} environments={[{ slug: environment, name: environment, id: "not-used" }]} secretPath={secretPath} isSingleEnvironmentMode /> handlePopUpToggle("addSecretRotation", isOpen)} /> handlePopUpToggle("addFolder", isOpen)} > handlePopUpToggle("bulkDeleteSecrets", isOpen)} onDeleteApproved={handleSecretBulkDelete} /> handlePopUpToggle("replicateFolder", isOpen)} onParsedEnv={handleParsedEnvMultiFolder} environment={environment} environments={currentWorkspace.environments} workspaceId={workspaceId} secretPath={secretPath} /> {subscription && ( handlePopUpToggle("upgradePlan", isOpen)} text={ subscription.slug === null ? "You can perform this action under an Enterprise license" : "You can perform this action if you switch to Infisical's Team plan" } /> )} handlePopUpToggle("confirmUpload", open)} > {isNonConflictingUpload ? "Upload" : "Overwrite"} , ]} > {isNonConflictingUpload ? (
Are you sure you want to import {createSecretCount} secret {createSecretCount > 1 ? "s" : ""} to this environment?
) : (
Your project already contains the following {updateSecretCount} secrets:
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {}) ?.map((key) => key) .join(", ")}
Are you sure you want to overwrite these secrets {createSecretCount > 0 ? ` and import ${createSecretCount} new one${createSecretCount > 1 ? "s" : ""}` : ""} ?
)}
); }; ActionBar.displayName = "ActionBar";