From b650b142f7cf0232ebc7c4f2142eb27938b0bde9 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 1 Sep 2025 14:43:24 +0530 Subject: [PATCH] feat: union said - bring back overview page!! --- frontend/src/helpers/project.ts | 9 +- .../SecretManagerLayout.tsx | 8 +- .../components/VaultPlatformModal.tsx | 6 +- .../OverviewPage/OverviewPage.tsx | 1738 ++++++++++++++++- 4 files changed, 1690 insertions(+), 71 deletions(-) diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 787ff453d..7c9185556 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -72,15 +72,14 @@ export const getProjectBaseURL = (type: ProjectType) => { } }; +// @ts-expect-error akhilmhdh: will remove this later +// eslint-disable-next-line @typescript-eslint/no-unused-vars export const getProjectHomePage = (type: ProjectType, environments: WorkspaceEnv[]) => { switch (type) { case ProjectType.SecretManager: - if (environments.length > 0) - return `/projects/secret-management/$projectId/secrets/${environments[0].slug}`; - - return "/projects/secret-management/$projectId/overview"; + return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers"; + return "/projects/cert-management/$projectId/subscribers" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; default: diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index a11107a41..0584e8e9c 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -72,11 +72,7 @@ export const SecretManagerLayout = () => { { isSelected={ isActive || location.pathname.startsWith( - `/projects/secret-management/${currentWorkspace.id}/secrets` + `/projects/secret-management/${currentWorkspace.id}/overview` ) } > diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx index 74abe7fb8..137e765d2 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultPlatformModal.tsx @@ -2,20 +2,20 @@ import { Controller, useForm } from "react-hook-form"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input, Select, SelectItem, Tooltip } from "@app/components/v2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; -import { useImportVault } from "@app/hooks/api/migration/mutations"; -import { OrgPermissionCan } from "@app/components/permissions"; import { OrgGatewayPermissionActions, OrgPermissionSubjects } from "@app/context/OrgPermissionContext/types"; -import { useQuery } from "@tanstack/react-query"; import { gatewaysQueryKeys } from "@app/hooks/api"; +import { useImportVault } from "@app/hooks/api/migration/mutations"; type Props = { id?: string; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 2de6d12f3..910e07eac 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -1,35 +1,908 @@ -import { useEffect } from "react"; -import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; -import { useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { subject } from "@casl/ability"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faAngleDown, + faArrowDown, + faArrowLeft, + faArrowRight, + faArrowRightToBracket, + faArrowUp, + faFilter, + faFingerprint, + faFolder, + faFolderBlank, + faFolderPlus, + faKey, + faPlus, + faRotate +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; -import { Button, ContentLoader, EmptyState } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { getProjectHomePage } from "@app/helpers/project"; -import { usePopUp } from "@app/hooks"; -import { workspaceKeys } from "@app/hooks/api"; +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 { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal"; +import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal"; +import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal"; +import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials"; +import { + Button, + Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Lottie, + Modal, + ModalContent, + PageHeader, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + TFoot, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { HeaderResizer } from "@app/components/v2/HeaderResizer/HeaderResizer"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + ProjectPermissionActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { + useDebounce, + usePagination, + usePopUp, + useResetPageHelper, + useResizableHeaderHeight, + useToggle +} from "@app/hooks"; +import { + useCreateFolder, + useCreateSecretV3, + useDeleteSecretV3, + useGetImportedSecretsAllEnvs, + useGetWsTags, + useUpdateSecretV3 +} from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; +import { DashboardSecretsOrderBy, ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; +import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { + SecretType, + SecretV3RawSanitized, + TSecretFolder, + WorkspaceEnv +} from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; -import { AddEnvironmentModal } from "@app/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal"; +import { + useDynamicSecretOverview, + useFolderOverview, + useSecretOverview, + useSecretRotationOverview +} from "@app/hooks/utils"; +import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow"; +import { getHeaderStyle } from "@app/pages/secret-manager/OverviewPage/components/utils"; +import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm"; +import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm"; +import { + HIDDEN_SECRET_VALUE, + HIDDEN_SECRET_VALUE_API_MASK +} from "../SecretDashboardPage/components/SecretListView/SecretItem"; +import { CreateSecretForm } from "./components/CreateSecretForm"; +import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; +import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; +import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; +import { + SecretNoAccessOverviewTableRow, + SecretOverviewTableRow +} from "./components/SecretOverviewTableRow"; +import { SecretSearchInput } from "./components/SecretSearchInput"; +import { SecretTableResourceCount } from "./components/SecretTableResourceCount"; import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection"; +import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +enum RowType { + Folder = "folder", + DynamicSecret = "dynamic", + Secret = "secret", + SecretRotation = "rotation" +} + +type Filter = { + [key in RowType]: boolean; +}; + +const DEFAULT_FILTER_STATE = { + [RowType.Folder]: false, + [RowType.DynamicSecret]: false, + [RowType.Secret]: false, + [RowType.SecretRotation]: false +}; + +const DEFAULT_COLLAPSED_HEADER_HEIGHT = 120; export const OverviewPage = () => { + const { t } = useTranslation(); + + const router = useRouter(); + const navigate = useNavigate({ + from: ROUTE_PATHS.SecretManager.OverviewPage.path + }); + const routerSearch = useSearch({ + from: ROUTE_PATHS.SecretManager.OverviewPage.id, + select: (el) => ({ + secretPath: el.secretPath, + search: el.search + }) + }); + const [scrollOffset, setScrollOffset] = useState(0); + const [debouncedScrollOffset] = useDebounce(scrollOffset); + const { permission } = useProjectPermission(); + const tableRef = useRef(null); const { currentWorkspace } = useWorkspace(); const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addEnvironment"] as const); + const workspaceId = currentWorkspace?.id as string; + const projectSlug = currentWorkspace?.slug as string; + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(searchFilter); + const secretPath = (routerSearch?.secretPath as string) || "/"; + const { subscription } = useSubscription(); + const [collapseEnvironments, setCollapseEnvironments] = useToggle( + Boolean(localStorage.getItem("overview-collapse-environments")) + ); + + const handleToggleNarrowHeader = () => { + setCollapseEnvironments.toggle(); + if (collapseEnvironments) { + localStorage.removeItem("overview-collapse-environments"); + } else { + localStorage.setItem("overview-collapse-environments", "true"); + } + }; + + const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); + const [filterHistory, setFilterHistory] = useState< + Map + >(new Map()); + + const [selectedEntries, setSelectedEntries] = useState<{ + // selectedEntries[name/key][envSlug][resource] + [EntryType.FOLDER]: Record>; + [EntryType.SECRET]: Record>; + }>({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + + const { + offset, + limit, + orderDirection, + setOrderDirection, + setPage, + perPage, + page, + setPerPage, + orderBy + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, 100) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, newPerPage); + }; + + const resetSelectedEntries = useCallback(() => { + setSelectedEntries({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + }, []); useEffect(() => { - if (currentWorkspace.environments.length) { - navigate({ - to: getProjectHomePage(currentWorkspace.type, currentWorkspace.environments), - params: { - projectId: currentWorkspace.id - } + const onRouteChangeStart = () => { + resetSelectedEntries(); + }; + + const unsubscribeRouterEvent = router.subscribe("onLoad", onRouteChangeStart); + + return () => { + unsubscribeRouterEvent(); + }; + }, []); + + const userAvailableEnvs = currentWorkspace?.environments || []; + const userAvailableDynamicSecretEnvs = userAvailableEnvs.filter((env) => + permission.can( + ProjectPermissionDynamicSecretActions.CreateRootCredential, + subject(ProjectPermissionSub.DynamicSecrets, { + environment: env.slug, + secretPath, + metadata: ["*"] + }) + ) + ); + const userAvailableSecretRotationEnvs = userAvailableEnvs.filter((env) => + permission.can( + ProjectPermissionSecretRotationActions.Create, + subject(ProjectPermissionSub.SecretRotation, { + environment: env.slug, + secretPath + }) + ) + ); + + const [filteredEnvs, setFilteredEnvs] = useState([]); + const visibleEnvs = filteredEnvs.length ? filteredEnvs : userAvailableEnvs; + + const { + secretImports, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + getEnvImportedSecretKeyCount + } = useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (userAvailableEnvs || []).map(({ slug }) => slug) + }); + + const isFilteredByResources = Object.values(filter).some(Boolean); + const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: visibleEnvs.map((env) => env.slug), + secretPath, + orderDirection, + orderBy, + includeFolders: isFilteredByResources ? filter.folder : true, + includeDynamicSecrets: isFilteredByResources ? filter.dynamic : true, + includeSecrets: isFilteredByResources ? filter.secret : true, + includeImports: true, + includeSecretRotations: isFilteredByResources ? filter.rotation : true, + search: debouncedSearchFilter, + limit, + offset + }, + { enabled: isProjectV3 } + ); + + const { + secrets, + folders, + dynamicSecrets, + secretRotations, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount, + totalSecretRotationCount, + totalImportCount, + totalCount = 0, + totalUniqueFoldersInPage, + totalUniqueSecretsInPage, + totalUniqueSecretImportsInPage, + totalUniqueDynamicSecretsInPage, + totalUniqueSecretRotationsInPage, + importedByEnvs, + usedBySecretSyncs + } = overview ?? {}; + + const secretImportsShaped = secretImports + ?.flatMap(({ data }) => data) + .filter(Boolean) + .flatMap((item) => item?.secrets || []); + + const handleIsImportedSecretPresentInEnv = (envSlug: string, secretName: string) => { + if (secrets?.some((s) => s.key === secretName && s.env === envSlug)) { + return false; + } + if (secretImportsShaped.some((s) => s.key === secretName && s.sourceEnv === envSlug)) { + return true; + } + return isImportedSecretPresentInEnv(envSlug, secretName); + }; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = + useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { + secretRotationNames, + isSecretRotationPresentInEnv, + getSecretRotationByName, + getSecretRotationStatusesByName + } = useSecretRotationOverview(secretRotations); + + const { secKeys, getEnvSecretKeyCount } = useSecretOverview( + secrets?.concat(secretImportsShaped) || [] + ); + + const getSecretByKey = useCallback( + (env: string, key: string) => { + const sec = secrets?.find((s) => s.env === env && s.key === key); + return sec; + }, + [secrets] + ); + + const { data: tags } = useGetWsTags( + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" + ); + + 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", + "addDynamicSecret", + "addSecretRotation", + "editSecretRotation", + "rotateSecretRotation", + "viewSecretRotationGeneratedCredentials", + "deleteSecretRotation", + "upgradePlan" + ] as const); + + const handleFolderCreate = async (folderName: string, description: string | null) => { + const promises = userAvailableEnvs.map((env) => { + const environment = env.slug; + return createFolder({ + name: folderName, + path: secretPath, + environment, + projectId: workspaceId, + description + }); + }); + + 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, description: string | null) => { + const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; + + const updatedFolders: TUpdateFolderBatchDTO["folders"] = []; + userAvailableEnvs.forEach((env) => { + if ( + permission.can( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) + ) + ) { + const folder = getFolderByNameAndEnv(oldFolderName, env.slug); + if (folder) { + updatedFolders.push({ + environment: env.slug, + name: newFolderName, + id: folder.id, + path: secretPath, + description + }); + } + } + }); + + 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 { + 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); + const canCreateFolder = permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.SecretFolders, { + environment: env, + secretPath: parentPath + }) + ); + if (folderName && parentPath && canCreateFolder) { + await createFolder({ + projectId: workspaceId, + path: parentPath, + environment: env, + name: folderName + }); + } + } + const result = await createSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretValue: value, + secretComment: "", + type: SecretType.Shared + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + 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 (filteredEnvs.map((env) => env.id).includes(envId)) { + setFilteredEnvs(filteredEnvs.filter((env) => env.id !== envId)); + } else { + setFilteredEnvs(filteredEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); + } + }; + + const handleSecretUpdate = async ( + env: string, + key: string, + value: string, + secretValueHidden: boolean, + type = SecretType.Shared + ) => { + let secretValue: string | undefined = value; + + if ( + secretValueHidden && + (value === HIDDEN_SECRET_VALUE_API_MASK || value === HIDDEN_SECRET_VALUE) + ) { + secretValue = undefined; + } + + try { + const result = await updateSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretValue, + type + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + 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 { + const result = await deleteSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretId, + type: SecretType.Shared + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully deleted secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to delete secret" + }); + } + }; + + const handleResetSearch = (path: string) => { + const restore = filterHistory.get(path); + setFilter(restore?.filter ?? DEFAULT_FILTER_STATE); + const el = restore?.searchFilter ?? ""; + setSearchFilter(el); + setDebouncedSearchFilter(el); + }; + + const handleFolderClick = (path: string) => { + // store for breadcrumb nav to restore previously used filters + setFilterHistory((prev) => { + const curr = new Map(prev); + curr.set(secretPath, { filter, searchFilter }); + return curr; + }); + + navigate({ + search: (prev) => ({ + ...prev, + secretPath: `${routerSearch.secretPath === "/" ? "" : routerSearch.secretPath}/${path}` + }) + }).then(() => { + setFilter(DEFAULT_FILTER_STATE); + setSearchFilter(""); + setDebouncedSearchFilter(""); + }); + }; + + 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); + const canCreateFolder = permission.can( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.SecretFolders, { + environment: slug, + secretPath: parentPath + }) + ); + if (folderName && parentPath && canCreateFolder) { + await createFolder({ + projectId: workspaceId, + environment: slug, + path: parentPath, + name: folderName + }); + } + } + + const query: Record = { ...routerSearch, search: searchFilter }; + const envIndex = visibleEnvs.findIndex((el) => slug === el.slug); + if (envIndex !== -1) { + navigate({ + to: "/projects/secret-management/$projectId/secrets/$envSlug", + params: { + projectId: workspaceId, + envSlug: slug + }, + search: query + }); + } + }; + + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); + + const allRowsSelectedOnPage = useMemo(() => { + if (!secrets?.length && !folders?.length) return { isChecked: false, isIndeterminate: false }; + + if ( + (!secrets?.length || + secrets?.every((secret) => selectedEntries[EntryType.SECRET][secret.key])) && + (!folders?.length || + folders?.every((folder) => selectedEntries[EntryType.FOLDER][folder.name])) + ) + return { isChecked: true, isIndeterminate: false }; + + if ( + secrets?.some((secret) => selectedEntries[EntryType.SECRET][secret.key]) || + folders?.some((folder) => selectedEntries[EntryType.FOLDER][folder.name]) + ) + return { isChecked: true, isIndeterminate: true }; + + return { isChecked: false, isIndeterminate: false }; + }, [selectedEntries, secrets, folders]); + + 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] = {}; + userAvailableEnvs.forEach((env) => { + const resource = + type === EntryType.SECRET + ? getSecretByKey(env.slug, key) + : getFolderByNameAndEnv(key, env.slug); + + if (resource) newChecks[type][key][env.slug] = resource; + }); + } + + setSelectedEntries(newChecks); + }, + [selectedEntries, getFolderByNameAndEnv, getSecretByKey] + ); + + const toggleSelectAllRows = () => { + const newChecks = { ...selectedEntries }; + + userAvailableEnvs.forEach((env) => { + secrets?.forEach((secret) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[EntryType.SECRET][secret.key]; + } else { + if (!newChecks[EntryType.SECRET][secret.key]) + newChecks[EntryType.SECRET][secret.key] = {}; + + const resource = getSecretByKey(env.slug, secret.key); + + if (resource) newChecks[EntryType.SECRET][secret.key][env.slug] = resource; + } + }); + + folders?.forEach((folder) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[EntryType.FOLDER][folder.name]; + } else { + if (!newChecks[EntryType.FOLDER][folder.name]) + newChecks[EntryType.FOLDER][folder.name] = {}; + + const resource = getFolderByNameAndEnv(folder.name, env.slug); + + if (resource) newChecks[EntryType.FOLDER][folder.name][env.slug] = resource; + } + }); + }); + + setSelectedEntries(newChecks); + }; + + useEffect(() => { + if (routerSearch.search) { + const { search, ...query } = routerSearch; + // temp workaround until we transition state to query params + navigate({ + search: query + }); + setFilter(DEFAULT_FILTER_STATE); + setSearchFilter(routerSearch.search as string); + setDebouncedSearchFilter(routerSearch.search as string); + } + }, [routerSearch.search]); + + const selectedKeysCount = Object.keys(selectedEntries.secret).length; + + const secretsToDeleteKeys = useMemo(() => { + return Object.values(selectedEntries.secret).flatMap((entries) => + Object.values(entries).map((secret) => secret.key) + ); + }, [selectedEntries]); + + const filterAndMergeEnvironments = ( + envNames: string[], + envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[] + ): ProjectSecretsImportedBy[] => { + const environments = envs.filter((env) => envNames.includes(env.environment)); + + if (environments.length === 0) return []; + + const allImportedBy = environments.flatMap((env) => env.importedBy); + const groupedBySlug: Record = {}; + + allImportedBy.forEach((item) => { + const { slug } = item.environment; + if (!groupedBySlug[slug]) groupedBySlug[slug] = []; + groupedBySlug[slug].push(item); + }); + + const mergedImportedBy = Object.values(groupedBySlug).map((group) => { + const { environment } = group[0]; + const allFolders = group.flatMap((item) => item.folders); + + const foldersByName: Record = {}; + allFolders.forEach((folder) => { + if (!foldersByName[folder.name]) foldersByName[folder.name] = []; + foldersByName[folder.name].push(folder); + }); + + const mergedFolders = Object.entries(foldersByName).map(([name, foldersData]) => { + const isImported = foldersData.some((folder) => folder.isImported); + const allSecrets = foldersData.flatMap((folder) => folder.secrets || []); + + const uniqueSecrets: { + secretId: string; + referencedSecretKey: string; + referencedSecretEnv: string; + }[] = []; + const secretIds = new Set(); + + allSecrets + .filter( + (secret) => + !secretsToDeleteKeys || + secretsToDeleteKeys.length === 0 || + secretsToDeleteKeys.includes(secret.referencedSecretKey) + ) + .forEach((secret) => { + if (!secretIds.has(secret.secretId)) { + secretIds.add(secret.secretId); + uniqueSecrets.push(secret); + } + }); + + return { + name, + isImported, + ...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {}) + }; + }); + + return { + environment, + folders: mergedFolders.filter( + (folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0) + ) + }; + }); + + return mergedImportedBy; + }; + + const importedBy = useMemo(() => { + if (!importedByEnvs) return []; + if (selectedKeysCount === 0) { + return filterAndMergeEnvironments( + visibleEnvs.map(({ slug }) => slug), + importedByEnvs + ); + } + return filterAndMergeEnvironments( + Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)), + importedByEnvs + ); + }, [importedByEnvs, selectedEntries, selectedKeysCount]); + + const storedHeight = Number.parseInt( + localStorage.getItem("overview-header-height") ?? DEFAULT_COLLAPSED_HEADER_HEIGHT.toString(), + 10 + ); + const { headerHeight, handleMouseDown, isResizing } = useResizableHeaderHeight({ + initialHeight: Number.isNaN(storedHeight) ? DEFAULT_COLLAPSED_HEADER_HEIGHT : storedHeight, + minHeight: DEFAULT_COLLAPSED_HEADER_HEIGHT, + maxHeight: 288 + }); + + const debouncedHeaderHeight = useDebounce(headerHeight); + + useEffect(() => { + localStorage.setItem("overview-header-height", debouncedHeaderHeight.toString()); + }, [debouncedHeaderHeight]); + + if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { + return ( +
+ +
+ ); + } + + 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 isTableEmpty = totalCount === 0; + + const isTableFiltered = isFilteredByResources || filteredEnvs.length > 0; if (!isProjectV3) return ( @@ -37,46 +910,797 @@ export const OverviewPage = () => { ); - - if (currentWorkspace.environments.length) { - return ; - } - return ( -
- handlePopUpToggle("addEnvironment", isOpen)} - onComplete={async (env) => { - await queryClient.refetchQueries({ - queryKey: workspaceKeys.getWorkspaceById(currentWorkspace.id) - }); - - navigate({ - to: getProjectHomePage(currentWorkspace.type, [env]), - params: { - projectId: currentWorkspace.id +
+ + {t("common.head-title", { title: t("dashboard.title") })} + + + +
+
+ + Inject your secrets using + + Infisical CLI + + , + + Infisical API + + , + + Infisical SDKs + + , and + + more + + . Click the Explore button to view the secret details section. +

} - }); - }} - /> - -
- Create an environment to get started - + />
-
+
+ +
+ {isTableFiltered && ( + + )} + {userAvailableEnvs.length > 0 && ( + + + + + + {/* + + */} + Filter by Resource + + { + e.preventDefault(); + handleToggleRowType(RowType.Folder); + }} + icon={filter[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.DynamicSecret); + }} + icon={filter[RowType.DynamicSecret] && } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.SecretRotation); + }} + icon={ + filter[RowType.SecretRotation] && + } + iconPos="right" + > +
+ + Secret Rotations +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.Secret); + }} + icon={filter[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
+ Filter by Environment + {userAvailableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; + + const isEnvSelected = filteredEnvs.map((env) => env.id).includes(envId); + return ( + { + e.preventDefault(); + handleEnvSelect(envId); + }} + key={envId} + icon={isEnvSelected && } + iconPos="right" + > +
{name}
+
+ ); + })} +
+
+ )} + + {userAvailableEnvs.length > 0 && ( +
+ + handlePopUpToggle("misc", isOpen)} + > + + + + + + +
+ + {(isAllowed) => ( + + )} + + + + + + + +
+
+
+
+ )} +
+
+ +
+ setScrollOffset(e.currentTarget.scrollLeft)} + className="thin-scrollbar max-h-[66vh] overflow-y-auto rounded-b-none" + > + + + + + {visibleEnvs?.map(({ name, slug }, index) => { + const envSecKeyCount = getEnvSecretKeyCount(slug); + const importedSecKeyCount = getEnvImportedSecretKeyCount(slug); + const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount; + + const isLast = index === visibleEnvs.length - 1; + + return ( + + ); + })} + + {collapseEnvironments && ( + + )} + + + {canViewOverviewPage && isOverviewLoading && ( + + )} + {userAvailableEnvs.length === 0 && ( + + + + )} + {isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && ( + + + + )} + {!isOverviewLoading && visibleEnvs.length > 0 && ( + <> + {folderNamesAndDescriptions.map(({ name: folderName, description }, index) => ( + + toggleSelectedEntry(EntryType.FOLDER, folderName) + } + environments={visibleEnvs} + key={`overview-${folderName}-${index + 1}`} + onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name, description }) + } + /> + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secretRotationNames.map((secretRotationName, index) => ( + + handlePopUpOpen("editSecretRotation", secretRotation) + } + onRotate={(secretRotation) => + handlePopUpOpen("rotateSecretRotation", secretRotation) + } + onViewGeneratedCredentials={(secretRotation) => + handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation) + } + onDelete={(secretRotation) => + handlePopUpOpen("deleteSecretRotation", secretRotation) + } + /> + ))} + {secKeys.map((key, index) => ( + toggleSelectedEntry(EntryType.SECRET, key)} + secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} + isImportedSecretPresentInEnv={handleIsImportedSecretPresentInEnv} + onSecretCreate={handleSecretCreate} + onSecretDelete={handleSecretDelete} + onSecretUpdate={handleSecretUpdate} + key={`overview-${key}-${index + 1}`} + environments={visibleEnvs} + secretKey={key} + getSecretByKey={getSecretByKey} + scrollOffset={debouncedScrollOffset} + importedBy={importedBy} + /> + ))} + totalCount ? totalCount % perPage : perPage) - + (totalUniqueFoldersInPage || 0) - + (totalUniqueDynamicSecretsInPage || 0) - + (totalUniqueSecretsInPage || 0) - + (totalUniqueSecretImportsInPage || 0) - + (totalUniqueSecretRotationsInPage || 0), + 0 + )} + /> + + )} + + + + + {visibleEnvs?.map(({ name, slug }, i) => ( + + ))} + + +
+
+
+ 0 + ? `${ + !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" + } all folders and secrets on page` + : "" + } + > +
+ +
+
+ Name + + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } + > + + +
+ + + + + +
+
+ {name}

