Files
infisical/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx
T

1131 lines
38 KiB
TypeScript

/* eslint-disable no-case-declarations */
import { useCallback, useEffect, useMemo, useRef, 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,
useProject,
useProjectPermission
} from "@app/context";
import {
ProjectPermissionCommitsActions,
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 { ProjectVersion } from "@app/hooks/api/projects/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 { useResizableColWidth } from "@app/hooks/useResizableColWidth";
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 { EnvironmentTabs } from "./components/EnvironmentTabs";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
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 { currentProject } = useProject();
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, isPending: isCommitPending } = useCreateCommit();
const tableRef = useRef<HTMLTableElement>(null);
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>(DashboardSecretsOrderBy.Name, {
initPerPage: getUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, 100)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("secretDashboardTable", PreferenceKey.PerPage, newPerPage);
};
const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId);
const { popUp, handlePopUpClose, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"snapshots",
"requestAccess"
] as const);
// env slug
const projectId = currentProject?.id || "";
const projectSlug = currentProject?.slug || "";
const secretPath = (routerQueryParams.secretPath as string) || "/";
useEffect(() => {
if (isBatchMode && projectId && environment && secretPath) {
loadPendingChanges({ projectId, environment, secretPath });
}
}, [isBatchMode, projectId, 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 canReadCommits = permission.can(
ProjectPermissionCommitsActions.Read,
ProjectPermissionSub.Commits
);
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]: false,
[RowType.Import]: false,
[RowType.DynamicSecret]: false,
[RowType.Secret]: false,
[RowType.SecretRotation]: false
}
};
const [filter, setFilter] = useState<Filter>(defaultFilterState);
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map());
const createSecretPopUp = usePopUpState(PopUpNames.CreateSecretForm);
const { togglePopUp } = usePopUpAction();
useEffect(() => {
if (!currentProject?.environments.find((env) => env.slug === environment)) {
createNotification({
text: "No environment found with given slug",
type: "error"
});
navigate({
to: "/projects/secret-management/$projectId/overview",
params: {
projectId
}
});
}
}, [currentProject, environment]);
const isResourceTypeFiltered = Object.values(filter.include).some(Boolean);
const {
data,
isPending: isDetailsLoading,
isFetching: isDetailsFetching,
isFetched
} = useGetProjectSecretsDetails({
environment,
projectId,
secretPath,
offset,
limit,
orderBy,
search: debouncedSearchFilter,
orderDirection,
includeImports: canReadSecretImports && (isResourceTypeFiltered ? filter.include.import : true),
includeFolders: isResourceTypeFiltered ? filter.include.folder : true,
viewSecretValue: canReadSecretValue,
includeDynamicSecrets:
canReadDynamicSecret && (isResourceTypeFiltered ? filter.include.dynamic : true),
includeSecrets: canReadSecret && (isResourceTypeFiltered ? filter.include.secret : true),
includeSecretRotations:
canReadSecretRotations && (isResourceTypeFiltered ? filter.include.rotation : true),
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,
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,
environment,
path: secretPath,
options: {
enabled: canReadSecret
}
});
// fetch tags
const { data: tags } = useGetWsTags(
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? projectId : ""
);
const { pathPolicies, hasPathPolicies } = usePathAccessPolicies({ secretPath, environment });
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
projectId,
environment,
secretPath
});
const isProtectedBranch = Boolean(boardPolicy);
const handleCreateCommit = async (changes: PendingChanges, message: string) => {
try {
await createCommit({
projectId,
environment,
secretPath,
pendingChanges: changes,
message
});
createNotification({
text: isProtectedBranch
? "Requested changes have been sent for review"
: "Changes saved successfully",
type: "success"
});
} catch (error) {
createNotification({
text: "Failed to save changes",
type: "error"
});
console.error(error);
}
};
const {
data: snapshotList,
isFetchingNextPage: isFetchingNextSnapshotList,
fetchNextPage: fetchNextSnapshotList,
hasNextPage: hasNextSnapshotListPage
} = useGetWorkspaceSnapshotList({
projectId,
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,
projectId,
environment,
isPaused: !canReadCommits
});
const {
data: snapshotCount,
isPending: isSnapshotCountLoading,
isFetching: isSnapshotCountFetching
} = useGetWsSnapshotCount({
projectId,
environment,
directory: secretPath,
isPaused: !canDoReadRollback
});
const isPITEnabled = !currentProject?.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/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId,
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 ||
pendingChanges.secrets.length ||
pendingChanges.folders.length
);
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");
}, []);
const { handleMouseDown, isResizing, colWidth } = useResizableColWidth({
initialWidth: 320,
minWidth: 100,
maxWidth: tableRef.current
? tableRef.current.clientWidth - 148 // ensure value column can't collapse completely
: 800,
ref: tableRef
});
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<string, boolean> = {};
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) => {
// bulk actions don't apply to rotation secrets (move/delete)
if (secret.isRotatedSecret) return;
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[secret.id];
} else {
newChecks[secret.id] = secret;
}
});
selectedSecretActions.set(newChecks);
};
if (isDetailsLoading) {
return <ContentLoader text={LOADER_TEXT} />;
}
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
? change.tags?.map((tag) => ({
id: tag.id,
slug: tag.slug,
projectId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
__v: 0
})) || []
: mergedSecrets[updateIndex].tags
};
}
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();
if (!(currentProject?.version === ProjectVersion.V3))
return (
<div className="flex h-full w-full flex-col items-center justify-center px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
</div>
);
return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader
title="Secrets Management"
description={
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/api"
target="_blank"
rel="noopener noreferrer"
>
Infisical API
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
, and
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
>
more
</a>
.
</p>
}
/>
<SecretV2MigrationSection />
<FolderBreadCrumbs secretPath={secretPath} />
<EnvironmentTabs secretPath={secretPath} />
{!isRollbackMode ? (
<>
<ActionBar
environment={environment}
secretPath={secretPath}
isVisible={isVisible}
isBatchMode={isBatchMode}
filter={filter}
tags={tags}
onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={changesCount || 0}
isSnapshotCountLoading={isChangesCountPending && isChangesCountFetching}
onToggleRowType={handleToggleRowType}
onClickRollbackMode={handleOnClickRollbackMode}
protectedBranchPolicyName={boardPolicy?.name}
importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
isPITEnabled={isPITEnabled}
hasPathPolicies={hasPathPolicies}
onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)}
onClearFilters={() =>
setFilter((prev) => ({
...prev,
tags: {},
include: {
secret: false,
import: false,
dynamic: false,
rotation: false,
folder: false
}
}))
}
/>
<div
ref={tableRef}
className={twMerge(
"thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md bg-mineshaft-800 text-left text-sm text-bunker-300",
isNotEmpty && "rounded-b-none"
)}
>
<div className="flex flex-col" id="dashboard">
{isNotEmpty && (
<div
className={twMerge(
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)}
>
<Tooltip
className="max-w-[20rem] whitespace-nowrap"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all secrets on page`
: ""
}
>
<div className="mr-[0.055rem] flex w-11 items-center justify-center pl-2.5">
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
onClick={(e) => e.stopPropagation()}
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
<div className="relative">
<div
tabIndex={-1}
role="button"
className={`absolute -right-[0.05rem] z-40 h-full w-0.5 cursor-ew-resize hover:bg-blue-400/20 ${
isResizing ? "bg-blue-400/75" : "bg-transparent"
}`}
onMouseDown={handleMouseDown}
/>
<div className="pointer-events-none absolute -right-[0.04rem] top-2 z-30">
<div className="h-5 w-0.5 rounded-[1.5px] bg-gray-400 opacity-50" />
</div>
<div
className="flex flex-shrink-0 items-center border-r border-mineshaft-600 py-2 pl-4"
style={{ width: colWidth }}
role="button"
tabIndex={0}
onClick={handleSortToggle}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleSortToggle();
}}
>
Key
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2"
/>
</div>
</div>
<div className="flex-grow px-4 py-2">Value</div>
</div>
)}
{hasPathPolicies &&
// eslint-disable-next-line no-nested-ternary
(!canReadSecret ? (
<div
className={twMerge(
"flex border-l-2 border-l-primary bg-mineshaft-700 px-4 py-2",
isNotEmpty ? "border-b border-b-mineshaft-600" : ""
)}
>
<div className="flex items-center text-sm">
<FontAwesomeIcon
icon={faInfoCircle}
className="ml-[0.15rem] mr-[1.65rem] text-primary"
/>
<span>You do not have permission to read secrets in this folder</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", [ProjectPermissionActions.Read])
}
>
Request Access
</Button>
</div>
) : !canEditSecrets || !canDeleteSecrets ? (
<div className="flex border-b border-l-2 border-b-mineshaft-600 border-l-primary bg-mineshaft-700 px-4 py-2">
<div className="flex items-center text-sm">
<FontAwesomeIcon
icon={faInfoCircle}
className="ml-[0.15rem] mr-[1.65rem] text-primary"
/>
<span>
You do not have permission to {!canEditSecrets ? "edit" : ""}
{!canEditSecrets && !canDeleteSecrets ? " or " : ""}
{!canDeleteSecrets ? "delete" : ""} secrets in this folder
</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", [
...(!canEditSecrets ? [ProjectPermissionActions.Edit] : []),
...(!canDeleteSecrets ? [ProjectPermissionActions.Delete] : [])
])
}
>
Request Access
</Button>
</div>
) : null)}
{canReadSecretImports && Boolean(imports?.length) && (
<SecretImportListView
searchTerm={debouncedSearchFilter}
secretImports={imports}
isFetching={isDetailsFetching}
environment={environment}
projectId={projectId}
secretPath={secretPath}
importedSecrets={importedSecrets}
/>
)}
{Boolean(mergedFolders?.length) && (
<FolderListView
folders={mergedFolders}
environment={environment}
projectId={projectId}
secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
canNavigate={isFetched}
/>
)}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
<DynamicSecretListView
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecretRotations && Boolean(secretRotations?.length) && (
<SecretRotationListView secretRotations={secretRotations} />
)}
{canReadSecret && Boolean(mergedSecrets?.length) && (
<SecretListView
colWidth={colWidth}
secrets={mergedSecrets}
tags={tags}
isVisible={isVisible}
environment={environment}
projectId={projectId}
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
/>
)}
{(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && (
<CommitForm
onCommit={handleCreateCommit}
environment={environment}
projectId={projectId}
secretPath={secretPath}
isCommitting={isCommitPending}
/>
)}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
</div>
</div>
{!canReadSecret &&
!canReadDynamicSecret &&
!canReadSecretImports &&
folders?.length === 0 && <PermissionDeniedBanner />}
{!isDetailsLoading &&
(totalCount > 0 ||
pendingChanges.secrets.length > 0 ||
pendingChanges.folders.length > 0) && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={
totalSecretCount +
pendingChanges.secrets.filter((s) => s.type === PendingAction.Create).length
}
folderCount={
totalFolderCount +
pendingChanges.folders.filter((f) => f.type === PendingAction.Create).length
}
secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount + pendingChanges.secrets.length + pendingChanges.folders.length}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
<Modal
isOpen={createSecretPopUp.isOpen}
onOpenChange={(state) => togglePopUp(PopUpNames.CreateSecretForm, state)}
>
<ModalContent
title="Create Secret"
subTitle="Add a secret to this particular environment and folder"
bodyClassName="overflow-visible"
>
<CreateSecretForm
environment={environment}
projectId={projectId}
secretPath={secretPath}
autoCapitalize={currentProject?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
isBatchMode={isBatchMode}
/>
</ModalContent>
</Modal>
{!!pathPolicies && (
<RequestAccessModal
policies={pathPolicies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
handlePopUpClose("requestAccess");
}}
selectedActions={popUp.requestAccess.data}
secretPath={pathPolicies?.[0]?.secretPath}
/>
)}
<SecretDropzone
environment={environment}
projectId={projectId}
secretPath={secretPath}
isSmaller={isNotEmpty}
environments={currentProject?.environments}
isProtectedBranch={isProtectedBranch}
/>
<PitDrawer
secretSnaphots={snapshotList}
snapshotId={snapshotId}
isDrawerOpen={popUp.snapshots.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
hasNextPage={hasNextSnapshotListPage}
fetchNextPage={fetchNextSnapshotList}
onSelectSnapshot={handleSelectSnapshot}
isFetchingNextPage={isFetchingNextSnapshotList}
/>
</>
) : (
<SnapshotView
snapshotId={snapshotId || ""}
environment={environment}
projectId={projectId}
secretPath={secretPath}
secrets={secrets}
folders={folders}
snapshotCount={changesCount}
onGoBack={handleResetSnapshot}
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
/>
)}
</div>
);
};
export const SecretDashboardPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("dashboard.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={String(t("dashboard.og-title"))} />
<meta name="og:description" content={String(t("dashboard.og-description"))} />
</Helmet>
<div className="h-full">
<StoreProvider>
<Page />
</StoreProvider>
</div>
</>
);
};