import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowDown, faArrowUp, faFolderBlank, faFolderPlus, faList, faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import NavHeader from "@app/components/navigation/NavHeader"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, EmptyState, IconButton, Input, Modal, ModalContent, Table, TableContainer, TableSkeleton, TBody, Td, TFoot, Th, THead, Tooltip, Tr } from "@app/components/v2"; import { UpgradeProjectAlert } from "@app/components/v2/UpgradeProjectAlert"; import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, useProjectPermission, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useCreateFolder, useCreateSecretV3, useDeleteSecretV3, useGetDynamicSecretsOfAllEnv, useGetFoldersByEnv, useGetImportedSecretsAllEnvs, useGetProjectSecretsAllEnv, useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; import { SecretType, TSecretFolder } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSection"; import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; export enum EntryType { FOLDER = "folder", SECRET = "secret" } export const SecretOverviewPage = () => { const { t } = useTranslation(); const router = useRouter(); // this is to set expandable table width // coz when overflow the table goes to the right const parentTableRef = useRef(null); const [expandableTableWidth, setExpandableTableWidth] = useState(0); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const { permission } = useProjectPermission(); useEffect(() => { if (parentTableRef.current) { setExpandableTableWidth(parentTableRef.current.clientWidth); } }, [parentTableRef.current]); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); const { currentOrg } = useOrganization(); const workspaceId = currentWorkspace?.id as string; const projectSlug = currentWorkspace?.slug as string; const { data: latestFileKey } = useGetUserWsKey(workspaceId); const [searchFilter, setSearchFilter] = useState(""); const secretPath = (router.query?.secretPath as string) || "/"; const [selectedEntries, setSelectedEntries] = useState<{ [EntryType.FOLDER]: Record; [EntryType.SECRET]: Record; }>({ [EntryType.FOLDER]: {}, [EntryType.SECRET]: {} }); const toggleSelectedEntry = useCallback( (type: EntryType, key: string) => { const isChecked = Boolean(selectedEntries[type]?.[key]); const newChecks = { ...selectedEntries }; // remove selection if its present else add it if (isChecked) { delete newChecks[type][key]; } else { newChecks[type][key] = true; } setSelectedEntries(newChecks); }, [selectedEntries] ); const resetSelectedEntries = useCallback(() => { setSelectedEntries({ [EntryType.FOLDER]: {}, [EntryType.SECRET]: {} }); }, []); useEffect(() => { const handleParentTableWidthResize = () => { setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); }; const onRouteChangeStart = () => { resetSelectedEntries(); }; router.events.on("routeChangeStart", onRouteChangeStart); window.addEventListener("resize", handleParentTableWidthResize); return () => { window.removeEventListener("resize", handleParentTableWidthResize); router.events.off("routeChangeStart", onRouteChangeStart); }; }, []); useEffect(() => { if (!isWorkspaceLoading && !workspaceId && router.isReady) { router.push(`/org/${currentOrg?.id}/overview`); } }, [isWorkspaceLoading, workspaceId, router.isReady]); const userAvailableEnvs = currentWorkspace?.environments || []; const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs); useEffect(() => { setVisibleEnvs(userAvailableEnvs); }, [userAvailableEnvs]); const { data: secrets, getSecretByKey, secKeys, getEnvSecretKeyCount } = useGetProjectSecretsAllEnv({ workspaceId, envs: userAvailableEnvs.map(({ slug }) => slug), secretPath, decryptFileKey: latestFileKey! }); const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({ projectId: workspaceId, path: secretPath, environments: userAvailableEnvs.map(({ slug }) => slug) }); const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({ projectId: workspaceId, decryptFileKey: latestFileKey!, path: secretPath, environments: userAvailableEnvs.map(({ slug }) => slug) }); const { dynamicSecretNames, dynamicSecrets, isDynamicSecretPresentInEnv } = useGetDynamicSecretsOfAllEnv({ projectSlug, environmentSlugs: userAvailableEnvs.map(({ slug }) => slug), path: secretPath }); const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: updateFolderBatch } = useUpdateFolderBatch(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addSecretsInAllEnvs", "addFolder", "misc", "updateFolder" ] as const); const handleFolderCreate = async (folderName: string) => { const promises = userAvailableEnvs.map((env) => { const environment = env.slug; return createFolder({ name: folderName, path: secretPath, environment, projectId: workspaceId }); }); const results = await Promise.allSettled(promises); const isFoldersAdded = results.some((result) => result.status === "fulfilled"); if (isFoldersAdded) { handlePopUpClose("addFolder"); createNotification({ type: "success", text: "Successfully created folder" }); } else { createNotification({ type: "error", text: "Failed to create folder" }); } }; const handleFolderUpdate = async (newFolderName: string) => { const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; const updatedFolders: TUpdateFolderBatchDTO["folders"] = []; userAvailableEnvs.forEach((env) => { if ( permission.can( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) ) ) { const folder = getFolderByNameAndEnv(oldFolderName, env.slug); if (folder) { updatedFolders.push({ environment: env.slug, name: newFolderName, id: folder.id, path: secretPath }); } } }); if (updatedFolders.length === 0) { createNotification({ type: "info", text: "You don't have access to rename selected folder" }); handlePopUpClose("updateFolder"); return; } try { await updateFolderBatch({ projectSlug, folders: updatedFolders, projectId: workspaceId }); createNotification({ type: "success", text: "Successfully renamed folder across environments" }); } catch (err) { createNotification({ type: "error", text: "Failed to rename folder across environments" }); } finally { handlePopUpClose("updateFolder"); } }; const handleSecretCreate = async (env: string, key: string, value: string) => { try { // create folder if not existing if (secretPath !== "/") { // /hello/world -> [hello","world"] const pathSegment = secretPath.split("/").filter(Boolean); const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; const folderName = pathSegment.at(-1); if (folderName && parentPath) { await createFolder({ projectId: workspaceId, path: parentPath, environment: env, name: folderName }); } } await createSecretV3({ environment: env, workspaceId, secretPath, secretName: key, secretValue: value, secretComment: "", type: SecretType.Shared, latestFileKey: latestFileKey! }); createNotification({ type: "success", text: "Successfully created secret" }); } catch (error) { console.log(error); createNotification({ type: "error", text: "Failed to create secret" }); } }; const handleEnvSelect = (envId: string) => { if (visibleEnvs.map((env) => env.id).includes(envId)) { setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId)); } else { setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); } }; const handleSecretUpdate = async ( env: string, key: string, value: string, type = SecretType.Shared, secretId?: string ) => { try { await updateSecretV3({ environment: env, workspaceId, secretPath, secretId, secretName: key, secretValue: value, type, latestFileKey: latestFileKey! }); createNotification({ type: "success", text: "Successfully updated secret" }); } catch (error) { console.log(error); createNotification({ type: "error", text: "Failed to update secret" }); } }; const handleSecretDelete = async (env: string, key: string, secretId?: string) => { try { await deleteSecretV3({ environment: env, workspaceId, secretPath, secretName: key, secretId, type: SecretType.Shared }); createNotification({ type: "success", text: "Successfully deleted secret" }); } catch (error) { console.log(error); createNotification({ type: "error", text: "Failed to delete secret" }); } }; const handleResetSearch = () => setSearchFilter(""); const handleFolderClick = (path: string) => { router.push({ pathname: router.pathname, query: { ...router.query, secretPath: `${router.query?.secretPath || ""}/${path}` } }); }; const handleExploreEnvClick = async (slug: string) => { if (secretPath !== "/") { const pathSegment = secretPath.split("/").filter(Boolean); const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; const folderName = pathSegment.at(-1); console.log(folderName, parentPath); if (folderName && parentPath) { await createFolder({ projectId: workspaceId, environment: slug, path: parentPath, name: folderName }); } } const query: Record = { ...router.query, env: slug, searchFilter }; const envIndex = visibleEnvs.findIndex((el) => slug === el.slug); if (envIndex !== -1) { router.push({ pathname: "/project/[id]/secrets/[env]", query }); } }; if (isWorkspaceLoading) { return (
loading animation
); } const isTableLoading = !( folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading) ); const canViewOverviewPage = Boolean(userAvailableEnvs.length); // This is needed to also show imports from other paths – right now those are missing. // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; const filteredSecretNames = secKeys ?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase())) .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))); const filteredFolderNames = folderNames ?.filter((name) => name.toLowerCase().includes(searchFilter.toLowerCase())) .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))); const filteredDynamicSecrets = dynamicSecretNames ?.filter((name) => name.toLowerCase().includes(searchFilter.toLowerCase())) .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))); const isTableEmpty = !( folders?.every(({ isLoading }) => isLoading) && secrets?.every(({ isLoading }) => isLoading) && dynamicSecrets?.every(({ isLoading }) => isLoading) ) && filteredSecretNames?.length === 0 && filteredFolderNames?.length === 0 && filteredDynamicSecrets?.length === 0; return ( <>