+ ) : ( + "" + ) + } + side="bottom" + sideOffset={-1} + align="end" + className="max-w-xl text-xs normal-case" + rootProps={{ + disableHoverableContent: true + }} + > +
+
+ + {!collapseEnvironments && missingKeyCount > 0 && ( + +
+ {missingKeyCount} +
+
+ )} +
+ +
+ + + + + +
+ + + +
+
+
+
+ {collapseEnvironments ? ( + + handleExploreEnvClick(slug)} + > + + + + ) : ( + + )} +
+
+
+ {!isOverviewLoading && totalCount > 0 && ( + + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )} +
+
+ handlePopUpToggle("addSecretsInAllEnvs", isOpen)} + > + e.preventDefault()} + > + handlePopUpClose("addSecretsInAllEnvs")} + /> + + + handlePopUpToggle("addFolder", isOpen)} + > + + + + + handlePopUpToggle("updateFolder", isOpen)} + > + + )?.name} + defaultDescription={ + (popUp.updateFolder?.data as Pick)?.description + } + onUpdateFolder={handleFolderUpdate} + showDescriptionOverwriteWarning + /> + + + handlePopUpToggle("addDynamicSecret", isOpen)} + projectSlug={projectSlug} + environments={userAvailableDynamicSecretEnvs} + 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("addSecretRotation", isOpen)} + /> + handlePopUpToggle("editSecretRotation", isOpen)} + /> + handlePopUpToggle("rotateSecretRotation", isOpen)} + /> + + handlePopUpToggle("viewSecretRotationGeneratedCredentials", isOpen) + } + /> + handlePopUpToggle("deleteSecretRotation", isOpen)} + />
); };