/* eslint-disable no-case-declarations */ 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, faInfoCircle } 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 { Button, Checkbox, ContentLoader, Modal, ModalContent, PageHeader, 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 { getUserTablePreference, PreferenceKey, setUserTablePreference } from "@app/helpers/userTablePreferences"; 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 { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits"; 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 { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal"; 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 { CommitForm } from "./components/CommitForm"; 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 { PendingChanges, PopUpNames, StoreProvider, useBatchMode, useBatchModeActions, 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 { mutateAsync: createCommit } = useCreateCommit(); const [isVisible, setIsVisible] = useState(false); const { isBatchMode, pendingChanges } = useBatchMode(); const { loadPendingChanges, setExistingKeys } = useBatchModeActions(); const { offset, limit, orderDirection, setOrderDirection, setPage, perPage, page, setPerPage, orderBy } = usePagination(DashboardSecretsOrderBy.Name, { initPerPage: getUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, 100) }); const handlePerPageChange = (newPerPage: number) => { setPerPage(newPerPage); setUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, newPerPage); }; const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); const { popUp, handlePopUpClose, handlePopUpToggle, handlePopUpOpen } = usePopUp([ "snapshots", "requestAccess" ] as const); // env slug const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; const secretPath = (routerQueryParams.secretPath as string) || "/"; useEffect(() => { if (isBatchMode && workspaceId && environment && secretPath) { loadPendingChanges({ workspaceId, environment, secretPath }); } }, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]); const canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, { environment, secretPath, secretName: "*", secretTags: ["*"] } ); const canEditSecrets = permission.can( ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath, secretName: "*", secretTags: ["*"] }) ); const canDeleteSecrets = permission.can( ProjectPermissionSecretActions.Delete, subject(ProjectPermissionSub.Secrets, { 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, metadata: ["*"] }) ); 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: "/projects/$projectId/secret-manager/overview", 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, usedBySecretSyncs, 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 { pathPolicies, hasPathPolicies } = usePathAccessPolicies({ secretPath, environment }); const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ workspaceId, environment, secretPath }); const isProtectedBranch = Boolean(boardPolicy); const handleCreateCommit = async (changes: PendingChanges, message: string) => { try { await createCommit({ workspaceId, environment, secretPath, pendingChanges: changes, message }); createNotification({ text: isProtectedBranch ? "Requested changes have been sent for review" : "Changes committed successfully", type: "success" }); } catch (error) { createNotification({ text: "Failed to commit changes", type: "error" }); console.error(error); } }; const { data: snapshotList, isFetchingNextPage: isFetchingNextSnapshotList, fetchNextPage: fetchNextSnapshotList, hasNextPage: hasNextSnapshotListPage } = useGetWorkspaceSnapshotList({ workspaceId, directory: secretPath, environment, isPaused: !popUp.snapshots.isOpen || !canDoReadRollback, limit: 10 }); const { data: { count: folderCommitsCount, folderId } = { count: 0, folderId: "" }, isPending: isFolderCommitsCountLoading, isFetching: isFolderCommitsCountFetching } = useGetFolderCommitsCount({ directory: secretPath, workspaceId, environment, isPaused: !canDoReadRollback }); const { data: snapshotCount, isPending: isSnapshotCountLoading, isFetching: isSnapshotCountFetching } = useGetWsSnapshotCount({ workspaceId, environment, directory: secretPath, isPaused: !canDoReadRollback }); const isPITEnabled = !currentWorkspace?.showSnapshotsLegacy; const changesCount = useMemo(() => { return isPITEnabled ? folderCommitsCount : snapshotCount; }, [folderCommitsCount, snapshotCount]); const isChangesCountPending = useMemo(() => { return isPITEnabled ? isFolderCommitsCountLoading || isSnapshotCountLoading : false; }, [isFolderCommitsCountLoading, isSnapshotCountLoading]); const isChangesCountFetching = useMemo(() => { return isPITEnabled ? isFolderCommitsCountFetching || isSnapshotCountFetching : false; }, [isFolderCommitsCountFetching, isSnapshotCountFetching]); const handleOnClickRollbackMode = () => { if (isPITEnabled) { navigate({ to: "/projects/$projectId/secret-manager/commits/$environment/$folderId", params: { projectId: workspaceId, folderId, environment }, search: (query) => ({ ...query, secretPath }) }); } else { handlePopUpToggle("snapshots", true); } }; 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 ); useEffect(() => { if (data && isBatchMode) { const existingSecretKeys = [...(secrets?.map((s) => s.key) || [])]; const existingFolderNames = folders?.map((f) => f.name) || []; setExistingKeys(existingSecretKeys, existingFolderNames); } }, [data, isBatchMode, setExistingKeys, secrets, importedSecrets, folders]); 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(""); }; const getMergedSecretsWithPending = () => { if (!isBatchMode || pendingChanges.secrets.length === 0) { return secrets; } const mergedSecrets = [...(secrets || [])]; pendingChanges.secrets.forEach((change) => { switch (change.type) { case PendingAction.Create: mergedSecrets.unshift({ id: change.id, key: change.secretKey, value: change.secretValue, comment: change.secretComment || "", skipMultilineEncoding: change.skipMultilineEncoding || false, tags: change.tags || [], secretMetadata: change.secretMetadata || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), version: 1, isPending: true, pendingAction: PendingAction.Create } as unknown as SecretV3RawSanitized); break; case PendingAction.Update: const updateIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey); if (updateIndex >= 0) { mergedSecrets[updateIndex] = { ...mergedSecrets[updateIndex], key: change.newSecretName || change.secretKey, value: change.secretValue !== undefined ? change.secretValue : mergedSecrets[updateIndex].value, comment: change.secretComment !== undefined ? change.secretComment : mergedSecrets[updateIndex].comment, skipMultilineEncoding: change.skipMultilineEncoding !== undefined ? change.skipMultilineEncoding : mergedSecrets[updateIndex].skipMultilineEncoding, secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata, isPending: true, pendingAction: PendingAction.Update, tags: change.tags?.map((tag) => ({ id: tag.id, slug: tag.slug, projectId: workspaceId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), __v: 0 })) || [] }; } break; case PendingAction.Delete: const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey); if (deleteIndex >= 0) { mergedSecrets[deleteIndex] = { ...mergedSecrets[deleteIndex], isPending: true, pendingAction: PendingAction.Delete }; } break; default: break; } }); return mergedSecrets; }; const getMergedFoldersWithPending = () => { if (!isBatchMode || pendingChanges.folders.length === 0) { return folders; } const mergedFolders = [...(folders || [])]; pendingChanges.folders.forEach((change) => { switch (change.type) { case PendingAction.Create: mergedFolders.unshift({ id: change.id, name: change.folderName, description: change.description, parentId: null, isPending: true, pendingAction: PendingAction.Create } as any); break; case PendingAction.Update: const updateIndex = mergedFolders.findIndex((f) => f.id === change.id); if (updateIndex >= 0) { mergedFolders[updateIndex] = { ...mergedFolders[updateIndex], name: change.folderName, description: change.description !== undefined ? change.description : mergedFolders[updateIndex].description, isPending: true, pendingAction: PendingAction.Update }; } break; case PendingAction.Delete: const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id); if (deleteIndex >= 0) { mergedFolders[deleteIndex] = { ...mergedFolders[deleteIndex], isPending: true, pendingAction: PendingAction.Delete }; } break; default: break; } }); return mergedFolders; }; const mergedSecrets = getMergedSecretsWithPending(); const mergedFolders = getMergedFoldersWithPending(); return (
env.slug === environment)?.name ?? environment } description={

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

} /> {!isRollbackMode ? ( <> handlePopUpOpen("requestAccess", params)} />
{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
)} {hasPathPolicies && // eslint-disable-next-line no-nested-ternary (!canReadSecret ? (
You do not have permission to read secrets in this folder
) : !canEditSecrets || !canDeleteSecrets ? (
You do not have permission to {!canEditSecrets ? "edit" : ""} {!canEditSecrets && !canDeleteSecrets ? " or " : ""} {!canDeleteSecrets ? "delete" : ""} secrets in this folder
) : null)} {canReadSecretImports && Boolean(imports?.length) && ( )} {Boolean(mergedFolders?.length) && ( )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( )} {canReadSecretRotations && Boolean(secretRotations?.length) && ( )} {canReadSecret && Boolean(mergedSecrets?.length) && ( )} {(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && ( )} {noAccessSecretCount > 0 && } {!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={handlePerPageChange} /> )} togglePopUp(PopUpNames.CreateSecretForm, state)} > {!!pathPolicies && ( { handlePopUpClose("requestAccess"); }} selectedActions={popUp.requestAccess.data} secretPath={pathPolicies?.[0]?.secretPath} /> )} 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") })}
); };