import { TypeOptions } from "react-toastify"; import { subject } from "@casl/ability"; import { faAngleDown, faAnglesRight, faCheckCircle, faChevronRight, faCodeCommit, faDownload, faEye, faEyeSlash, faFileImport, faFilter, faFingerprint, faFolder, faFolderPlus, faInfoCircle, faKey, faLock, 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 { Badge, Button, DeleteActionModal, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, DropdownSubMenu, DropdownSubMenuContent, DropdownSubMenuTrigger, IconButton, Modal, ModalClose, ModalContent, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionDynamicSecretActions, ProjectPermissionSub, useProject, useProjectPermission, useSubscription } from "@app/context"; import { ProjectPermissionCommitsActions, ProjectPermissionSecretActions, 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 { 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 } from "../../SecretMainPage.store"; import { Filter, RowType } from "../../SecretMainPage.types"; import { CollapsibleSecretImports } from "../SecretListView/CollapsibleSecretImports"; import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; import { MoveSecretsModal } from "./MoveSecretsModal"; type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[]; 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; secretPath?: string; filter: Filter; tags?: WsTag[]; isVisible?: boolean; isBatchMode?: boolean; snapshotCount: number; isSnapshotCountLoading?: boolean; protectedBranchPolicyName?: string; onSearchChange: (term: string) => void; onToggleTagFilter: (tagId: string) => void; onVisibilityToggle: () => void; onToggleRowType: (rowType: RowType) => void; onClickRollbackMode: () => void; usedBySecretSyncs?: UsedBySecretSyncs[]; importedBy?: { environment: { name: string; slug: string }; folders: { name: string; secrets?: { secretId: string; referencedSecretKey: string; referencedSecretEnv: string }[]; isImported: boolean; }[]; }[]; isPITEnabled: boolean; onRequestAccess: (actions: ProjectPermissionActions[]) => void; hasPathPolicies: boolean; onClearFilters: () => void; }; export const ActionBar = ({ environment, secretPath = "/", filter, tags = [], isVisible, isBatchMode, snapshotCount, isSnapshotCountLoading, onSearchChange, onToggleTagFilter, onVisibilityToggle, onClickRollbackMode, onToggleRowType, protectedBranchPolicyName, importedBy, isPITEnabled = false, usedBySecretSyncs, onRequestAccess, hasPathPolicies, onClearFilters }: Props) => { const { projectId, currentProject } = useProject(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", "addDynamicSecret", "addSecretImport", "bulkDeleteSecrets", "addSecretRotation", "moveSecrets", "misc", "upgradePlan", "replicateFolder", "confirmUpload", "requestAccess" ] 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 { addPendingChange } = useBatchModeActions(); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const { permission } = useProjectPermission(); 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, { projectId, environment, secretPath }); handlePopUpClose("addFolder"); return; } await createFolder({ name: folderName, path: secretPath, environment, projectId, 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({ projectId, 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, projectId, 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({ shouldOverwrite, sourceEnvironment: environment, sourceSecretPath: secretPath, destinationEnvironment, destinationSecretPath, projectId, 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 = new Set(); const processBatches = async () => { await secretBatches.reduce(async (previous, batch) => { await previous; const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({ secretPath: normalizedPath, environment, projectId, keys: batch }); batchSecrets.forEach((secret) => { existingSecretLookup.add(`${normalizedPath}-${secret.secretKey}`); }); }, 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, secretKey }; if (existingSecretLookup.has(`${normalizedPath}-${secretKey}`)) { allUpdateSecrets.push(secretWithPath); } else { allCreateSecrets.push(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 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 }); createdFolders.add(fullPath); } catch (err) { console.log(`Folder ${folderName} may already exist:`, err); } return Promise.resolve(); }, Promise.resolve()); if (create.length > 0) { create.forEach((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: secData.secretKey }); }); await Promise.all( Object.entries(groupedCreateSecrets).map(([path, secrets]) => createSecretBatch({ secretPath: path, projectId, environment, secrets }) ) ); } if (update.length > 0) { update.forEach((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: secData.secretKey }); }); // Update secrets for each path in parallel await Promise.all( Object.entries(groupedUpdateSecrets).map(([path, secrets]) => updateSecretBatch({ secretPath: path, projectId, environment, secrets }) ) ); } // Invalidate appropriate queries to refresh UI queryClient.invalidateQueries({ queryKey: secretKeys.getProjectSecret({ projectId, environment, secretPath }) }); queryClient.invalidateQueries({}); dashboardKeys.getDashboardSecrets({ projectId, secretPath }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ projectId }) }); // 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" }); } }; const isTableFiltered = Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean); const filteredTags = Object.values(filter?.tags ?? {}).filter(Boolean).length; return ( <>
env.slug === environment)!]} projectId={projectId} tags={tags} />
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
{Boolean(tags.length) && ( } >
Tags {Boolean(filteredTags) && {filteredTags} Applied}
Filter by Secret Tags
{tags.map(({ id, slug, color }) => ( { evt.preventDefault(); onToggleTagFilter(slug); }} key={id} icon={filter?.tags[slug] && } iconPos="right" >
{slug}
))} )}
{isTableFiltered && ( )}
{isProtectedBranch && ( )}
{(isAllowed) => ( )}
{hasPathPolicies ? ( ) : ( {(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={currentProject.slug} environments={[{ slug: environment, name: environment, id: "not-used" }]} secretPath={secretPath} isSingleEnvironmentMode /> handlePopUpToggle("addSecretRotation", isOpen)} /> handlePopUpToggle("addFolder", isOpen)} > handlePopUpToggle("bulkDeleteSecrets", isOpen)} onDeleteApproved={handleSecretBulkDelete} formContent={ ((importedBy && importedBy.length > 0) || (usedBySecretSyncs && usedBySecretSyncs?.length > 0)) && ( s.key)} usedBySecretSyncs={usedBySecretSyncs} /> ) } /> handlePopUpToggle("replicateFolder", isOpen)} onParsedEnv={handleParsedEnvMultiFolder} environment={environment} environments={currentProject.environments} projectId={projectId} 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:
{(popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update ?.map((sec) => sec.secretKey) .join(", ")}
Are you sure you want to overwrite these secrets {createSecretCount > 0 ? ` and import ${createSecretCount} new one${createSecretCount > 1 ? "s" : ""}` : ""} ?
)}
handlePopUpToggle("requestAccess", open)} >

You do not have permission to perform this action.

Request access to perform this action in this folder.

); }; ActionBar.displayName = "ActionBar";