improvement: remove overview page and re-vamp secret dashboard

This commit is contained in:
Scott Wilson
2025-08-27 16:51:15 -07:00
parent fc6778dd89
commit c99d5c210c
34 changed files with 1725 additions and 1778 deletions
@@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
reset(); reset();
onOpenChange(false); onOpenChange(false);
navigate({ navigate({
to: getProjectHomePage(project.type), to: getProjectHomePage(project.type, project.environments),
params: { projectId: project.id } params: { projectId: project.id }
}); });
} catch (err) { } catch (err) {
@@ -10,6 +10,7 @@ type Props = {
children?: ReactNode; children?: ReactNode;
icon?: IconDefinition; icon?: IconDefinition;
iconSize?: SizeProp; iconSize?: SizeProp;
titleClassName?: string;
}; };
export const EmptyState = ({ export const EmptyState = ({
@@ -17,7 +18,8 @@ export const EmptyState = ({
className, className,
children, children,
icon = faCubesStacked, icon = faCubesStacked,
iconSize = "2x" iconSize = "2x",
titleClassName
}: Props) => ( }: Props) => (
<div <div
className={twMerge( className={twMerge(
@@ -27,7 +29,7 @@ export const EmptyState = ({
> >
<FontAwesomeIcon icon={icon} size={iconSize} /> <FontAwesomeIcon icon={icon} size={iconSize} />
<div className="flex flex-col items-center py-4"> <div className="flex flex-col items-center py-4">
<div className="text-sm text-bunker-300">{title}</div> <div className={twMerge("text-sm text-bunker-300", titleClassName)}>{title}</div>
<div>{children}</div> <div>{children}</div>
</div> </div>
</div> </div>
@@ -67,7 +67,7 @@ export const FilterableSelect = <T,>({
}), }),
menuPortal: (provided) => ({ menuPortal: (provided) => ({
...provided, ...provided,
zIndex: 9999 zIndex: 99999
}) })
}} }}
tabSelectsValue={tabSelectsValue} tabSelectsValue={tabSelectsValue}
+5 -2
View File
@@ -1,6 +1,6 @@
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { createWorkspace } from "@app/hooks/api/workspace/queries"; 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 = [ 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) { switch (type) {
case ProjectType.SecretManager: case ProjectType.SecretManager:
if (environments.length > 0)
return `/projects/secret-management/$projectId/secrets/${environments[0].slug}`;
return "/projects/secret-management/$projectId/overview"; return "/projects/secret-management/$projectId/overview";
case ProjectType.CertificateManager: case ProjectType.CertificateManager:
return "/projects/cert-management/$projectId/subscribers"; return "/projects/cert-management/$projectId/subscribers";
+12 -7
View File
@@ -47,7 +47,8 @@ import {
UpdateEnvironmentDTO, UpdateEnvironmentDTO,
UpdatePitVersionLimitDTO, UpdatePitVersionLimitDTO,
UpdateProjectDTO, UpdateProjectDTO,
Workspace Workspace,
WorkspaceEnv
} from "./types"; } from "./types";
export const fetchWorkspaceById = async (workspaceId: string) => { export const fetchWorkspaceById = async (workspaceId: string) => {
@@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => {
export const useCreateWsEnvironment = () => { export const useCreateWsEnvironment = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<object, object, CreateEnvironmentDTO>({ return useMutation<WorkspaceEnv, WorkspaceEnv, CreateEnvironmentDTO>({
mutationFn: ({ workspaceId, name, slug }) => { mutationFn: async ({ workspaceId, name, slug }) => {
return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, { const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>(
name, `/api/v1/workspace/${workspaceId}/environments`,
slug {
}); name,
slug
}
);
return data.environment;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => {
}, },
{ {
onSuccess: () => { onSuccess: () => {
const url = getProjectHomePage(currentWorkspace.type); const url = getProjectHomePage(
currentWorkspace.type,
currentWorkspace.environments
);
window.location.href = url.replace("$projectId", currentWorkspace.id); window.location.href = url.replace("$projectId", currentWorkspace.id);
} }
} }
@@ -101,7 +101,7 @@ export const ProjectSelect = () => {
<div className="-mr-2 flex w-full items-center gap-1"> <div className="-mr-2 flex w-full items-center gap-1">
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<Link <Link
to={getProjectHomePage(currentWorkspace.type)} to={getProjectHomePage(currentWorkspace.type, currentWorkspace.environments)}
params={{ params={{
projectId: currentWorkspace.id projectId: currentWorkspace.id
}} }}
@@ -158,7 +158,7 @@ export const ProjectSelect = () => {
// to reproduce change this back to router.push and switch between two projects with different env count // to reproduce change this back to router.push and switch between two projects with different env count
// look into this on dashboard revamp // look into this on dashboard revamp
const url = linkOptions({ const url = linkOptions({
to: getProjectHomePage(workspace.type), to: getProjectHomePage(workspace.type, workspace.environments),
params: { params: {
projectId: workspace.id projectId: workspace.id
} }
@@ -71,9 +71,16 @@ export const SecretManagerLayout = () => {
<Menu> <Menu>
<MenuGroup title="Resources"> <MenuGroup title="Resources">
<Link <Link
to="/projects/secret-management/$projectId/overview" to={
currentWorkspace.environments.length
? "/projects/secret-management/$projectId/secrets/$envSlug"
: "/projects/secret-management/$projectId/overview"
}
params={{ params={{
projectId: currentWorkspace.id projectId: currentWorkspace.id,
...(currentWorkspace.environments.length
? { envSlug: currentWorkspace.environments[0]?.slug }
: {})
}} }}
> >
{({ isActive }) => ( {({ isActive }) => (
@@ -48,7 +48,7 @@ import {
useRequestProjectAccess, useRequestProjectAccess,
useSearchProjects useSearchProjects
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; import { ProjectType, Workspace, WorkspaceEnv } from "@app/hooks/api/workspace/types";
type Props = { type Props = {
onAddNewProject: () => void; onAddNewProject: () => void;
@@ -144,13 +144,17 @@ export const AllProjectView = ({
type: projectTypeFilter type: projectTypeFilter
}); });
const handleAccessProject = async (type: ProjectType, projectId: string) => { const handleAccessProject = async (
type: ProjectType,
projectId: string,
environments: WorkspaceEnv[]
) => {
try { try {
await orgAdminAccessProject.mutateAsync({ await orgAdminAccessProject.mutateAsync({
projectId projectId
}); });
await navigate({ await navigate({
to: getProjectHomePage(type), to: getProjectHomePage(type, environments),
params: { params: {
projectId projectId
} }
@@ -307,7 +311,7 @@ export const AllProjectView = ({
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter" && workspace.isMember) { if (evt.key === "Enter" && workspace.isMember) {
navigate({ navigate({
to: getProjectHomePage(workspace.type), to: getProjectHomePage(workspace.type, workspace.environments),
params: { params: {
projectId: workspace.id projectId: workspace.id
} }
@@ -317,7 +321,7 @@ export const AllProjectView = ({
onClick={() => { onClick={() => {
if (workspace.isMember) { if (workspace.isMember) {
navigate({ navigate({
to: getProjectHomePage(workspace.type), to: getProjectHomePage(workspace.type, workspace.environments),
params: { params: {
projectId: workspace.id projectId: workspace.id
} }
@@ -363,7 +367,7 @@ export const AllProjectView = ({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
handleAccessProject(workspace.type, workspace.id); handleAccessProject(workspace.type, workspace.id, workspace.environments);
}} }}
disabled={ disabled={
orgAdminAccessProject.variables?.projectId === workspace.id && orgAdminAccessProject.variables?.projectId === workspace.id &&
@@ -185,7 +185,7 @@ export const MyProjectView = ({
<div <div
onClick={() => { onClick={() => {
navigate({ navigate({
to: getProjectHomePage(workspace.type), to: getProjectHomePage(workspace.type, workspace.environments),
params: { params: {
projectId: workspace.id projectId: workspace.id
} }
@@ -239,7 +239,7 @@ export const MyProjectView = ({
<div <div
onClick={() => { onClick={() => {
navigate({ navigate({
to: getProjectHomePage(workspace.type), to: getProjectHomePage(workspace.type, workspace.environments),
params: { params: {
projectId: workspace.id projectId: workspace.id
} }
@@ -136,7 +136,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
text: "User privilege assumption has started" 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); window.location.href = url.replace("$projectId", currentWorkspace.id);
} }
} }
@@ -67,7 +67,7 @@ const Page = () => {
type: "success", type: "success",
text: "Identity privilege assumption has started" 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); window.location.href = url.replace("$projectId", currentWorkspace.id);
} }
} }
@@ -72,7 +72,7 @@ export const Page = () => {
text: "User privilege assumption has started" 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); window.location.href = url.replace("$projectId", currentWorkspace.id);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { useCreateCommit } from "@app/hooks/api/secrets/mutations"; import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
import { SecretV3RawSanitized } from "@app/hooks/api/types"; import { SecretV3RawSanitized } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies"; import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; import { useResizableColWidth } from "@app/hooks/useResizableColWidth";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
@@ -64,6 +65,7 @@ import { ActionBar } from "./components/ActionBar";
import { CommitForm } from "./components/CommitForm"; import { CommitForm } from "./components/CommitForm";
import { CreateSecretForm } from "./components/CreateSecretForm"; import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView"; import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { EnvironmentTabs } from "./components/EnvironmentTabs";
import { FolderListView } from "./components/FolderListView"; import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer"; import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone"; import { SecretDropzone } from "./components/SecretDropzone";
@@ -710,12 +712,18 @@ const Page = () => {
const mergedSecrets = getMergedSecretsWithPending(); const mergedSecrets = getMergedSecretsWithPending();
const mergedFolders = getMergedFoldersWithPending(); const mergedFolders = getMergedFoldersWithPending();
if (!(currentWorkspace?.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 ( return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader <PageHeader
title={ title="Secrets Management"
currentWorkspace.environments.find((env) => env.slug === environment)?.name ?? environment
}
description={ description={
<p className="text-md text-bunker-300"> <p className="text-md text-bunker-300">
Inject your secrets using Inject your secrets using
@@ -759,6 +767,7 @@ const Page = () => {
} }
/> />
<SecretV2MigrationSection /> <SecretV2MigrationSection />
<EnvironmentTabs secretPath={secretPath} />
{!isRollbackMode ? ( {!isRollbackMode ? (
<> <>
<ActionBar <ActionBar
@@ -0,0 +1,589 @@
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 TABLE_WIDTH_OFFSET = 17;
const COL_WIDTH_OFFSET = 220;
export const CompareEnvironments = ({ secretPath }: Props) => {
const { currentWorkspace } = useWorkspace();
const compareEnvironmentsKey = `compare-environments-${currentWorkspace.id}`;
const [selectedEnvironments, setSelectedEnvironments] = useState<WorkspaceEnv[]>(() => {
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<Filter>(DEFAULT_FILTER_STATE);
const {
offset,
limit,
orderDirection,
setOrderDirection,
setPage,
perPage,
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(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,
totalImportCount,
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<HTMLDivElement>(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
});
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
[rowType]: !state[rowType]
};
}),
[]
);
const isTableEmpty = totalCount === 0;
const isTableFiltered = isFilteredByResources;
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
// eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
setTableWidth(entry.contentRect.width - TABLE_WIDTH_OFFSET);
}
});
if (tableRef.current) {
resizeObserver.observe(tableRef.current);
}
return () => resizeObserver.disconnect();
}, []);
return (
// scott: this is reverse to fix z-indexing bug of dropdown with sticky table cols; couldn't resolve with flex-col
<div className="flex flex-1 flex-col-reverse overflow-hidden">
{!isOverviewLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
importCount={totalImportCount}
secretRotationCount={totalSecretRotationCount}
/>
}
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}
/>
)}
<div ref={tableRef} className="thin-scrollbar flex flex-1 flex-col overflow-y-auto">
<TableContainer
className={twMerge(
"mt-4 flex flex-1 flex-col border-mineshaft-500 bg-mineshaft-700",
!isTableEmpty && "rounded-b-none"
)}
>
{/* eslint-disable-next-line no-nested-ternary */}
{isOverviewLoading ? (
<div className="flex h-full flex-col items-center justify-center">
<Lottie
isAutoPlay
icon="infisical_loading"
className="h-10 place-self-center self-center"
/>
</div>
) : isTableEmpty ? (
<EmptyState
titleClassName="text-base"
className="m-auto bg-mineshaft-700 text-lg"
title={
isTableFiltered
? "No secrets to compare with current filters"
: "No secrets to compare"
}
/>
) : (
<Table className="border-collapse bg-mineshaft-700">
<THead className="sticky top-0 z-20">
<Tr className="sticky top-0 z-20">
<Th className="sticky left-0 z-10 border-none p-0" style={{ width: colWidth }}>
<div className="relative">
<div
tabIndex={-1}
role="button"
className={`absolute -right-[0.02rem] 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.02rem] top-[0.67rem] z-30">
<div className="h-5 w-0.5 rounded-[1.5px] bg-gray-400 opacity-50" />
</div>
<div className="flex h-full items-center border-b-2 border-r border-mineshaft-500 bg-mineshaft-700 bg-clip-padding p-0 px-4 py-2.5">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() =>
setOrderDirection((prev) =>
prev === OrderByDirection.ASC
? OrderByDirection.DESC
: OrderByDirection.ASC
)
}
>
<FontAwesomeIcon
icon={orderDirection === "asc" ? faArrowDown : faArrowUp}
/>
</IconButton>
</div>
</div>
</Th>
{compareEnvironments?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const importedSecKeyCount = getEnvImportedSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount;
return (
<Th
className="whitespace-nowrap border-none p-0 text-center"
key={`environment-${slug}`}
>
<div
className={twMerge(
"flex h-full w-full items-center justify-center gap-x-2 border-b-2 border-mineshaft-500 bg-mineshaft-700 p-0 px-4 py-3 text-center",
index < compareEnvironments.length - 1 && "border-r"
)}
>
{name}
{missingKeyCount > 0 && (
<Tooltip
className="max-w-none lowercase"
content={`${missingKeyCount} secrets missing\n compared to other environments`}
>
<Badge
variant="primary"
className="-mt-[0.05rem] flex h-4 items-center gap-x-1 pt-[0.1rem] font-normal leading-3"
>
<FontAwesomeIcon icon={faWarning} className="-mt-[0.1rem] w-2.5" />
{missingKeyCount}
</Badge>
</Tooltip>
)}
</div>
</Th>
);
})}
</Tr>
</THead>
<TBody>
{folderNamesAndDescriptions.map(({ name: folderName }, index) => (
<FolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={compareEnvironments}
key={`overview-${folderName}-${index + 1}`}
colWidth={colWidth}
/>
))}
{dynamicSecretNames.map((dynamicSecretName, index) => (
<DynamicSecretRow
dynamicSecretName={dynamicSecretName}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={compareEnvironments}
key={`overview-${dynamicSecretName}-${index + 1}`}
colWidth={colWidth}
/>
))}
{secretRotationNames.map((secretRotationName, index) => (
<SecretRotationRow
secretRotationName={secretRotationName}
isSecretRotationInEnv={isSecretRotationPresentInEnv}
environments={compareEnvironments}
getSecretRotationByName={getSecretRotationByName}
key={`overview-${secretRotationName}-${index + 1}`}
colWidth={colWidth}
tableWidth={tableWidth}
/>
))}
{secKeys.map((key, index) => (
<SecretRow
colWidth={colWidth}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={handleIsImportedSecretPresentInEnv}
key={`overview-${key}-${index + 1}`}
environments={compareEnvironments}
secretKey={key}
getSecretByKey={getSecretByKey}
tableWidth={tableWidth}
/>
))}
<SecretNoAccessRow
colWidth={colWidth}
environments={compareEnvironments}
count={Math.max(
(page * perPage > totalCount ? totalCount % perPage : perPage) -
(totalUniqueFoldersInPage || 0) -
(totalUniqueDynamicSecretsInPage || 0) -
(totalUniqueSecretsInPage || 0) -
(totalUniqueSecretImportsInPage || 0) -
(totalUniqueSecretRotationsInPage || 0),
0
)}
/>
</TBody>
</Table>
)}
</TableContainer>
</div>
<div className="mt-3 flex flex-row items-center justify-center space-x-2">
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="h-full flex-1"
placeholder="Search by resource name..."
leftIcon={<FontAwesomeIcon icon={faSearch} />}
containerClassName="h-10"
/>
{isTableFiltered && (
<Button
variant="plain"
colorSchema="secondary"
onClick={() => {
setFilter(DEFAULT_FILTER_STATE);
}}
>
Clear Filters
</Button>
)}
{compareEnvironments.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline_bg"
className={twMerge(
"flex h-[2.5rem]",
isTableFiltered && "border-primary/40 bg-primary/10"
)}
leftIcon={
<FontAwesomeIcon
icon={faFilter}
className={isTableFiltered ? "text-primary/80" : undefined}
/>
}
>
Filters
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
sideOffset={2}
>
<DropdownMenuLabel>Filter by Resource</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Folder);
}}
icon={filter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.DynamicSecret);
}}
icon={filter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.SecretRotation);
}}
icon={filter[RowType.SecretRotation] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
<span>Secret Rotations</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Secret);
}}
icon={filter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="!z-[99999999]">
<FormLabel label="Select Environments to Compare" />
<FilterableSelect
value={selectedEnvironments}
onChange={(value) => {
const selected = value as MultiValue<WorkspaceEnv>;
setSelectedEnvironments((selected as WorkspaceEnv[]) ?? []);
}}
placeholder="Leave blank to compare all environments"
options={currentWorkspace.environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
isMulti
/>
</div>
</div>
);
};
@@ -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 (
<Tr isHoverable className="group border-mineshaft-500">
<ResourceNameCell
label={dynamicSecretName}
icon={faFingerprint}
colWidth={colWidth}
iconClassName="text-yellow-700"
/>
{environments.map(({ slug }, i) => {
const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`dynamic-secret-${dynamicSecretName}-${i + 1}-value`}
/>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { DynamicSecretRow } from "./DynamicSecretRow";
@@ -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 (
<Tr isHoverable className="group border-mineshaft-500">
<ResourceNameCell
label={folderName}
icon={faFolder}
iconClassName="text-yellow-700"
colWidth={colWidth}
/>
{environments.map(({ slug }, i) => {
const isPresent = isFolderPresentInEnv(folderName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`folder-${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { FolderRow } from "./FolderRow";
@@ -0,0 +1,174 @@
import { faEye, faEyeSlash, faInfoCircle, faRotate } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, 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 (
<>
<Tr isHoverable isSelectable onClick={setIsExpanded.toggle} className="group">
<ResourceNameCell
label={secretRotationName}
icon={faRotate}
colWidth={colWidth}
iconClassName="text-mineshaft-400"
isRowExpanded={isExpanded}
/>
{environments.map(({ slug }, i) => {
const isPresent = isSecretRotationInEnv(secretRotationName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`secret-rotation-${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
{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 (
<Tr key={`secret-rotation-${slug}-${secretRotationName}`} className="border-b-0">
<Td
colSpan={totalCols}
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-mineshaft-800 bg-clip-padding px-0 py-0"
>
<div
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-mineshaft-800 bg-clip-padding px-0 py-0"
>
<div className="flex !h-[40px] items-center justify-between gap-x-2 px-4">
<div className="w-full">
<div className="flex w-full flex-wrap items-center gap-x-2.5">
<span>{envName}</span>
<Tag className="flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
<img
src={`/images/integrations/${image}`}
className="w-[11px]"
alt={`${rotationType} logo`}
/>
{rotationType}
</Tag>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</div>
<Button
variant="plain"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />}
onClick={() => setIsSecretVisible.toggle()}
>
{isSecretVisible ? "Hide Values" : "Reveal Values"}
</Button>
</div>
<TableContainer className="rounded-none border-0">
<table className="secret-table w-full border-b-0">
<tbody className="!last:border-b-0 w-full border-t-2 border-mineshaft-600">
{secrets.map((secret, index) => {
return (
<Tooltip
className="max-w-sm"
content={
secret
? undefined
: "You do not have permission to view this secret."
}
// eslint-disable-next-line react/no-array-index-key
key={`rotation-secret-${secretRotation.id}-${index}`}
>
<tr className="hover:bg-mineshaft-700/70">
<td
style={{
width: colWidth
}}
className="!h-[1px] border-none !p-0"
>
<div
className="flex h-full flex-1 items-center border-r border-mineshaft-500 px-4 py-1"
style={{
width: colWidth
}}
>
<span className={twMerge(!secret && "blur", "truncate")}>
{secret?.key ?? "********"}
</span>
</div>
</td>
<td className="!h-[40px] !px-4">
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<InfisicalSecretInput
isReadOnly
value={secret.value}
isVisible={isSecretVisible}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
onChange={() => {}}
/>
)}
</td>
</tr>
</Tooltip>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
);
})}
</>
);
};
@@ -0,0 +1 @@
export { SecretRotationRow } from "./SecretRotationRow";
@@ -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 (
<div className="group flex w-full cursor-text items-center space-x-2">
{secretValueHidden && !isOverride && (
<Tooltip content="You do not have access to view the current value">
<FontAwesomeIcon className="pl-2" size="sm" icon={faEyeSlash} />
</Tooltip>
)}
<div className="flex-1 pl-3 pr-2">
<InfisicalSecretInput
onChange={() => {}}
isReadOnly
value={defaultValue as string}
key="secret-input"
isVisible={isVisible && !secretValueHidden}
secretPath={secretPath}
environment={environment}
isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined}
canEditButNotView={secretValueHidden && !isOverride}
/>
</div>
</div>
);
};
@@ -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) => (
<Tr
key={`no-access-secret-${j + 1}`}
isHoverable
isSelectable
className="group border-mineshaft-500"
>
<ResourceNameCell
label={<Blur />}
iconClassName="text-bunker-400"
icon={faLock}
colWidth={colWidth}
tooltipContent="You do not have permission to view this secret"
/>
{environments.map(({ slug }, i) => {
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status="no-access"
key={`no-access-sec--${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
))}
</>
);
};
@@ -0,0 +1,226 @@
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 { Button, 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 (
<>
<Tr
isHoverable
isSelectable
onClick={() => setIsFormExpanded.toggle()}
className="group border-mineshaft-500"
>
<ResourceNameCell
colWidth={colWidth}
label={secretKey}
icon={faKey}
iconClassName="text-bunker-300"
isRowExpanded={isFormExpanded}
/>
{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 (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
key={`sec-overview-${slug}-${i + 1}-value`}
status={status}
/>
);
})}
</Tr>
{isFormExpanded && (
<Tr className="border-b-0">
<Td
colSpan={totalCols}
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-mineshaft-800 bg-clip-padding px-0 py-0"
>
<div
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-mineshaft-800 bg-clip-padding px-0 py-0"
>
<TableContainer className="rounded-none border-0">
<table className="secret-table">
<thead>
<tr className="h-10 border-b-2 border-mineshaft-600">
<th
style={{
width: colWidth
}}
className="min-table-row"
>
<span className="truncate">Environment</span>
</th>
<th style={{ padding: "0.5rem 1rem" }} className="border-none">
Value
</th>
<div className="absolute right-3 top-[1px] ml-auto mr-1 mt-1 w-min">
<Button
variant="plain"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />}
onClick={() => setIsSecretVisible.toggle()}
>
{isSecretVisible ? "Hide Values" : "Reveal Values"}
</Button>
</div>
</tr>
</thead>
<tbody className="border-t-2 border-mineshaft-600">
{environments.map(({ name, slug }) => {
const secret = getSecretByKey(slug, secretKey);
const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey);
const importedSecret = getImportedSecretByKey(slug, secretKey);
return (
<tr
key={`secret-expanded-${slug}-${secretKey}`}
className="h-full hover:bg-mineshaft-700/70"
>
<td
className="h-[1px] border-none !p-0"
style={{
width: colWidth
}}
>
<div
title={name}
style={{
width: colWidth
}}
className="flex h-full min-h-[40px] w-[8rem] items-center space-x-2 border-r border-mineshaft-500 px-4"
>
<span className="truncate">{name}</span>
{isImportedSecret && (
<Tooltip
content={`Imported secret from the '${importedSecret?.environmentInfo?.name}' environment`}
>
<FontAwesomeIcon icon={faFileImport} />
</Tooltip>
)}
{secret?.isRotatedSecret && (
<Tooltip content="Rotated Secret">
<FontAwesomeIcon icon={faRotate} />
</Tooltip>
)}
{secret?.valueOverride && (
<Tooltip content="Personal Override">
<FontAwesomeIcon icon={faCodeBranch} />
</Tooltip>
)}
</div>
</td>
<td className="col-span-2 h-8 w-full">
<EnvironmentSecretRow
secretPath={secretPath}
isVisible={isSecretVisible}
secretValueHidden={secret?.secretValueHidden || false}
defaultValue={getDefaultValue(secret, importedSecret)}
isOverride={Boolean(secret?.valueOverride)}
isImportedSecret={isImportedSecret}
environment={slug}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -0,0 +1,2 @@
export { SecretNoAccessRow } from "./SecretNoAccessRow";
export { SecretRow } from "./SecretRow";
@@ -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 (
<Td
className={twMerge(
"border-b border-mineshaft-500 bg-clip-padding p-0 group-hover:bg-mineshaft-600",
(status === "present" || status === "imported") && "text-green-600",
status === "empty" && "text-yellow",
status === "missing" && "text-red-600",
status === "no-access" && "text-bunker-400"
)}
>
<div
className={twMerge(
"flex h-10 items-center justify-center border-mineshaft-500 px-0 py-3",
!isLast && "border-r"
)}
>
<div className="flex justify-center">
<Tooltip center content={tooltipContent}>
<FontAwesomeIcon className={iconClassName} icon={icon} />
</Tooltip>
</div>
</div>
</Td>
);
};
@@ -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 (
<Td
className="sticky left-0 z-10 border-b border-mineshaft-500 bg-mineshaft-700 bg-clip-padding p-0 group-hover:bg-mineshaft-600"
style={{
width: colWidth
}}
>
<Tooltip content={tooltipContent} className="max-w-sm">
<div
style={{
width: colWidth
}}
className="flex h-10 items-center space-x-5 border-r border-mineshaft-600 px-4 py-2.5"
>
<div className="w-5 min-w-5">
<FontAwesomeIcon className={iconClassName} icon={isRowExpanded ? faAngleDown : icon} />
</div>
{typeof label === "string" ? <span className="truncate">{label}</span> : label}
</div>
</Tooltip>
</Td>
);
};
@@ -0,0 +1,2 @@
export * from "./EnvironmentStatusCell";
export * from "./ResourceNameCell";
@@ -0,0 +1 @@
export * from "./CompareEnvironments";
@@ -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 (
<>
<Tabs
value={currentEnv}
onValueChange={(value) => {
if (value === COMPARE_ENVIRONMENT_TAB) {
handlePopUpOpen("compareEnvironments");
return;
}
if (value === ADD_ENVIRONMENT_TAB) {
handleAddEnvironment();
return;
}
handleSelect(value);
}}
defaultValue="environment-tabs"
>
<TabList>
{tabEnvironments.map((environment) => (
<Tab className="max-w-[12vw] truncate" value={environment.slug}>
<p className="truncate">{environment.name}</p>
</Tab>
))}
{dropdownEnvironments.length ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Tab value={VIEW_MORE_ENVIRONMENT_TAB} className="p-0">
<Tooltip content="More Environments">
<div className="px-3">
<FontAwesomeIcon icon={faEllipsisH} />
</div>
</Tooltip>
</Tab>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
sideOffset={2}
align="end"
>
<DropdownMenuLabel>Environments</DropdownMenuLabel>
<div className="thin-scrollbar max-h-[40vh] overflow-auto">
{dropdownEnvironments.map((environment) => (
<DropdownMenuItem
key={environment.id}
onClick={(e) => {
e.stopPropagation();
handleSelect(environment.slug);
}}
>
{environment.name}
</DropdownMenuItem>
))}
</div>
<div className="h-1 border-t border-mineshaft-600" />
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<DropdownMenuItem
isDisabled={!isAllowed}
onClick={handleAddEnvironment}
className="data-[highlighted]:bg-mineshaft-900"
>
<Button
size="xs"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
className="w-full"
>
Add Environment
</Button>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tab value={ADD_ENVIRONMENT_TAB} className="p-0">
<Tooltip content="Add environment">
<div className="px-3">
<FontAwesomeIcon icon={faPlus} />
</div>
</Tooltip>
</Tab>
)}
{currentWorkspace.environments.length > 1 && (
<Tab className="ml-auto" value={COMPARE_ENVIRONMENT_TAB}>
<div className="flex items-center gap-x-2 whitespace-nowrap">
<FontAwesomeIcon icon={faArrowRightArrowLeft} />
Compare Environments
</div>
</Tab>
)}
</TabList>
</Tabs>
<Modal
isOpen={popUp.compareEnvironments.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("compareEnvironments", isOpen)}
>
<ModalContent
title="Compare Environments"
subTitle="Compare secrets across multiple environments"
className="flex h-full !w-[95vw] max-w-none flex-col"
bodyClassName="flex-1 flex flex-col overflow-hidden"
>
<CompareEnvironments secretPath={secretPath} />
</ModalContent>
</Modal>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
<AddEnvironmentModal
isOpen={popUp.createEnvironment.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createEnvironment", isOpen)}
onComplete={async (env) => {
await queryClient.refetchQueries({
queryKey: workspaceKeys.getWorkspaceById(currentWorkspace.id)
});
handleSelect(env.slug);
}}
/>
</>
);
};
@@ -0,0 +1 @@
export * from "./EnvironmentTabs";
@@ -3,16 +3,16 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; 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 { useWorkspace } from "@app/context";
import { useCreateWsEnvironment } from "@app/hooks/api"; 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"; import { slugSchema } from "@app/lib/schemas";
type Props = { type Props = {
popUp: UsePopUpState<["createEnv"]>; isOpen: boolean;
handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void; onOpenChange: (isOpen: boolean) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; onComplete?: (environment: WorkspaceEnv) => void;
}; };
const schema = z.object({ const schema = z.object({
@@ -24,10 +24,14 @@ const schema = z.object({
export type FormData = z.infer<typeof schema>; export type FormData = z.infer<typeof schema>;
export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { type ContentProps = {
onComplete: (environment: WorkspaceEnv) => void;
};
const Content = ({ onComplete }: ContentProps) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useCreateWsEnvironment(); const { mutateAsync, isPending } = useCreateWsEnvironment();
const { control, handleSubmit, reset } = useForm<FormData>({ const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema) resolver: zodResolver(schema)
}); });
@@ -35,7 +39,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle
try { try {
if (!currentWorkspace?.id) return; if (!currentWorkspace?.id) return;
await mutateAsync({ const env = await mutateAsync({
workspaceId: currentWorkspace.id, workspaceId: currentWorkspace.id,
name: environmentName, name: environmentName,
slug: environmentSlug slug: environmentSlug
@@ -46,7 +50,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle
type: "success" type: "success"
}); });
handlePopUpClose("createEnv"); onComplete(env);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
@@ -57,64 +61,62 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle
}; };
return ( return (
<Modal <form onSubmit={handleSubmit(onFormSubmit)}>
isOpen={popUp?.createEnv?.isOpen} <Controller
onOpenChange={(isOpen) => { control={control}
handlePopUpToggle("createEnv", isOpen); defaultValue=""
reset(); name="environmentName"
}} render={({ field, fieldState: { error } }) => (
> <FormControl label="Environment Name" isError={Boolean(error)} errorText={error?.message}>
<ModalContent title="Create a new environment"> <Input {...field} />
<form onSubmit={handleSubmit(onFormSubmit)}> </FormControl>
<Controller )}
control={control} />
defaultValue="" <Controller
name="environmentName" control={control}
render={({ field, fieldState: { error } }) => ( defaultValue=""
<FormControl name="environmentSlug"
label="Environment Name" render={({ field, fieldState: { error } }) => (
isError={Boolean(error)} <FormControl
errorText={error?.message} label="Environment Slug"
> helperText="Slugs are shorthands used in cli to access environment"
<Input {...field} /> isError={Boolean(error)}
</FormControl> errorText={error?.message}
)} >
/> <Input {...field} />
<Controller </FormControl>
control={control} )}
defaultValue="" />
name="environmentSlug" <div className="mt-8 flex items-center">
render={({ field, fieldState: { error } }) => ( <Button
<FormControl className="mr-4"
label="Environment Slug" size="sm"
helperText="Slugs are shorthands used in cli to access environment" type="submit"
isError={Boolean(error)} isLoading={isPending}
errorText={error?.message} isDisabled={isPending}
> >
<Input {...field} /> Create
</FormControl> </Button>
)} <ModalClose asChild>
/> <Button colorSchema="secondary" variant="plain">
<div className="mt-8 flex items-center"> Cancel
<Button </Button>
className="mr-4" </ModalClose>
size="sm" </div>
type="submit" </form>
isLoading={isPending} );
isDisabled={isPending} };
>
Create
</Button>
<Button export const AddEnvironmentModal = ({ onComplete, ...props }: Props) => {
onClick={() => handlePopUpClose("createEnv")} return (
colorSchema="secondary" <Modal {...props}>
variant="plain" <ModalContent title="Create a new environment">
> <Content
Cancel onComplete={(env) => {
</Button> if (onComplete) onComplete(env);
</div> props.onOpenChange(false);
</form> }}
/>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
@@ -103,9 +103,8 @@ export const EnvironmentSection = () => {
<PermissionDeniedBanner /> <PermissionDeniedBanner />
)} )}
<AddEnvironmentModal <AddEnvironmentModal
popUp={popUp} isOpen={popUp.createEnv.isOpen}
handlePopUpClose={handlePopUpClose} onOpenChange={(isOpen) => handlePopUpToggle("createEnv", isOpen)}
handlePopUpToggle={handlePopUpToggle}
/> />
<UpdateEnvironmentModal <UpdateEnvironmentModal
popUp={popUp} popUp={popUp}