import { useCallback, useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { PermissionDeniedBanner } from "@app/components/permissions"; import { Checkbox, ContentLoader, Modal, ModalContent, Pagination, Tooltip } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionActions, ProjectPermissionDynamicSecretActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useGetImportedSecretsSingleEnv, useGetSecretApprovalPolicyOfABoard, useGetWorkspaceSnapshotList, useGetWsSnapshotCount, useGetWsTags } from "@app/hooks/api"; import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectType } from "@app/hooks/api/workspace/types"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection"; import { ActionBar } from "./components/ActionBar"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { DynamicSecretListView } from "./components/DynamicSecretListView"; import { FolderListView } from "./components/FolderListView"; import { PitDrawer } from "./components/PitDrawer"; import { SecretDropzone } from "./components/SecretDropzone"; import { SecretImportListView } from "./components/SecretImportListView"; import { SecretListView, SecretNoAccessListView } from "./components/SecretListView"; import { SnapshotView } from "./components/SnapshotView"; import { PopUpNames, StoreProvider, usePopUpAction, usePopUpState, useSelectedSecretActions, useSelectedSecrets } from "./SecretMainPage.store"; import { Filter, RowType } from "./SecretMainPage.types"; const LOADER_TEXT = [ "Retrieving your encrypted secrets...", "Fetching folders...", "Getting secret import links..." ]; const Page = () => { const { currentWorkspace } = useWorkspace(); const navigate = useNavigate({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.path }); const routerQueryParams = useSearch({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id }); const environment = useParams({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id, select: (el) => el.envSlug }); const { permission } = useProjectPermission(); const [isVisible, setIsVisible] = useState(false); const { offset, limit, orderDirection, setOrderDirection, setPage, perPage, page, setPerPage, orderBy } = usePagination(DashboardSecretsOrderBy.Name); const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const); // env slug const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; const secretPath = (routerQueryParams.secretPath as string) || "/"; const canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, { environment, secretPath, secretName: "*", secretTags: ["*"] } ); const canReadSecretValue = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.ReadValue, { environment, secretPath, secretName: "*", secretTags: ["*"] } ); const canReadSecretImports = permission.can( ProjectPermissionActions.Read, subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) ); const canReadDynamicSecret = permission.can( ProjectPermissionDynamicSecretActions.ReadRootCredential, subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath }) ); const canReadSecretRotations = permission.can( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ); const canDoReadRollback = permission.can( ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback ); const defaultFilterState = { tags: {}, searchFilter: (routerQueryParams.search as string) || "", // these should always be on by default for the UI, they will be disabled for the query below based off permissions include: { [RowType.Folder]: true, [RowType.Import]: true, [RowType.DynamicSecret]: true, [RowType.Secret]: true, [RowType.SecretRotation]: true } }; const [filter, setFilter] = useState(defaultFilterState); const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter); const [filterHistory, setFilterHistory] = useState>(new Map()); const createSecretPopUp = usePopUpState(PopUpNames.CreateSecretForm); const { togglePopUp } = usePopUpAction(); useEffect(() => { if (!currentWorkspace?.environments.find((env) => env.slug === environment)) { createNotification({ text: "No environment found with given slug", type: "error" }); navigate({ to: `/${ProjectType.SecretManager}/$projectId/overview` as const, params: { projectId: workspaceId } }); } }, [currentWorkspace, environment]); const { data, isPending: isDetailsLoading, isFetching: isDetailsFetching } = useGetProjectSecretsDetails({ environment, projectId: workspaceId, secretPath, offset, limit, orderBy, search: debouncedSearchFilter, orderDirection, includeImports: canReadSecretImports && filter.include.import, includeFolders: filter.include.folder, viewSecretValue: canReadSecretValue, includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic, includeSecrets: canReadSecret && filter.include.secret, includeSecretRotations: canReadSecretRotations && filter.include.rotation, tags: filter.tags }); const { imports, folders, dynamicSecrets, secretRotations, secrets, totalImportCount = 0, totalFolderCount = 0, totalDynamicSecretCount = 0, totalSecretCount = 0, totalCount = 0, importedBy, totalSecretRotationCount = 0 } = data ?? {}; useResetPageHelper({ totalCount, offset, setPage }); // fetch imported secrets to show user the overriden ones const { data: importedSecrets } = useGetImportedSecretsSingleEnv({ projectId: workspaceId, environment, path: secretPath, options: { enabled: canReadSecret } }); // fetch tags const { data: tags } = useGetWsTags( permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" ); const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ workspaceId, environment, secretPath }); const isProtectedBranch = Boolean(boardPolicy); const { data: snapshotList, isFetchingNextPage: isFetchingNextSnapshotList, fetchNextPage: fetchNextSnapshotList, hasNextPage: hasNextSnapshotListPage } = useGetWorkspaceSnapshotList({ workspaceId, directory: secretPath, environment, isPaused: !popUp.snapshots.isOpen || !canDoReadRollback, limit: 10 }); const { data: snapshotCount, isPending: isSnapshotCountLoading, isFetching: isSnapshotCountFetching } = useGetWsSnapshotCount({ workspaceId, environment, directory: secretPath, isPaused: !canDoReadRollback }); const noAccessSecretCount = Math.max( (page * perPage > totalCount ? totalCount % perPage : perPage) - (imports?.length || 0) - (folders?.length || 0) - (secrets?.length || 0) - (dynamicSecrets?.length || 0) - (secretRotations?.length || 0), 0 ); const isNotEmpty = Boolean( secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length || secretRotations?.length || noAccessSecretCount ); const handleSortToggle = () => setOrderDirection((state) => state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC ); const handleTagToggle = useCallback( (tagSlug: string) => setFilter((state) => { const isTagPresent = Boolean(state.tags?.[tagSlug]); const newTagFilter = { ...state.tags }; if (isTagPresent) delete newTagFilter[tagSlug]; else newTagFilter[tagSlug] = true; return { ...state, tags: newTagFilter }; }), [] ); const handleToggleRowType = useCallback( (rowType: RowType) => setFilter((state) => { return { ...state, include: { ...state.include, [rowType]: !state.include[rowType] } }; }), [] ); const handleSearchChange = useCallback( (searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })), [] ); const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []); // snapshot functions const handleSelectSnapshot = useCallback((snapId: string) => { setSnapshotId(snapId); }, []); const handleResetSnapshot = useCallback(() => { setSnapshotId(null); handlePopUpClose("snapshots"); }, []); useEffect(() => { // restore filters for path if set const restore = filterHistory.get(secretPath); setFilter(restore ?? defaultFilterState); setDebouncedSearchFilter(restore?.searchFilter ?? ""); }, [secretPath]); useEffect(() => { if (!routerQueryParams.search && !routerQueryParams.tags) return; const queryTags = routerQueryParams.tags ? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim())) : []; const updatedTags: Record = {}; queryTags.forEach((tag) => { updatedTags[tag] = true; }); setFilter((prev) => ({ ...prev, ...defaultFilterState, searchFilter: (routerQueryParams.search as string) ?? "", tags: updatedTags })); setDebouncedSearchFilter(routerQueryParams.search as string); // this is a temp workaround until we fully transition state to query params, navigate({ search: (state) => { const { search, tags: qTags, ...query } = state; return query; } }); }, [routerQueryParams.search, routerQueryParams.tags]); const selectedSecrets = useSelectedSecrets(); const selectedSecretActions = useSelectedSecretActions(); const allRowsSelectedOnPage = useMemo(() => { if (!secrets?.length) return { isChecked: false, isIndeterminate: false }; if (secrets?.every((secret) => selectedSecrets[secret.id])) return { isChecked: true, isIndeterminate: false }; if (secrets?.some((secret) => selectedSecrets[secret.id])) return { isChecked: true, isIndeterminate: true }; return { isChecked: false, isIndeterminate: false }; }, [selectedSecrets, secrets]); const toggleSelectAllRows = () => { const newChecks = { ...selectedSecrets }; secrets?.forEach((secret) => { if (allRowsSelectedOnPage.isChecked) { delete newChecks[secret.id]; } else { newChecks[secret.id] = secret; } }); selectedSecretActions.set(newChecks); }; if (isDetailsLoading) { return ; } const handleResetFilter = () => { // store for breadcrumb nav to restore previously used filters setFilterHistory((prev) => { const curr = new Map(prev); curr.set(secretPath, filter); return curr; }); setFilter(defaultFilterState); setDebouncedSearchFilter(""); }; return (
{!isRollbackMode ? ( <> handlePopUpToggle("snapshots", true)} protectedBranchPolicyName={boardPolicy?.name} />
{isNotEmpty && (
0 ? `${ !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" } all secrets on page` : "" } >
e.stopPropagation()} isChecked={allRowsSelectedOnPage.isChecked} isIndeterminate={allRowsSelectedOnPage.isIndeterminate} onCheckedChange={toggleSelectAllRows} />
{ if (evt.key === "Enter") handleSortToggle(); }} > Key
Value
)} {canReadSecretImports && Boolean(imports?.length) && ( )} {Boolean(folders?.length) && ( )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( )} {canReadSecretRotations && Boolean(secretRotations?.length) && ( )} {canReadSecret && Boolean(secrets?.length) && ( )} {canReadSecret && } {!canReadSecret && !canReadDynamicSecret && !canReadSecretImports && folders?.length === 0 && }
{!isDetailsLoading && totalCount > 0 && ( } className="rounded-b-md border-t border-solid border-t-mineshaft-600" count={totalCount} page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} onChangePerPage={(newPerPage) => setPerPage(newPerPage)} /> )} togglePopUp(PopUpNames.CreateSecretForm, state)} > handlePopUpToggle("snapshots", isOpen)} hasNextPage={hasNextSnapshotListPage} fetchNextPage={fetchNextSnapshotList} onSelectSnapshot={handleSelectSnapshot} isFetchingNextPage={isFetchingNextSnapshotList} /> ) : ( handlePopUpToggle("snapshots", true)} /> )}
); }; export const SecretDashboardPage = () => { const { t } = useTranslation(); return ( <> {t("common.head-title", { title: t("dashboard.title") })}
); };