diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 48d536c14..a54bd5ccf 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -703,6 +703,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { // prevent older projects from accessing endpoint if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + // verify folder exists and user has project permission + await server.services.folder.getFolderByPath({ projectId, environment, secretPath }, req.permission); + const tags = req.query.tags?.split(",") ?? []; let remainingLimit = limit; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 90cc25710..c5eb0adde 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -30,6 +30,7 @@ import { TDeleteFolderDTO, TDeleteManyFoldersDTO, TGetFolderByIdDTO, + TGetFolderByPathDTO, TGetFolderDTO, TGetFoldersDeepByEnvsDTO, TUpdateFolderDTO, @@ -1398,6 +1399,31 @@ export const secretFolderServiceFactory = ({ }; }; + const getFolderByPath = async ( + { projectId, environment, secretPath }: TGetFolderByPathDTO, + actor: OrgServiceActor + ) => { + // folder check is allowed to be read by anyone + // permission is to check if user has access + await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new NotFoundError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + return folder; + }; + return { createFolder, updateFolder, @@ -1405,6 +1431,7 @@ export const secretFolderServiceFactory = ({ deleteFolder, getFolders, getFolderById, + getFolderByPath, getProjectFolderCount, getFoldersMultiEnv, getFoldersDeepByEnvs, diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index ae8e2c5dc..eed815da5 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = { idOrName: string; }>; }; + +export type TGetFolderByPathDTO = { + projectId: string; + environment: string; + secretPath: string; +}; diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index fa3ae2c70..1bbe9f6f1 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { reset(); onOpenChange(false); navigate({ - to: getProjectHomePage(project.type), + to: getProjectHomePage(project.type, project.environments), params: { projectId: project.id } }); } catch (err) { diff --git a/frontend/src/components/v2/EmptyState/EmptyState.tsx b/frontend/src/components/v2/EmptyState/EmptyState.tsx index 9816a3fe2..f6b926953 100644 --- a/frontend/src/components/v2/EmptyState/EmptyState.tsx +++ b/frontend/src/components/v2/EmptyState/EmptyState.tsx @@ -10,6 +10,7 @@ type Props = { children?: ReactNode; icon?: IconDefinition; iconSize?: SizeProp; + titleClassName?: string; }; export const EmptyState = ({ @@ -17,7 +18,8 @@ export const EmptyState = ({ className, children, icon = faCubesStacked, - iconSize = "2x" + iconSize = "2x", + titleClassName }: Props) => (
-
{title}
+
{title}
{children}
diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 47a5acd56..7ab8b0f3c 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -67,7 +67,7 @@ export const FilterableSelect = ({ }), menuPortal: (provided) => ({ ...provided, - zIndex: 9999 + zIndex: 99999 }) }} tabSelectsValue={tabSelectsValue} diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index cc180ffb6..b8ca27dc1 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -1,4 +1,4 @@ -import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; +import { DetailedHTMLProps, forwardRef, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; import { twMerge } from "tailwind-merge"; import { Skeleton } from "../Skeleton"; @@ -9,22 +9,20 @@ export type TableContainerProps = { className?: string; } & DetailedHTMLProps, HTMLDivElement>; -export const TableContainer = ({ - children, - className, - isRounded = true, - ...props -}: TableContainerProps): JSX.Element => ( -
- {children} -
+export const TableContainer = forwardRef( + ({ children, className, isRounded = true, ...props }, ref): JSX.Element => ( +
+ {children} +
+ ) ); // main parent table diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 3b04b263d..787ff453d 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -1,6 +1,6 @@ import { apiRequest } from "@app/config/request"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; -import { ProjectType } from "@app/hooks/api/workspace/types"; +import { ProjectType, WorkspaceEnv } from "@app/hooks/api/workspace/types"; const secretsToBeAdded = [ { @@ -72,9 +72,12 @@ export const getProjectBaseURL = (type: ProjectType) => { } }; -export const getProjectHomePage = (type: ProjectType) => { +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"; case ProjectType.CertificateManager: return "/projects/cert-management/$projectId/subscribers"; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index e748e3e5b..fde3a5ee3 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { apiRequest } from "@app/config/request"; import { @@ -273,6 +274,12 @@ export const useGetProjectSecretsDetails = ( ...options, // wait for all values to be available enabled: Boolean(projectId) && (options?.enabled ?? true), + retry: (count, error) => { + // don't retry 404s + if (error instanceof AxiosError && error.status === 404) return false; + + return count <= 5; + }, queryKey: dashboardKeys.getProjectSecretsDetails({ secretPath, search, diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 78c56394f..fbd5107cd 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit< DashboardProjectSecretsOverviewResponse, "secrets" | "secretRotations" > & { - secrets?: SecretV3RawSanitized[]; + secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[]; secretRotations?: (TSecretRotationV2 & { secrets: (SecretV3RawSanitized | null)[]; })[]; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7a0e3d2e2..3c7a6d30c 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -47,7 +47,8 @@ import { UpdateEnvironmentDTO, UpdatePitVersionLimitDTO, UpdateProjectDTO, - Workspace + Workspace, + WorkspaceEnv } from "./types"; export const fetchWorkspaceById = async (workspaceId: string) => { @@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => { export const useCreateWsEnvironment = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ workspaceId, name, slug }) => { - return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, { - name, - slug - }); + return useMutation({ + mutationFn: async ({ workspaceId, name, slug }) => { + const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>( + `/api/v1/workspace/${workspaceId}/environments`, + { + name, + slug + } + ); + return data.environment; }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/frontend/src/hooks/useResizableColWidth.tsx b/frontend/src/hooks/useResizableColWidth.tsx index f2ad80625..6af269217 100644 --- a/frontend/src/hooks/useResizableColWidth.tsx +++ b/frontend/src/hooks/useResizableColWidth.tsx @@ -1,12 +1,13 @@ -import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { MouseEvent, RefObject, useCallback, useEffect, useRef, useState } from "react"; type Params = { minWidth: number; maxWidth: number; initialWidth: number; + ref: RefObject; }; -export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Params) => { +export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth, ref }: Params) => { const [colWidth, setColWidth] = useState(initialWidth); const [isResizing, setIsResizing] = useState(false); const startX = useRef(0); @@ -63,6 +64,28 @@ export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Param }; }, [isResizing, handleMouseMove, handleMouseUp]); + useEffect(() => { + const element = ref?.current; + if (!element) return; + + const handleResize = () => { + if (colWidth > maxWidth) { + setColWidth(Math.max(maxWidth, minWidth)); + } else if (ref.current?.clientWidth && colWidth > ref.current.clientWidth * 0.9) { + // this else is a fallback to ensure col is always visible + setColWidth(initialWidth); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); + + // eslint-disable-next-line consistent-return + return () => { + resizeObserver.disconnect(); + }; + }, [ref, maxWidth, colWidth]); + return { colWidth, handleMouseDown, diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index e136df3f2..d77b41f5f 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -130,7 +130,11 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr const getEnvSecretKeyCount = useCallback( (env: string) => { - return secrets?.filter((secret) => secret.env === env).length ?? 0; + return ( + secrets?.filter((secret) => + secret.sourceEnv ? secret.sourceEnv === env : secret.env === env + ).length ?? 0 + ); }, [secrets] ); diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 2b4fcff80..995083ee7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => { }, { onSuccess: () => { - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage( + currentWorkspace.type, + currentWorkspace.environments + ); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index b66717a5c..ebc28a227 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -101,7 +101,7 @@ export const ProjectSelect = () => {
{ // to reproduce change this back to router.push and switch between two projects with different env count // look into this on dashboard revamp const url = linkOptions({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index ad31d9017..a11107a41 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -11,7 +11,7 @@ import { faVault } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; @@ -31,6 +31,7 @@ export const SecretManagerLayout = () => { const { t } = useTranslation(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; + const location = useLocation(); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId @@ -71,13 +72,27 @@ export const SecretManagerLayout = () => { {({ isActive }) => ( - +
diff --git a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx index a9f613584..7b066a099 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx @@ -48,7 +48,7 @@ import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api"; -import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; +import { ProjectType, Workspace, WorkspaceEnv } from "@app/hooks/api/workspace/types"; import { ProjectListToggle, ProjectListView @@ -152,13 +152,17 @@ export const AllProjectView = ({ type: projectTypeFilter }); - const handleAccessProject = async (type: ProjectType, projectId: string) => { + const handleAccessProject = async ( + type: ProjectType, + projectId: string, + environments: WorkspaceEnv[] + ) => { try { await orgAdminAccessProject.mutateAsync({ projectId }); await navigate({ - to: getProjectHomePage(type), + to: getProjectHomePage(type, environments), params: { projectId } @@ -315,7 +319,7 @@ export const AllProjectView = ({ onKeyDown={(evt) => { if (evt.key === "Enter" && workspace.isMember) { navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } @@ -325,7 +329,7 @@ export const AllProjectView = ({ onClick={() => { if (workspace.isMember) { navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } @@ -371,7 +375,7 @@ export const AllProjectView = ({ onClick={(e) => { e.stopPropagation(); e.preventDefault(); - handleAccessProject(workspace.type, workspace.id); + handleAccessProject(workspace.type, workspace.id, workspace.environments); }} disabled={ orgAdminAccessProject.variables?.projectId === workspace.id && diff --git a/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx index 8606aeb89..d63099273 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx @@ -193,7 +193,7 @@ export const MyProjectView = ({
{ navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } @@ -247,7 +247,7 @@ export const MyProjectView = ({
{ navigate({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index bcffb428f..7d155ba43 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -136,7 +136,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { text: "User privilege assumption has started" }); - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 19d6c4acc..e1da4edab 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -67,7 +67,7 @@ const Page = () => { type: "success", text: "Identity privilege assumption has started" }); - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 7bac32f75..7fadf2cb9 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -72,7 +72,7 @@ export const Page = () => { text: "User privilege assumption has started" }); - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 910e07eac..2de6d12f3 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -1,908 +1,35 @@ -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 { useEffect } from "react"; +import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; -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 { 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 { ProjectVersion } from "@app/hooks/api/workspace/types"; -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 { AddEnvironmentModal } from "@app/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal"; -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 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]: {} - }); - }, []); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addEnvironment"] as const); useEffect(() => { - 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) { + if (currentWorkspace.environments.length) { navigate({ - to: "/projects/secret-management/$projectId/secrets/$envSlug", + to: getProjectHomePage(currentWorkspace.type, currentWorkspace.environments), 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; + projectId: currentWorkspace.id } }); - - 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 ( @@ -910,797 +37,46 @@ export const OverviewPage = () => {
); + + if (currentWorkspace.environments.length) { + return ; + } + return ( -
- - {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. -

+
+ handlePopUpToggle("addEnvironment", isOpen)} + onComplete={async (env) => { + await queryClient.refetchQueries({ + queryKey: workspaceKeys.getWorkspaceById(currentWorkspace.id) + }); + + navigate({ + to: getProjectHomePage(currentWorkspace.type, [env]), + params: { + projectId: currentWorkspace.id } - /> -
-
- -
- {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" + }); + }} + /> + +
+ Create an environment to get started +
-
- 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)} - /> +
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 9241c2c6f..d79a107db 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -52,6 +52,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types"; import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { useCreateCommit } from "@app/hooks/api/secrets/mutations"; import { SecretV3RawSanitized } from "@app/hooks/api/types"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies"; import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; @@ -64,6 +65,8 @@ import { ActionBar } from "./components/ActionBar"; import { CommitForm } from "./components/CommitForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { DynamicSecretListView } from "./components/DynamicSecretListView"; +import { EnvironmentTabs } from "./components/EnvironmentTabs"; +import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; import { FolderListView } from "./components/FolderListView"; import { PitDrawer } from "./components/PitDrawer"; import { SecretDropzone } from "./components/SecretDropzone"; @@ -105,7 +108,7 @@ const Page = () => { const { permission } = useProjectPermission(); const { mutateAsync: createCommit } = useCreateCommit(); - const tableRef = useRef(null); + const tableRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const { isBatchMode, pendingChanges } = useBatchMode(); @@ -249,7 +252,8 @@ const Page = () => { const { data, isPending: isDetailsLoading, - isFetching: isDetailsFetching + isFetching: isDetailsFetching, + isFetched } = useGetProjectSecretsDetails({ environment, projectId: workspaceId, @@ -270,6 +274,18 @@ const Page = () => { tags: filter.tags }); + useEffect(() => { + // if switching tabs in a folder path that doesn't exist in a separate env we navigate to the root + if (!data && isFetched) { + navigate({ + search: (prev) => ({ + ...prev, + secretPath: "/" + }) + }); + } + }, [data, isFetched]); + const { imports, folders, @@ -491,7 +507,8 @@ const Page = () => { minWidth: 100, maxWidth: tableRef.current ? tableRef.current.clientWidth - 148 // ensure value column can't collapse completely - : 800 + : 800, + ref: tableRef }); useEffect(() => { @@ -710,12 +727,18 @@ const Page = () => { const mergedSecrets = getMergedSecretsWithPending(); const mergedFolders = getMergedFoldersWithPending(); + + if (!(currentWorkspace?.version === ProjectVersion.V3)) + return ( +
+ +
+ ); + return (
env.slug === environment)?.name ?? environment - } + title="Secrets Management" description={

Inject your secrets using @@ -759,6 +782,8 @@ const Page = () => { } /> + + {!isRollbackMode ? ( <> { workspaceId={workspaceId} secretPath={secretPath} onNavigateToFolder={handleResetFilter} + canNavigate={isFetched} /> )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/CompareEnvironments.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/CompareEnvironments.tsx new file mode 100644 index 000000000..c3570bf5a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/CompareEnvironments.tsx @@ -0,0 +1,595 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { MultiValue } from "react-select"; +import { + faArrowDown, + faArrowUp, + faCheckCircle, + faFilter, + faFingerprint, + faFolder, + faKey, + faRotate, + faSearch, + faWarning +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + FilterableSelect, + FormLabel, + IconButton, + Input, + Lottie, + Pagination, + Table, + TableContainer, + TBody, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { useDebounce, usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetImportedSecretsAllEnvs } from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard"; +import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; +import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; +import { + useDynamicSecretOverview, + useFolderOverview, + useSecretOverview, + useSecretRotationOverview +} from "@app/hooks/utils"; +import { SecretTableResourceCount } from "@app/pages/secret-manager/OverviewPage/components/SecretTableResourceCount"; + +import { DynamicSecretRow } from "./components/DynamicSecretRow"; +import { FolderRow } from "./components/FolderRow"; +import { SecretRotationRow } from "./components/SecretRotationRow"; +import { SecretNoAccessRow, SecretRow } from "./components/SecretRow"; + +type Props = { + secretPath: string; +}; + +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 COL_WIDTH_OFFSET = 220; + +export const CompareEnvironments = ({ secretPath }: Props) => { + const { currentWorkspace } = useWorkspace(); + const compareEnvironmentsKey = `compare-environments-${currentWorkspace.id}`; + + const [selectedEnvironments, setSelectedEnvironments] = useState(() => { + try { + const storedEnvironments = JSON.parse(localStorage.getItem(compareEnvironmentsKey) ?? "[]"); + + if (Array.isArray(storedEnvironments) && storedEnvironments.length > 0) { + const potentialEnvs: string[] = []; + storedEnvironments.forEach((env) => { + if (typeof env === "string") { + potentialEnvs.push(env); + } + }); + + return currentWorkspace.environments.filter((env) => potentialEnvs.includes(env.id)); + } + } catch { + // do nothing and proceed + } + return currentWorkspace.environments.slice(0, 2); + }); + + const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); + + const { + offset, + limit, + orderDirection, + setOrderDirection, + setPage, + perPage, + page, + setPerPage, + orderBy + } = usePagination(DashboardSecretsOrderBy.Name, { + initPerPage: getUserTablePreference("secretCompareTable", PreferenceKey.PerPage, 50) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("secretCompareTable", PreferenceKey.PerPage, newPerPage); + }; + + const workspaceId = currentWorkspace.id; + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearchFilter] = useDebounce(searchFilter); + const [debouncedSelectedEnvironments] = useDebounce(selectedEnvironments); + + useEffect(() => { + localStorage.setItem( + compareEnvironmentsKey, + JSON.stringify(selectedEnvironments.map((env) => env.id)) + ); + }, [debouncedSelectedEnvironments]); + + const { + secretImports, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + getEnvImportedSecretKeyCount + } = useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (currentWorkspace.environments || []).map(({ slug }) => slug) + }); + + const compareEnvironments = selectedEnvironments.length + ? selectedEnvironments + : currentWorkspace.environments; + + const isFilteredByResources = Object.values(filter).some(Boolean); + const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: compareEnvironments.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: Boolean(compareEnvironments.length) } + ); + + const { + secrets, + folders, + dynamicSecrets, + secretRotations, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount, + totalSecretRotationCount, + totalCount = 0, + totalUniqueFoldersInPage, + totalUniqueSecretsInPage, + totalUniqueSecretImportsInPage, + totalUniqueDynamicSecretsInPage, + totalUniqueSecretRotationsInPage + } = 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, isFolderPresentInEnv } = useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { secretRotationNames, isSecretRotationPresentInEnv, getSecretRotationByName } = + 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 [tableWidth, setTableWidth] = useState(0); + const tableRef = useRef(null); + + const { handleMouseDown, isResizing, colWidth } = useResizableColWidth({ + initialWidth: 320, + minWidth: 160, + maxWidth: tableRef.current + ? tableRef.current.clientWidth - COL_WIDTH_OFFSET // ensure value column can't collapse completely + : 800, + ref: tableRef + }); + + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); + + const isTableEmpty = totalCount === 0; + + const isTableFiltered = isFilteredByResources; + + useEffect(() => { + const element = tableRef.current; + if (!element) return; + + const handleResize = () => { + setTableWidth(element.clientWidth - 1); + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); + + // eslint-disable-next-line consistent-return + return () => { + resizeObserver.disconnect(); + }; + }, [tableRef]); + + return ( + // scott: this is reverse to fix z-indexing bug of dropdown with sticky table cols; couldn't resolve with flex-col +

+ {!isOverviewLoading && totalCount > 0 && ( + + } + className="rounded-b-lg border border-solid border-mineshaft-500 bg-mineshaft-700" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> + )} +
+ + {/* eslint-disable-next-line no-nested-ternary */} + {isOverviewLoading ? ( +
+ +
+ ) : isTableEmpty ? ( + + ) : ( + + + + + {compareEnvironments?.map(({ name, slug }, index) => { + const envSecKeyCount = getEnvSecretKeyCount(slug); + const importedSecKeyCount = getEnvImportedSecretKeyCount(slug); + const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount; + + return ( + + ); + })} + + + + {folderNamesAndDescriptions.map(({ name: folderName }, index) => ( + + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secretRotationNames.map((secretRotationName, index) => ( + + ))} + {secKeys.map((key, index) => ( + + ))} + totalCount ? totalCount % perPage : perPage) - + (totalUniqueFoldersInPage || 0) - + (totalUniqueDynamicSecretsInPage || 0) - + (totalUniqueSecretsInPage || 0) - + (totalUniqueSecretImportsInPage || 0) - + (totalUniqueSecretRotationsInPage || 0), + 0 + )} + /> + +
+
+
+
+
+
+
+ Name + + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } + > + + +
+
+
+
+ {name} + {missingKeyCount > 0 && ( + + {missingKeyCount} secret{missingKeyCount > 1 ? "s" : ""} missing + compared to other environments on this page + + } + > + + + {missingKeyCount} + + + )} +
+
+ )} +
+
+
+ setSearchFilter(e.target.value)} + className="h-full flex-1" + placeholder="Search by resource name..." + leftIcon={} + containerClassName="h-10" + /> + {isTableFiltered && ( + + )} + {compareEnvironments.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 +
+
+
+
+ )} +
+
+ + { + const selected = value as MultiValue; + + setSelectedEnvironments((selected as WorkspaceEnv[]) ?? []); + }} + placeholder="Leave blank to compare all environments" + options={currentWorkspace.environments} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + isMulti + /> +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/DynamicSecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/DynamicSecretRow.tsx new file mode 100644 index 000000000..16de6533b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/DynamicSecretRow.tsx @@ -0,0 +1,41 @@ +import { faFingerprint } from "@fortawesome/free-solid-svg-icons"; + +import { Tr } from "@app/components/v2"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + dynamicSecretName: string; + environments: { name: string; slug: string }[]; + isDynamicSecretInEnv: (name: string, env: string) => boolean; + colWidth: number; +}; + +export const DynamicSecretRow = ({ + dynamicSecretName, + environments = [], + isDynamicSecretInEnv, + colWidth +}: Props) => { + return ( + + + {environments.map(({ slug }, i) => { + const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug); + + return ( + + ); + })} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/index.tsx new file mode 100644 index 000000000..5c7bf445a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/DynamicSecretRow/index.tsx @@ -0,0 +1 @@ +export { DynamicSecretRow } from "./DynamicSecretRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/FolderRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/FolderRow.tsx new file mode 100644 index 000000000..a3d672e13 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/FolderRow.tsx @@ -0,0 +1,41 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; + +import { Tr } from "@app/components/v2"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + folderName: string; + environments: { name: string; slug: string }[]; + isFolderPresentInEnv: (name: string, env: string) => boolean; + colWidth: number; +}; + +export const FolderRow = ({ + folderName, + environments = [], + isFolderPresentInEnv, + colWidth +}: Props) => { + return ( + + + {environments.map(({ slug }, i) => { + const isPresent = isFolderPresentInEnv(folderName, slug); + + return ( + + ); + })} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/index.tsx new file mode 100644 index 000000000..171b2e596 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/FolderRow/index.tsx @@ -0,0 +1 @@ +export { FolderRow } from "./FolderRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/SecretRotationRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/SecretRotationRow.tsx new file mode 100644 index 000000000..d5c938e9b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/SecretRotationRow.tsx @@ -0,0 +1,179 @@ +import { faEye, faEyeSlash, faInfoCircle, faRotate } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { useToggle } from "@app/hooks"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + secretRotationName: string; + environments: { name: string; slug: string }[]; + isSecretRotationInEnv: (name: string, env: string) => boolean; + getSecretRotationByName: (slug: string, name: string) => TSecretRotationV2 | undefined; + colWidth: number; + tableWidth: number; +}; + +export const SecretRotationRow = ({ + secretRotationName, + environments = [], + isSecretRotationInEnv, + colWidth, + getSecretRotationByName, + tableWidth +}: Props) => { + const [isExpanded, setIsExpanded] = useToggle(false); + const [isSecretVisible, setIsSecretVisible] = useToggle(); + + const totalCols = environments.length + 1; // secret key row + + return ( + <> + + + {environments.map(({ slug }, i) => { + const isPresent = isSecretRotationInEnv(secretRotationName, slug); + + return ( + + ); + })} + + {isExpanded && + environments.map(({ name: envName, slug }) => { + const secretRotation = getSecretRotationByName(slug, secretRotationName); + + if (!secretRotation) return null; + + const { type, secrets, description } = secretRotation; + + const { name: rotationType, image } = SECRET_ROTATION_MAP[type]; + + return ( + + +
+
+
+
+ {envName} + + {`${rotationType} + {rotationType} + + {description && ( + + + + )} +
+
+ + setIsSecretVisible.toggle()} + > + + + +
+ + + + {secrets.map((secret, index) => { + return ( + + + + + + + ); + })} + +
+
+ + {secret?.key ?? "********"} + +
+
+ {/* eslint-disable-next-line no-nested-ternary */} + {!secret ? ( +
********
+ ) : secret.secretValueHidden ? ( + + ) : ( + {}} + /> + )} +
+
+
+ + + ); + })} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/index.tsx new file mode 100644 index 000000000..a0b2fcfa7 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRotationRow/index.tsx @@ -0,0 +1 @@ +export { SecretRotationRow } from "./SecretRotationRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/EnvironmentSecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/EnvironmentSecretRow.tsx new file mode 100644 index 000000000..87db191f6 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/EnvironmentSecretRow.tsx @@ -0,0 +1,49 @@ +import { faEyeSlash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; + +type Props = { + defaultValue?: string | null; + isOverride?: boolean; + isVisible?: boolean; + isImportedSecret: boolean; + environment: string; + secretValueHidden: boolean; + secretPath: string; +}; + +export const EnvironmentSecretRow = ({ + defaultValue, + isOverride, + isImportedSecret, + secretValueHidden, + environment, + secretPath, + isVisible +}: Props) => { + return ( +
+ {secretValueHidden && !isOverride && ( + + + + )} +
+ {}} + isReadOnly + value={defaultValue as string} + key="secret-input" + isVisible={isVisible && !secretValueHidden} + secretPath={secretPath} + environment={environment} + isImport={isImportedSecret} + defaultValue={secretValueHidden ? "" : undefined} + canEditButNotView={secretValueHidden && !isOverride} + /> +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretNoAccessRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretNoAccessRow.tsx new file mode 100644 index 000000000..075726d1b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretNoAccessRow.tsx @@ -0,0 +1,44 @@ +import { faLock } from "@fortawesome/free-solid-svg-icons"; + +import { Tr } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; + +import { EnvironmentStatusCell, ResourceNameCell } from "../shared"; + +type Props = { + environments: { name: string; slug: string }[]; + count: number; + colWidth: number; +}; + +export const SecretNoAccessRow = ({ environments = [], count, colWidth }: Props) => { + return ( + <> + {Array.from(Array(count)).map((_, j) => ( + + } + iconClassName="text-bunker-400" + icon={faLock} + colWidth={colWidth} + tooltipContent="You do not have permission to view this secret" + /> + {environments.map(({ slug }, i) => { + return ( + + ); + })} + + ))} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretRow.tsx new file mode 100644 index 000000000..095b50356 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/SecretRow.tsx @@ -0,0 +1,231 @@ +import { subject } from "@casl/ability"; +import { + faCodeBranch, + faEye, + faEyeSlash, + faFileImport, + faKey, + faRotate +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { useToggle } from "@app/hooks"; +import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; + +import { EnvironmentStatus, EnvironmentStatusCell, ResourceNameCell } from "../shared"; +import { EnvironmentSecretRow } from "./EnvironmentSecretRow"; + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; + isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; + getImportedSecretByKey: ( + env: string, + secretName: string + ) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined; + colWidth: number; + tableWidth: number; +}; + +export const SecretRow = ({ + secretKey, + environments = [], + secretPath, + getSecretByKey, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + colWidth, + tableWidth +}: Props) => { + const [isFormExpanded, setIsFormExpanded] = useToggle(); + const totalCols = environments.length + 1; // secret key row + const [isSecretVisible, setIsSecretVisible] = useToggle(); + + const { permission } = useProjectPermission(); + + const getDefaultValue = ( + secret: SecretV3RawSanitized | undefined, + importedSecret: { secret?: SecretV3RawSanitized } | undefined + ) => { + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment: secret?.env || "", + secretPath: secret?.path || "", + secretName: secret?.key || "", + secretTags: ["*"] + }) + ); + + if (secret?.secretValueHidden && !secret?.valueOverride) { + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; + } + return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || ""; + }; + + return ( + <> + setIsFormExpanded.toggle()} + className="group border-mineshaft-500" + > + + {environments.map(({ slug }, i) => { + const secret = getSecretByKey(slug, secretKey); + + const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey); + + const isSecretPresent = Boolean(secret); + const isSecretEmpty = secret?.value === ""; + + let status: EnvironmentStatus; + + if (isSecretEmpty) { + status = "empty"; + } else if (isSecretPresent) { + status = "present"; + } else if (isSecretImported) { + status = "imported"; + } else { + status = "missing"; + } + + return ( + + ); + })} + + {isFormExpanded && ( + + +
+ + + + + + +
+ + setIsSecretVisible.toggle()} + > + + + +
+ + + + {environments.map(({ name, slug }) => { + const secret = getSecretByKey(slug, secretKey); + + const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey); + const importedSecret = getImportedSecretByKey(slug, secretKey); + + return ( + + + + + ); + })} + +
+ Environment + + Value +
+
+ {name} + {isImportedSecret && ( + + + + )} + {secret?.isRotatedSecret && ( + + + + )} + {secret?.valueOverride && ( + + + + )} +
+
+ +
+
+
+ + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/index.tsx new file mode 100644 index 000000000..a1e4a3d51 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/SecretRow/index.tsx @@ -0,0 +1,2 @@ +export { SecretNoAccessRow } from "./SecretNoAccessRow"; +export { SecretRow } from "./SecretRow"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/EnvironmentStatusCell.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/EnvironmentStatusCell.tsx new file mode 100644 index 000000000..fa5d2f8bb --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/EnvironmentStatusCell.tsx @@ -0,0 +1,75 @@ +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faCircle } from "@fortawesome/free-regular-svg-icons"; +import { faBan, faCheck, faFileImport, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Td, Tooltip } from "@app/components/v2"; + +export type EnvironmentStatus = "present" | "missing" | "empty" | "imported" | "no-access"; + +type Props = { + isLast: boolean; + status: EnvironmentStatus; +}; + +export const EnvironmentStatusCell = ({ isLast, status }: Props) => { + let tooltipContent: string; + let icon: IconDefinition; + let iconClassName: string; + + switch (status) { + case "present": + tooltipContent = "Present in environment"; + icon = faCheck; + iconClassName = "h-3 w-3"; + break; + case "missing": + tooltipContent = "Missing from environment"; + icon = faXmark; + iconClassName = "h-3.5 w-3.5"; + break; + case "empty": + tooltipContent = "Empty value in environment"; + icon = faCircle; + iconClassName = "h-3 w-3"; + break; + case "imported": + tooltipContent = "Imported into environment"; + icon = faFileImport; + iconClassName = "h-3 w-3"; + break; + case "no-access": + tooltipContent = "You do not have permission to view this secret"; + icon = faBan; + iconClassName = "h-3 w-3"; + break; + default: + throw new Error(`Unhandled environment status: ${status as string}`); + } + + return ( + +
+
+ + + +
+
+ + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/ResourceNameCell.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/ResourceNameCell.tsx new file mode 100644 index 000000000..079b563d5 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/ResourceNameCell.tsx @@ -0,0 +1,47 @@ +import { ReactElement } from "react"; +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faAngleDown } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Td, Tooltip } from "@app/components/v2"; + +type Props = { + isRowExpanded?: boolean; + label: ReactElement | string; + icon: IconDefinition; + iconClassName?: string; + colWidth: number; + tooltipContent?: string; +}; + +export const ResourceNameCell = ({ + isRowExpanded, + label, + icon, + iconClassName, + colWidth, + tooltipContent +}: Props) => { + return ( + + +
+
+ +
+ {typeof label === "string" ? {label} : label} +
+
+ + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/index.ts b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/index.ts new file mode 100644 index 000000000..c8bd53cbe --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/components/shared/index.ts @@ -0,0 +1,2 @@ +export * from "./EnvironmentStatusCell"; +export * from "./ResourceNameCell"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/index.tsx new file mode 100644 index 000000000..f5c997972 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CompareEnvironments/index.tsx @@ -0,0 +1 @@ +export * from "./CompareEnvironments"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx new file mode 100644 index 000000000..66fd3a878 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import { faArrowRightArrowLeft, faEllipsisH, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + Modal, + ModalContent, + Tab, + TabList, + Tabs, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { workspaceKeys } from "@app/hooks/api"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; +import { AddEnvironmentModal } from "@app/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal"; + +import { CompareEnvironments } from "../CompareEnvironments"; + +const COMPARE_ENVIRONMENT_TAB = "__COMPARE_ENVIRONMENT_TAB__"; +const ADD_ENVIRONMENT_TAB = "__ADD_ENVIRONMENT_TAB__"; +const VIEW_MORE_ENVIRONMENT_TAB = "__VIEW_MORE_ENVIRONMENT_TAB__"; + +type Props = { + secretPath: string; +}; + +const TABS_TO_SHOW = 5; + +export const EnvironmentTabs = ({ secretPath }: Props) => { + const { currentWorkspace } = useWorkspace(); + const currentEnv = useParams({ + from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id, + select: (el) => el.envSlug + }); + + const { subscription } = useSubscription(); + + const isMoreEnvironmentsAllowed = + subscription?.environmentLimit && currentWorkspace?.environments + ? currentWorkspace.environments.length < subscription.environmentLimit + : true; + + const [isNavigating, setIsNavigating] = useState(false); + + const navigate = useNavigate(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "compareEnvironments", + "createEnvironment", + "upgradePlan" + ] as const); + + const selectedIndex = currentWorkspace.environments.findIndex((env) => env.slug === currentEnv); + + let tabEnvironments: WorkspaceEnv[]; + let dropdownEnvironments: WorkspaceEnv[]; + + if (selectedIndex < TABS_TO_SHOW) { + tabEnvironments = currentWorkspace.environments.slice(0, TABS_TO_SHOW); + dropdownEnvironments = currentWorkspace.environments.slice(TABS_TO_SHOW); + } else { + tabEnvironments = [ + ...currentWorkspace.environments.slice(0, TABS_TO_SHOW - 1), + currentWorkspace.environments[selectedIndex] + ]; + dropdownEnvironments = currentWorkspace.environments + .slice(TABS_TO_SHOW - 1) + .filter((env) => env.slug !== currentEnv); + } + + const queryClient = useQueryClient(); + + const handleSelect = async (envSlug: string) => { + if (isNavigating) return; + + setIsNavigating(true); + await navigate({ + to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path, + params: { + envSlug, + projectId: currentWorkspace.id + }, + search: (prev) => prev + }); + setIsNavigating(false); + }; + + const handleAddEnvironment = () => { + if (isMoreEnvironmentsAllowed) { + handlePopUpOpen("createEnvironment"); + } else { + handlePopUpOpen("upgradePlan"); + } + }; + + return ( + <> + { + if (value === COMPARE_ENVIRONMENT_TAB) { + handlePopUpOpen("compareEnvironments"); + return; + } + + if (value === ADD_ENVIRONMENT_TAB) { + handleAddEnvironment(); + return; + } + + handleSelect(value); + }} + defaultValue="environment-tabs" + > + + {tabEnvironments.map((environment) => ( + +

{environment.name}

+
+ ))} + {dropdownEnvironments.length ? ( + + + + +
+ +
+
+
+
+ + Environments +
+ {dropdownEnvironments.map((environment) => ( + { + e.stopPropagation(); + handleSelect(environment.slug); + }} + > + {environment.name} + + ))} +
+
+ + {(isAllowed) => ( + + + + )} + + + + ) : ( + + +
+ +
+
+
+ )} + {currentWorkspace.environments.length > 1 && ( + +
+ + Compare Environments +
+
+ )} + + + handlePopUpToggle("compareEnvironments", isOpen)} + > + + + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + /> + handlePopUpToggle("createEnvironment", isOpen)} + onComplete={async (env) => { + await queryClient.refetchQueries({ + queryKey: workspaceKeys.getWorkspaceById(currentWorkspace.id) + }); + handleSelect(env.slug); + }} + /> + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/index.tsx new file mode 100644 index 000000000..76be6609b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/index.tsx @@ -0,0 +1 @@ +export * from "./EnvironmentTabs"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx new file mode 100644 index 000000000..a2169fa5a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/FolderBreadCrumbs.tsx @@ -0,0 +1,52 @@ +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +type Props = { + secretPath: string; +}; + +export const FolderBreadCrumbs = ({ secretPath = "/" }: Props) => { + const navigate = useNavigate({ + from: "/projects/secret-management/$projectId/secrets/$envSlug" + }); + + const onFolderCrumbClick = (index: number) => { + const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`; + if (secretPath === newSecPath) return; + navigate({ + search: (prev) => ({ ...prev, secretPath: newSecPath }) + }); + }; + + return ( +
+
onFolderCrumbClick(0)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + +
+ {(secretPath || "") + .split("/") + .filter(Boolean) + .map((path, index, arr) => ( +
onFolderCrumbClick(index + 1)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + {path} +
+ ))} +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/index.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/index.tsx new file mode 100644 index 000000000..8224cdb25 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderBreadCrumbs/index.tsx @@ -0,0 +1 @@ +export { FolderBreadCrumbs } from "./FolderBreadCrumbs"; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx index 9b8c9ccaa..fd65bc639 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/FolderListView/FolderListView.tsx @@ -36,6 +36,7 @@ type Props = { workspaceId: string; secretPath?: string; onNavigateToFolder: (path: string) => void; + canNavigate: boolean; }; export const FolderListView = ({ @@ -43,7 +44,8 @@ export const FolderListView = ({ environment, workspaceId, secretPath = "/", - onNavigateToFolder + onNavigateToFolder, + canNavigate }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "updateFolder", @@ -190,7 +192,7 @@ export const FolderListView = ({ }; const handleFolderClick = (name: string, isPending?: boolean) => { - if (isPending) { + if (isPending || !canNavigate) { return; } const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 61841165b..0832dfe9d 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -3,16 +3,16 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsEnvironment } from "@app/hooks/api"; -import { UsePopUpState } from "@app/hooks/usePopUp"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; import { slugSchema } from "@app/lib/schemas"; type Props = { - popUp: UsePopUpState<["createEnv"]>; - handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: (environment: WorkspaceEnv) => void; }; const schema = z.object({ @@ -24,10 +24,14 @@ const schema = z.object({ export type FormData = z.infer; -export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { +type ContentProps = { + onComplete: (environment: WorkspaceEnv) => void; +}; + +const Content = ({ onComplete }: ContentProps) => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isPending } = useCreateWsEnvironment(); - const { control, handleSubmit, reset } = useForm({ + const { control, handleSubmit } = useForm({ resolver: zodResolver(schema) }); @@ -35,7 +39,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle try { if (!currentWorkspace?.id) return; - await mutateAsync({ + const env = await mutateAsync({ workspaceId: currentWorkspace.id, name: environmentName, slug: environmentSlug @@ -46,7 +50,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle type: "success" }); - handlePopUpClose("createEnv"); + onComplete(env); } catch (err) { console.error(err); createNotification({ @@ -57,64 +61,62 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }; return ( - { - handlePopUpToggle("createEnv", isOpen); - reset(); - }} - > - -
- ( - - - - )} - /> - ( - - - - )} - /> -
- + + ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + +
+ + ); +}; - -
- +export const AddEnvironmentModal = ({ onComplete, ...props }: Props) => { + return ( + + + { + if (onComplete) onComplete(env); + props.onOpenChange(false); + }} + /> ); diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 323fe2640..34acf0c7b 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -103,9 +103,8 @@ export const EnvironmentSection = () => { )} handlePopUpToggle("createEnv", isOpen)} />