Secrets Overview

Inject your secrets using Infisical CLI , Infisical API , Infisical SDKs , and more .

{currentWorkspace?.version === ProjectVersion.V1 && ( )}
{userAvailableEnvs.length > 0 && ( Choose visible environments {userAvailableEnvs.map((availableEnv) => { const { id: envId, name } = availableEnv; const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); return ( handleEnvSelect(envId)} key={envId} icon={ isEnvSelected ? ( ) : ( ) } iconPos="left" >
{name}
); })} {/* */}
)}
setSearchFilter(e.target.value)} leftIcon={} />
{userAvailableEnvs.length > 0 && (
{(isAllowed) => ( )} handlePopUpToggle("misc", isOpen)} >
{(isAllowed) => ( )}
)}
{visibleEnvs?.map(({ name, slug }, index) => { const envSecKeyCount = getEnvSecretKeyCount(slug); const missingKeyCount = secKeys.length - envSecKeyCount; return ( ); })} {canViewOverviewPage && isTableLoading && ( )} {userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && ( )} {userAvailableEnvs.length === 0 && ( )} {isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && ( )} {!isTableLoading && filteredFolderNames.map((folderName, index) => ( toggleSelectedEntry(EntryType.FOLDER, folderName)} environments={visibleEnvs} key={`overview-${folderName}-${index + 1}`} onClick={handleFolderClick} onToggleFolderEdit={(name: string) => handlePopUpOpen("updateFolder", { name }) } /> ))} {!isTableLoading && filteredDynamicSecrets.map((dynamicSecretName, index) => ( ))} {!isTableLoading && visibleEnvs?.length > 0 && filteredSecretNames.map((key, index) => ( toggleSelectedEntry(EntryType.SECRET, key)} secretPath={secretPath} getImportedSecretByKey={getImportedSecretByKey} isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onSecretCreate={handleSecretCreate} onSecretDelete={handleSecretDelete} onSecretUpdate={handleSecretUpdate} key={`overview-${key}-${index + 1}`} environments={visibleEnvs} secretKey={key} getSecretByKey={getSecretByKey} expandableColWidth={expandableTableWidth} /> ))} {visibleEnvs?.map(({ name, slug }) => ( ))}
Name setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))} >
{missingKeyCount > 0 && (
{missingKeyCount}
)}
handlePopUpToggle("addSecretsInAllEnvs", isOpen)} onClose={() => handlePopUpClose("addSecretsInAllEnvs")} decryptFileKey={latestFileKey!} /> handlePopUpToggle("addFolder", isOpen)} > handlePopUpToggle("updateFolder", isOpen)} > )?.name} onUpdateFolder={handleFolderUpdate} /> ); };