mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 18:00:54 +00:00
feature: secret scanning architecture and github data source (wip)
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { faBan, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
|
||||
import {
|
||||
SecretScanningDataSource,
|
||||
useGetSecretScanningDataSource
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import {
|
||||
SecretScanningDataSourceSection,
|
||||
SecretScanningResourceSection,
|
||||
SecretScanningScanSection
|
||||
} from "./components";
|
||||
|
||||
const PageContent = () => {
|
||||
const navigate = useNavigate();
|
||||
const { type, dataSourceId, projectId } = useParams({
|
||||
from: ROUTE_PATHS.SecretScanning.DataSourceByIdPage.id,
|
||||
select: (params) => ({
|
||||
...params,
|
||||
type: params.type as SecretScanningDataSource
|
||||
})
|
||||
});
|
||||
|
||||
const { data: dataSource, isPending } = useGetSecretScanningDataSource({ type, dataSourceId });
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const details = SECRET_SCANNING_DATA_SOURCE_MAP[type];
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-20">
|
||||
<EmptyState
|
||||
className="max-w-2xl rounded-md text-center"
|
||||
icon={faBan}
|
||||
title={`Could not find ${details.name ?? "Secret Scanning"} Data Source with ID ${dataSourceId}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/secret-scanning/$projectId/data-sources",
|
||||
params: {
|
||||
projectId
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Data Sources
|
||||
</Button>
|
||||
<div className="mb-6 flex w-full items-center gap-3">
|
||||
<img
|
||||
alt={`${details.name} data source`}
|
||||
src={`/images/integrations/${details.image}`}
|
||||
className="ml-1 mt-3 w-14"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-3xl font-semibold text-white">{dataSource.name}</p>
|
||||
<p className="leading-3 text-bunker-300">{details.name} Data Source</p>
|
||||
</div>
|
||||
{/* <SecretSyncActionTriggers dataSource={dataSource} /> */}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="mr-4 flex w-72 flex-col gap-4">
|
||||
<SecretScanningDataSourceSection dataSource={dataSource} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<SecretScanningResourceSection dataSource={dataSource} />
|
||||
<SecretScanningScanSection dataSource={dataSource} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SecretScanningDataSourceByIdPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Secret Scanning Data Source | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
passThrough={false}
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.Read}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
<PageContent />
|
||||
</ProjectPermissionCan>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
SecretScanningDataSource,
|
||||
TSecretScanningDataSource
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { GitHubDataSourceConfigDisplay } from "./GitHubDataSourceConfigDisplay";
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
export const DataSourceConfigDisplay = ({ dataSource }: Props) => {
|
||||
switch (dataSource.type) {
|
||||
case SecretScanningDataSource.GitHub:
|
||||
return <GitHubDataSourceConfigDisplay dataSource={dataSource} />;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled dataSource type ${(dataSource as TSecretScanningDataSource).type}`
|
||||
);
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { GenericFieldLabel } from "@app/components/v2";
|
||||
import { TGitHubDataSource } from "@app/hooks/api/secretScanningV2/types/github-data-source";
|
||||
|
||||
type Props = {
|
||||
dataSource: TGitHubDataSource;
|
||||
};
|
||||
|
||||
export const GitHubDataSourceConfigDisplay = ({ dataSource }: Props) => {
|
||||
const {
|
||||
config: { includeRepos }
|
||||
} = dataSource;
|
||||
|
||||
return (
|
||||
<GenericFieldLabel label="Scan Repositories">
|
||||
{includeRepos.includes("*") ? "All" : includeRepos.join(", ")}
|
||||
</GenericFieldLabel>
|
||||
);
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { faBan, faCheck, faEdit } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { EditSecretScanningDataSourceModal } from "@app/components/secret-scanning";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { Badge, IconButton } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
|
||||
import { DataSourceConfigDisplay } from "@app/pages/secret-scanning/SecretScanningDataSourceByIdPage/components/DataSourceConfigDisplay/DataSourceConfigDisplay";
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
export const SecretScanningDataSourceSection = ({ dataSource }: Props) => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["editDataSource"] as const);
|
||||
|
||||
const { name, description, connection, isAutoScanEnabled } = dataSource;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
|
||||
<h3 className="font-semibold text-mineshaft-100">Details</h3>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.Edit}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="Edit data source"
|
||||
onClick={() => handlePopUpOpen("editDataSource")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
|
||||
{connection && (
|
||||
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
|
||||
)}
|
||||
<DataSourceConfigDisplay dataSource={dataSource} />
|
||||
<GenericFieldLabel label="Auto-Scan">
|
||||
{isAutoScanEnabled ? (
|
||||
<Badge
|
||||
variant="success"
|
||||
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
<span>Enabled</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
|
||||
<FontAwesomeIcon icon={faBan} />
|
||||
<span>Disabled</span>
|
||||
</Badge>
|
||||
)}
|
||||
</GenericFieldLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditSecretScanningDataSourceModal
|
||||
dataSource={dataSource}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("editDataSource", isOpen)}
|
||||
isOpen={popUp.editDataSource.isOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faCopy,
|
||||
faEllipsisV,
|
||||
faExpand,
|
||||
faInfoCircle,
|
||||
faSearch,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretScanningScanStatusBadge } from "@app/components/secret-scanning";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionSecretScanningDataSourceActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
SecretScanningFindingStatus,
|
||||
SecretScanningScanStatus,
|
||||
TSecretScanningDataSource,
|
||||
TSecretScanningResourceWithDetails,
|
||||
useTriggerSecretScanningDataSource
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
type Props = {
|
||||
resource: TSecretScanningResourceWithDetails;
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
export const SecretScanningResourceRow = ({ resource, dataSource }: Props) => {
|
||||
const { id, name, lastScannedAt, lastScanStatus, unresolvedFindings, lastScanStatusMessage } =
|
||||
resource;
|
||||
|
||||
const triggerDataSourceScan = useTriggerSecretScanningDataSource();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleTriggerScan = async () => {
|
||||
try {
|
||||
await triggerDataSourceScan.mutateAsync({
|
||||
dataSourceId: dataSource.id,
|
||||
type: dataSource.type,
|
||||
projectId: dataSource.projectId,
|
||||
resourceId: id
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully triggered scan for ${name}`,
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: `Failed to trigger scan for ${name}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
const handleCopyId = useCallback(
|
||||
(idToCopy: string) => {
|
||||
setIsIdCopied.on();
|
||||
navigator.clipboard.writeText(idToCopy);
|
||||
|
||||
createNotification({
|
||||
text: "Resource ID copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[isIdCopied]
|
||||
);
|
||||
|
||||
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className={twMerge(
|
||||
"group h-10 transition-colors duration-100 hover:bg-mineshaft-700",
|
||||
lastScanStatus === SecretScanningScanStatus.Failed && "bg-red/5 hover:bg-red/10"
|
||||
)}
|
||||
key={`resource-${id}`}
|
||||
>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<div className="flex w-full items-center">
|
||||
<p className="truncate">{name}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{lastScanStatus?.match(/queued|scanning/) ? (
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="flex h-5 w-min animate-pulse items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
<span>Scanning For Leaks</span>
|
||||
</Badge>
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
lastScannedAt ? (
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
unresolvedFindings ? (
|
||||
<Badge
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/secret-scanning/$projectId/findings",
|
||||
params: {
|
||||
projectId: dataSource.projectId
|
||||
},
|
||||
search: {
|
||||
search: name,
|
||||
status: SecretScanningFindingStatus.Unresolved
|
||||
}
|
||||
})
|
||||
}
|
||||
variant="primary"
|
||||
className="flex h-5 w-min cursor-pointer items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>
|
||||
{unresolvedFindings} Secret{unresolvedFindings > 1 ? "s" : ""} Detected
|
||||
</span>
|
||||
</Badge>
|
||||
) : lastScanStatus === SecretScanningScanStatus.Failed ? (
|
||||
<span className="text-mineshaft-400">No findings</span>
|
||||
) : (
|
||||
<Badge
|
||||
variant="success"
|
||||
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
No Secrets Detected
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-mineshaft-400">No findings</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="whitespace-nowrap">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{lastScanStatus?.match(/queued|scanning|failed/) ? (
|
||||
<SecretScanningScanStatusBadge
|
||||
status={lastScanStatus}
|
||||
statusMessage={lastScanStatusMessage}
|
||||
scannedAt={lastScannedAt}
|
||||
/>
|
||||
) : lastScannedAt ? (
|
||||
formatDistance(new Date(lastScannedAt), new Date(), { addSuffix: true })
|
||||
) : (
|
||||
<span className="text-mineshaft-400">No scans</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyId(id);
|
||||
}}
|
||||
>
|
||||
Copy Resource ID
|
||||
</DropdownMenuItem>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.TriggerScans}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faExpand} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTriggerScan();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
position="left"
|
||||
sideOffset={42}
|
||||
content={`Manually trigger a scan for this ${resourceDetails.singularNoun}.`}
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-between gap-1">
|
||||
<span> Trigger Scan</span>
|
||||
<FontAwesomeIcon
|
||||
className="text-bunker-300"
|
||||
size="sm"
|
||||
icon={faInfoCircle}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { faExpand } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
RESOURCE_DESCRIPTION_HELPER,
|
||||
SECRET_SCANNING_DATA_SOURCE_MAP
|
||||
} from "@app/helpers/secretScanningV2";
|
||||
import {
|
||||
TSecretScanningDataSource,
|
||||
useTriggerSecretScanningDataSource
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningResourcesTable } from "./SecretScanningResourceTable";
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
export const SecretScanningResourceSection = ({ dataSource }: Props) => {
|
||||
const triggerDataSourceScan = useTriggerSecretScanningDataSource();
|
||||
|
||||
const handleTriggerScan = async () => {
|
||||
try {
|
||||
await triggerDataSourceScan.mutateAsync({
|
||||
dataSourceId: dataSource.id,
|
||||
type: dataSource.type,
|
||||
projectId: dataSource.projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully triggered scan for ${dataSource.name}`,
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: `Failed to trigger scan for ${dataSource.name}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">{resourceDetails.pluralTitle}</p>
|
||||
<p className="text-sm text-bunker-300">
|
||||
{resourceDetails.pluralTitle} associated with this{" "}
|
||||
{SECRET_SCANNING_DATA_SOURCE_MAP[dataSource.type].name} Data Source
|
||||
</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.TriggerScans}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faExpand} />}
|
||||
onClick={handleTriggerScan}
|
||||
isDisabled={!isAllowed || triggerDataSourceScan.isPending}
|
||||
isLoading={triggerDataSourceScan.isPending}
|
||||
>
|
||||
Scan {resourceDetails.pluralTitle}
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<SecretScanningResourcesTable dataSource={dataSource} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import {
|
||||
SecretScanningScanStatus,
|
||||
TSecretScanningDataSource,
|
||||
useListSecretScanningResources
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningResourceRow } from "./SecretScanningResourceRow";
|
||||
|
||||
enum ResourcesOrderBy {
|
||||
Name = "name",
|
||||
Findings = "findings",
|
||||
LastScanned = "last-scanned"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
export const SecretScanningResourcesTable = ({ dataSource }: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const canReadResources = permission.can(
|
||||
ProjectPermissionSecretScanningDataSourceActions.ReadResources,
|
||||
ProjectPermissionSub.SecretScanningDataSources
|
||||
);
|
||||
|
||||
const { data: resources = [], isPending: isResourcesPending } = useListSecretScanningResources(
|
||||
{ dataSourceId: dataSource.id, type: dataSource.type },
|
||||
{
|
||||
refetchInterval: 30000,
|
||||
enabled: canReadResources
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<ResourcesOrderBy>(ResourcesOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const filteredResources = useMemo(
|
||||
() =>
|
||||
resources
|
||||
.filter((resource) => {
|
||||
const { name } = resource;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return name.toLowerCase().includes(searchValue);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [resourceOne, resourceTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case ResourcesOrderBy.Findings:
|
||||
if (
|
||||
resourceOne.unresolvedFindings === 0 &&
|
||||
resourceOne.lastScanStatus === SecretScanningScanStatus.Failed
|
||||
)
|
||||
return 1;
|
||||
if (
|
||||
resourceTwo.unresolvedFindings === 0 &&
|
||||
resourceTwo.lastScanStatus === SecretScanningScanStatus.Failed
|
||||
)
|
||||
return -1;
|
||||
|
||||
return resourceTwo.unresolvedFindings - resourceOne.unresolvedFindings;
|
||||
case ResourcesOrderBy.LastScanned:
|
||||
return (
|
||||
new Date(resourceTwo.lastScannedAt).getTime() -
|
||||
new Date(resourceOne.lastScannedAt).getTime()
|
||||
);
|
||||
case ResourcesOrderBy.Name:
|
||||
default:
|
||||
return resourceOne.name.toLowerCase().localeCompare(resourceTwo.name.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[resources, orderDirection, search, orderBy]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredResources.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: ResourcesOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: ResourcesOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: ResourcesOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder={`Search ${resourceDetails.pluralNoun}...`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/2">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(ResourcesOrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ResourcesOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(ResourcesOrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/4">
|
||||
<div className="flex items-center">
|
||||
Findings
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(ResourcesOrderBy.Findings)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ResourcesOrderBy.Findings)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(ResourcesOrderBy.Findings)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
Last Scan
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(ResourcesOrderBy.LastScanned)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ResourcesOrderBy.LastScanned)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(ResourcesOrderBy.LastScanned)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{canReadResources && isResourcesPending && (
|
||||
<TableSkeleton columns={5} innerKey="resource" />
|
||||
)}
|
||||
{filteredResources.slice(offset, perPage * page).map((resource) => (
|
||||
<SecretScanningResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
dataSource={dataSource}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!canReadResources && (
|
||||
<EmptyState
|
||||
icon={faBan}
|
||||
title={`You do not have permission to view data source ${resourceDetails.pluralNoun}`}
|
||||
/>
|
||||
)}
|
||||
{Boolean(filteredResources.length) && (
|
||||
<Pagination
|
||||
count={filteredResources.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isResourcesPending && !filteredResources?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
resources.length
|
||||
? "No resources match search..."
|
||||
: `This data source has no ${resourceDetails.pluralNoun} associated with it.`
|
||||
}
|
||||
icon={resources.length ? faSearch : undefined}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { useCallback } from "react";
|
||||
import { faCheck, faCopy, faEllipsisV, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { SecretScanningScanStatusBadge } from "@app/components/secret-scanning";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
SecretScanningScanStatus,
|
||||
SecretScanningScanType,
|
||||
TSecretScanningScanWithDetails
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
type Props = {
|
||||
scan: TSecretScanningScanWithDetails;
|
||||
};
|
||||
|
||||
export const SecretScanningScanRow = ({ scan }: Props) => {
|
||||
const {
|
||||
id,
|
||||
resourceName,
|
||||
createdAt,
|
||||
status,
|
||||
statusMessage,
|
||||
unresolvedFindings,
|
||||
resolvedFindings,
|
||||
type
|
||||
} = scan;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const totalFindings = resolvedFindings + unresolvedFindings;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
const handleCopyId = useCallback(() => {
|
||||
setIsIdCopied.on();
|
||||
navigator.clipboard.writeText(id);
|
||||
|
||||
createNotification({
|
||||
text: "Scan ID copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
}, [isIdCopied]);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className={twMerge(
|
||||
"group h-10 transition-colors duration-100 hover:bg-mineshaft-700",
|
||||
status === SecretScanningScanStatus.Failed && "bg-red/5 hover:bg-red/10"
|
||||
)}
|
||||
key={`scan-${id}`}
|
||||
>
|
||||
<Td>
|
||||
<p>
|
||||
{format(createdAt, "MMM dd yyyy")}{" "}
|
||||
<span className="text-mineshaft-300">{format(createdAt, "h:mm aa")}</span>
|
||||
</p>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<div className="flex flex-col">
|
||||
<p className="truncate">{resourceName}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="whitespace-nowrap">
|
||||
{type === SecretScanningScanType.FullScan ? "Full scan" : "Diff scan"}
|
||||
</Td>
|
||||
<Td>
|
||||
{
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
status?.match(/queued|scanning|failed/) ? (
|
||||
<SecretScanningScanStatusBadge status={status} statusMessage={statusMessage} />
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
totalFindings ? (
|
||||
<div className="flex flex-col">
|
||||
<Badge
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/secret-scanning/$projectId/findings",
|
||||
params: {
|
||||
projectId: currentWorkspace.id
|
||||
},
|
||||
search: {
|
||||
search: `scanId:${id}`
|
||||
}
|
||||
})
|
||||
}
|
||||
variant={unresolvedFindings ? "primary" : undefined}
|
||||
className={twMerge(
|
||||
"flex h-5 w-min cursor-pointer items-center gap-1.5 whitespace-nowrap",
|
||||
!unresolvedFindings && "bg-mineshaft-400/50 text-bunker-300"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={unresolvedFindings ? faWarning : faCheck} />
|
||||
<span className="text-xs">
|
||||
{totalFindings}{" "}
|
||||
{unresolvedFindings
|
||||
? `Secret ${totalFindings > 1 ? "s" : ""} Detected`
|
||||
: "Leak Resolved"}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
) : status === SecretScanningScanStatus.Failed ? (
|
||||
<span className="text-mineshaft-400">No findings</span>
|
||||
) : (
|
||||
<Badge
|
||||
variant="success"
|
||||
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
No Secrets Detected
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyId();
|
||||
}}
|
||||
>
|
||||
Copy Scan ID
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningScanTable } from "./SecretScanningScanTable";
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
export const SecretScanningScanSection = ({ dataSource }: Props) => {
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Scan History</p>
|
||||
</div>
|
||||
<SecretScanningScanTable dataSource={dataSource} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faCheckCircle,
|
||||
faExpand,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import {
|
||||
SecretScanningScanStatus,
|
||||
TSecretScanningDataSource,
|
||||
useListSecretScanningScans
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningScanRow } from "./SecretScanningScanRow";
|
||||
|
||||
enum ScansOrderBy {
|
||||
ResourceName = "resource-name",
|
||||
Findings = "findings",
|
||||
Timestamp = "timestamp",
|
||||
Status = "status"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSource;
|
||||
};
|
||||
|
||||
type ScanFilters = {
|
||||
resourceNames: string[];
|
||||
};
|
||||
|
||||
export const SecretScanningScanTable = ({ dataSource }: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const canReadScans = permission.can(
|
||||
ProjectPermissionSecretScanningDataSourceActions.ReadScans,
|
||||
ProjectPermissionSub.SecretScanningDataSources
|
||||
);
|
||||
|
||||
const { data: scans = [], isPending: isScansPending } = useListSecretScanningScans(
|
||||
{ dataSourceId: dataSource.id, type: dataSource.type },
|
||||
{
|
||||
refetchInterval: 30000,
|
||||
enabled: canReadScans
|
||||
}
|
||||
);
|
||||
|
||||
const [filters, setFilters] = useState<ScanFilters>({
|
||||
resourceNames: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<ScansOrderBy>(ScansOrderBy.Timestamp, { initPerPage: 10 });
|
||||
|
||||
const filteredResources = useMemo(
|
||||
() =>
|
||||
scans
|
||||
.filter((scan) => {
|
||||
const { resourceName } = scan;
|
||||
|
||||
if (filters.resourceNames.length && !filters.resourceNames.includes(resourceName))
|
||||
return false;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return resourceName.toLowerCase().includes(searchValue);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [scanOne, scanTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case ScansOrderBy.Findings:
|
||||
if (
|
||||
scanOne.unresolvedFindings === 0 &&
|
||||
scanOne.status === SecretScanningScanStatus.Failed
|
||||
)
|
||||
return 1;
|
||||
if (
|
||||
scanTwo.unresolvedFindings === 0 &&
|
||||
scanTwo.status === SecretScanningScanStatus.Failed
|
||||
)
|
||||
return -1;
|
||||
|
||||
return scanTwo.unresolvedFindings - scanOne.unresolvedFindings;
|
||||
case ScansOrderBy.Timestamp:
|
||||
return new Date(scanTwo.createdAt).getTime() - new Date(scanOne.createdAt).getTime();
|
||||
case ScansOrderBy.ResourceName:
|
||||
default:
|
||||
return scanOne.resourceName
|
||||
.toLowerCase()
|
||||
.localeCompare(scanTwo.resourceName.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[scans, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredResources.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: ScansOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: ScansOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: ScansOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered = Boolean(filters.resourceNames.length);
|
||||
|
||||
const resourceDetails = RESOURCE_DESCRIPTION_HELPER[dataSource.type];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search scans..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter data sources"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>{resourceDetails.pluralTitle}</DropdownMenuLabel>
|
||||
{scans.length ? (
|
||||
[...new Set(scans.map(({ resourceName }) => resourceName))].map((resourceName) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
resourceNames: prev.resourceNames.includes(resourceName)
|
||||
? prev.resourceNames.filter((a) => a !== resourceName)
|
||||
: [...prev.resourceNames, resourceName]
|
||||
}));
|
||||
}}
|
||||
key={resourceName}
|
||||
icon={
|
||||
filters.resourceNames.includes(resourceName) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{resourceName}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Data Sources Configured</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
Timestamp
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(ScansOrderBy.Timestamp)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ScansOrderBy.Timestamp)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(ScansOrderBy.Timestamp)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/2">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(ScansOrderBy.ResourceName)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ScansOrderBy.ResourceName)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(ScansOrderBy.ResourceName)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-20">Type</Th>
|
||||
<Th className="w-1/5 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
Findings
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(ScansOrderBy.Findings)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ScansOrderBy.Findings)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(ScansOrderBy.Findings)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{canReadScans && isScansPending && <TableSkeleton columns={5} innerKey="resource" />}
|
||||
{filteredResources.slice(offset, perPage * page).map((scan) => (
|
||||
<SecretScanningScanRow key={scan.id} scan={scan} />
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!canReadScans && (
|
||||
<EmptyState icon={faBan} title="You do not have permission to view data source scans" />
|
||||
)}
|
||||
{Boolean(filteredResources.length) && (
|
||||
<Pagination
|
||||
count={filteredResources.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isScansPending && !filteredResources?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
scans.length
|
||||
? "No scans match search..."
|
||||
: "This data source has no scans associated with it."
|
||||
}
|
||||
icon={scans.length ? faSearch : faExpand}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./SecretScanningDataSourceSection";
|
||||
export * from "./SecretScanningResourceSection";
|
||||
export * from "./SecretScanningScanSection";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { SecretScanningDataSourceByIdPage } from "./SecretScanningDataSourceByIdPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/data-sources/$type/$dataSourceId"
|
||||
)({
|
||||
component: SecretScanningDataSourceByIdPage,
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Data Sources",
|
||||
link: linkOptions({
|
||||
to: "/secret-scanning/$projectId/data-sources",
|
||||
params
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Data Source"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { SecretScanningDataSourcesSection } from "./components";
|
||||
|
||||
export const SecretScanningDataSourcesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Secret Scanning" })}</title>
|
||||
</Helmet>
|
||||
<div className="h-full bg-bunker-800">
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Data Sources"
|
||||
description="Manage your Secret Scanning data sources."
|
||||
/>
|
||||
<SecretScanningDataSourcesSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
faBan,
|
||||
faCheck,
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faExpand,
|
||||
faInfoCircle,
|
||||
faSearch,
|
||||
faToggleOff,
|
||||
faToggleOn,
|
||||
faTrash,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { SecretScanningScanStatusBadge } from "@app/components/secret-scanning";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
RESOURCE_DESCRIPTION_HELPER,
|
||||
SECRET_SCANNING_DATA_SOURCE_MAP
|
||||
} from "@app/helpers/secretScanningV2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
SecretScanningFindingStatus,
|
||||
SecretScanningScanStatus,
|
||||
TSecretScanningDataSourceWithDetails
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
type Props = {
|
||||
dataSource: TSecretScanningDataSourceWithDetails;
|
||||
onDelete: (dataSource: TSecretScanningDataSourceWithDetails) => void;
|
||||
onTriggerScan: (dataSource: TSecretScanningDataSourceWithDetails) => void;
|
||||
onToggleEnableAutoScan: (dataSource: TSecretScanningDataSourceWithDetails) => void;
|
||||
onEdit: (dataSource: TSecretScanningDataSourceWithDetails) => void;
|
||||
};
|
||||
|
||||
export const SecretScanningDataSourceRow = ({
|
||||
dataSource,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onTriggerScan,
|
||||
onToggleEnableAutoScan
|
||||
}: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
isAutoScanEnabled,
|
||||
projectId,
|
||||
type,
|
||||
unresolvedFindings,
|
||||
lastScannedAt,
|
||||
lastScanStatus,
|
||||
lastScanStatusMessage
|
||||
} = dataSource;
|
||||
|
||||
const sourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[type];
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
const handleCopyId = useCallback(() => {
|
||||
setIsIdCopied.on();
|
||||
navigator.clipboard.writeText(id);
|
||||
|
||||
createNotification({
|
||||
text: "Data Source ID copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
}, [isIdCopied]);
|
||||
|
||||
const autoScanDescription = RESOURCE_DESCRIPTION_HELPER[type];
|
||||
|
||||
return (
|
||||
<Tr
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: ROUTE_PATHS.SecretScanning.DataSourceByIdPage.path,
|
||||
params: {
|
||||
dataSourceId: id,
|
||||
type,
|
||||
projectId
|
||||
}
|
||||
})
|
||||
}
|
||||
className={twMerge(
|
||||
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
|
||||
lastScanStatus === SecretScanningScanStatus.Failed && "bg-red/5 hover:bg-red/10"
|
||||
)}
|
||||
key={`data-source-${id}`}
|
||||
>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<div className="flex w-full items-center">
|
||||
<img
|
||||
alt={`${sourceDetails.name} Data Source`}
|
||||
src={`/images/integrations/${sourceDetails.image}`}
|
||||
className="w-5"
|
||||
/>
|
||||
<p className="ml-2 truncate">{sourceDetails.name}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<div className="flex w-full items-center">
|
||||
<p className="truncate">{name}</p>
|
||||
{description && (
|
||||
<Tooltip content={description}>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="ml-1 text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{lastScanStatus?.match(/queued|scanning/) ? (
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="flex h-5 w-min animate-pulse items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
<span>Scanning For Leaks</span>
|
||||
</Badge>
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
lastScannedAt ? (
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
unresolvedFindings ? (
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="flex h-5 w-min cursor-pointer items-center gap-1.5 whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: "/secret-scanning/$projectId/findings",
|
||||
params: {
|
||||
projectId
|
||||
},
|
||||
search: {
|
||||
search: name,
|
||||
status: SecretScanningFindingStatus.Unresolved
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>
|
||||
{unresolvedFindings} Secret{unresolvedFindings > 1 ? "s" : ""} Detected
|
||||
</span>
|
||||
</Badge>
|
||||
) : lastScanStatus === SecretScanningScanStatus.Failed ? (
|
||||
<span className="text-mineshaft-400">No findings</span>
|
||||
) : (
|
||||
<Badge
|
||||
variant="success"
|
||||
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
No Secrets Detected
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-mineshaft-400">No findings</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{lastScanStatus?.match(/queued|scanning|failed/) ? (
|
||||
<SecretScanningScanStatusBadge
|
||||
status={lastScanStatus}
|
||||
statusMessage={lastScanStatusMessage}
|
||||
scannedAt={lastScannedAt}
|
||||
/>
|
||||
) : lastScannedAt ? (
|
||||
<span>{formatDistance(new Date(lastScannedAt), new Date(), { addSuffix: true })}</span>
|
||||
) : (
|
||||
<span className="text-mineshaft-400">No scans</span>
|
||||
)}
|
||||
{!isAutoScanEnabled && (
|
||||
<Tooltip
|
||||
className="text-xs"
|
||||
content={`Auto-Scan is disabled. Scans will not be automatically triggered when a ${autoScanDescription.verb} occurs to ${autoScanDescription.pluralNoun} associated with this data source`}
|
||||
>
|
||||
<div className="ml-auto">
|
||||
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
|
||||
<FontAwesomeIcon icon={faBan} />
|
||||
<span>Auto-Scan Disabled</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyId();
|
||||
}}
|
||||
>
|
||||
Copy Data Source ID
|
||||
</DropdownMenuItem>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.TriggerScans}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faExpand} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTriggerScan(dataSource);
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip
|
||||
position="left"
|
||||
sideOffset={42}
|
||||
content={`Manually trigger a scan for this ${sourceDetails.name} data source.`}
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-between gap-1">
|
||||
<span> Trigger Scan</span>
|
||||
<FontAwesomeIcon
|
||||
className="text-bunker-300"
|
||||
size="sm"
|
||||
icon={faInfoCircle}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.Edit}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={isAutoScanEnabled ? faToggleOff : faToggleOn} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleEnableAutoScan(dataSource);
|
||||
}}
|
||||
>
|
||||
{isAutoScanEnabled ? "Disable" : "Enable"} Auto-Scan
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.Edit}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(dataSource);
|
||||
}}
|
||||
>
|
||||
Edit Data Source
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.Delete}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(dataSource);
|
||||
}}
|
||||
>
|
||||
Delete Data Source
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { CreateSecretScanningDataSourceModal } from "@app/components/secret-scanning";
|
||||
import { Button, Spinner } from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSecretScanningDataSourceActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useListSecretScanningDataSources } from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningDataSourcesTable } from "./SecretScanningDataSourcesTable";
|
||||
|
||||
export const SecretScanningDataSourcesSection = () => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addDataSource"] as const);
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: dataSources = [], isPending: isDataSourcesPending } =
|
||||
useListSecretScanningDataSources(currentWorkspace.id, {
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
if (isDataSourcesPending)
|
||||
return (
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Data Sources</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/secret-scanning/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Use App Connections to scan for secret leaks from third-party services.
|
||||
</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionSecretScanningDataSourceActions.Create}
|
||||
a={ProjectPermissionSub.SecretScanningDataSources}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addDataSource")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Data Source
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<SecretScanningDataSourcesTable dataSources={dataSources} />
|
||||
</div>
|
||||
<CreateSecretScanningDataSourceModal
|
||||
isOpen={popUp.addDataSource.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addDataSource", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPuzzlePiece,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { EditSecretScanningDataSourceModal } from "@app/components/secret-scanning";
|
||||
import { DeleteSecretScanningDataSourceModal } from "@app/components/secret-scanning/DeleteSecretScanningDataSourceModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import {
|
||||
SecretScanningDataSource,
|
||||
SecretScanningScanStatus,
|
||||
TSecretScanningDataSource,
|
||||
TSecretScanningDataSourceWithDetails,
|
||||
useTriggerSecretScanningDataSource,
|
||||
useUpdateSecretScanningDataSource
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningDataSourceRow } from "./SecretScanningDataSourceRow";
|
||||
|
||||
// import { getSecretSyncDestinationColValues } from "./helpers";
|
||||
|
||||
enum DataSourcesOrderBy {
|
||||
Findings = "findings",
|
||||
Name = "name",
|
||||
LastScan = "last-scan"
|
||||
}
|
||||
|
||||
type DataSourceFilters = {
|
||||
types: SecretScanningDataSource[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
dataSources: TSecretScanningDataSourceWithDetails[];
|
||||
};
|
||||
|
||||
export const SecretScanningDataSourcesTable = ({ dataSources }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"deleteDataSource",
|
||||
"editDataSource",
|
||||
"triggerDataSourceScan"
|
||||
] as const);
|
||||
const triggerDataSourceScan = useTriggerSecretScanningDataSource();
|
||||
const updateDataSource = useUpdateSecretScanningDataSource();
|
||||
|
||||
const [filters, setFilters] = useState<DataSourceFilters>({
|
||||
types: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<DataSourcesOrderBy>(DataSourcesOrderBy.Name, { initPerPage: 20 });
|
||||
|
||||
const filteredDataSources = useMemo(
|
||||
() =>
|
||||
dataSources
|
||||
.filter((dataSource) => {
|
||||
const { type, name, connection } = dataSource;
|
||||
|
||||
if (filters.types.length && !filters.types.includes(type)) return false;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
SECRET_SCANNING_DATA_SOURCE_MAP[type].name.toLowerCase().includes(searchValue) ||
|
||||
name.toLowerCase().includes(searchValue) ||
|
||||
connection.name.toLowerCase().includes(searchValue)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [dataSourceOne, dataSourceTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case DataSourcesOrderBy.LastScan:
|
||||
if (dataSourceOne.lastScannedAt === null && dataSourceTwo.lastScannedAt === null)
|
||||
return 0;
|
||||
if (dataSourceOne.lastScannedAt === null) return 1;
|
||||
if (dataSourceTwo.lastScannedAt === null) return -1;
|
||||
|
||||
return (
|
||||
new Date(dataSourceTwo.lastScannedAt).getTime() -
|
||||
new Date(dataSourceOne.lastScannedAt).getTime()
|
||||
);
|
||||
case DataSourcesOrderBy.Findings:
|
||||
if (
|
||||
dataSourceOne.unresolvedFindings === null &&
|
||||
dataSourceTwo.unresolvedFindings === null
|
||||
)
|
||||
return 0;
|
||||
if (dataSourceOne.unresolvedFindings === null) return 1;
|
||||
if (dataSourceTwo.unresolvedFindings === null) return -1;
|
||||
|
||||
if (
|
||||
dataSourceOne.unresolvedFindings === 0 &&
|
||||
dataSourceOne.lastScanStatus === SecretScanningScanStatus.Failed
|
||||
)
|
||||
return 1;
|
||||
if (
|
||||
dataSourceTwo.unresolvedFindings === 0 &&
|
||||
dataSourceTwo.lastScanStatus === SecretScanningScanStatus.Failed
|
||||
)
|
||||
return -1;
|
||||
|
||||
return dataSourceTwo.unresolvedFindings - dataSourceOne.unresolvedFindings;
|
||||
case DataSourcesOrderBy.Name:
|
||||
default:
|
||||
return dataSourceOne.name
|
||||
.toLowerCase()
|
||||
.localeCompare(dataSourceTwo.name.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[dataSources, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredDataSources.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: DataSourcesOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: DataSourcesOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: DataSourcesOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered = Boolean(filters.types.length);
|
||||
|
||||
const handleDelete = (dataSource: TSecretScanningDataSource) =>
|
||||
handlePopUpOpen("deleteDataSource", dataSource);
|
||||
|
||||
const handleEdit = (dataSource: TSecretScanningDataSource) =>
|
||||
handlePopUpOpen("editDataSource", dataSource);
|
||||
|
||||
const handleToggleEnableAutoScan = async (dataSource: TSecretScanningDataSource) => {
|
||||
const destinationName = SECRET_SCANNING_DATA_SOURCE_MAP[dataSource.type].name;
|
||||
|
||||
const isAutoScanEnabled = !dataSource.isAutoScanEnabled;
|
||||
|
||||
try {
|
||||
await updateDataSource.mutateAsync({
|
||||
dataSourceId: dataSource.id,
|
||||
type: dataSource.type,
|
||||
isAutoScanEnabled,
|
||||
projectId: dataSource.projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${isAutoScanEnabled ? "enabled" : "disabled"} auto-scan for ${destinationName} Data Source`,
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: `Failed to ${isAutoScanEnabled ? "enable" : "disable"} auto-scan for ${destinationName} Data Source`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerScan = async (dataSource: TSecretScanningDataSource) => {
|
||||
try {
|
||||
await triggerDataSourceScan.mutateAsync({
|
||||
dataSourceId: dataSource.id,
|
||||
type: dataSource.type,
|
||||
projectId: dataSource.projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully triggered scan",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to trigger scan",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search data sources..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter data sources"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Platform</DropdownMenuLabel>
|
||||
{dataSources.length ? (
|
||||
[...new Set(dataSources.map(({ type }) => type))].map((type) => {
|
||||
const { name, image } = SECRET_SCANNING_DATA_SOURCE_MAP[type];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
types: prev.types.includes(type)
|
||||
? prev.types.filter((a) => a !== type)
|
||||
: [...prev.types, type]
|
||||
}));
|
||||
}}
|
||||
key={type}
|
||||
icon={
|
||||
filters.types.includes(type) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${name} integration`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Data Sources Configured</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-60">Platform</Th>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(DataSourcesOrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(DataSourcesOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(DataSourcesOrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Findings
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(DataSourcesOrderBy.Findings)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(DataSourcesOrderBy.Findings)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(DataSourcesOrderBy.Findings)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Last Scan
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(DataSourcesOrderBy.LastScan)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(DataSourcesOrderBy.LastScan)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(DataSourcesOrderBy.LastScan)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredDataSources.slice(offset, perPage * page).map((dataSource) => (
|
||||
<SecretScanningDataSourceRow
|
||||
key={dataSource.id}
|
||||
dataSource={dataSource}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onTriggerScan={handleTriggerScan}
|
||||
onToggleEnableAutoScan={handleToggleEnableAutoScan}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredDataSources.length) && (
|
||||
<Pagination
|
||||
count={filteredDataSources.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!filteredDataSources?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
dataSources.length
|
||||
? "No data sources match search..."
|
||||
: "This project has no data sources configured"
|
||||
}
|
||||
icon={dataSources.length ? faSearch : faPuzzlePiece}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<DeleteSecretScanningDataSourceModal
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deleteDataSource", isOpen)}
|
||||
isOpen={popUp.deleteDataSource.isOpen}
|
||||
dataSource={popUp.deleteDataSource.data}
|
||||
/>
|
||||
<EditSecretScanningDataSourceModal
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("editDataSource", isOpen)}
|
||||
isOpen={popUp.editDataSource.isOpen}
|
||||
dataSource={popUp.editDataSource.data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretScanningDataSourcesSection } from "./SecretScanningDataSourcesSection";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { SecretScanningDataSourcesPage } from "./SecretScanningDataSourcesPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/data-sources/"
|
||||
)({
|
||||
component: SecretScanningDataSourcesPage
|
||||
// beforeLoad: ({ context }) => {
|
||||
// return {
|
||||
// breadcrumbs: [
|
||||
// ...context.breadcrumbs,
|
||||
// {
|
||||
// label: "Data Sources"
|
||||
// }
|
||||
// ]
|
||||
// };
|
||||
// }
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { SecretScanningFindingsSection } from "./components";
|
||||
|
||||
export const SecretScanningFindingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Secret Scanning" })}</title>
|
||||
</Helmet>
|
||||
<div className="h-full bg-bunker-800">
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader title="Findings" description="View Secret Leaks across your project." />
|
||||
<SecretScanningFindingsSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { useCallback } from "react";
|
||||
import { faCheck, faCopy, faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
SECRET_SCANNING_DATA_SOURCE_MAP,
|
||||
SECRET_SCANNING_FINDING_STATUS_ICON_MAP
|
||||
} from "@app/helpers/secretScanningV2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
SecretScanningFindingStatus,
|
||||
TSecretScanningFinding
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
type Props = {
|
||||
finding: TSecretScanningFinding;
|
||||
onUpdate: (finding: TSecretScanningFinding) => void;
|
||||
};
|
||||
|
||||
export const SecretScanningFindingRow = ({ finding, onUpdate }: Props) => {
|
||||
const {
|
||||
resourceName,
|
||||
id,
|
||||
dataSourceType,
|
||||
createdAt,
|
||||
dataSourceName,
|
||||
rule,
|
||||
status,
|
||||
details,
|
||||
remarks
|
||||
} = finding;
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
const handleCopyId = useCallback(
|
||||
(idToCopy: string) => {
|
||||
setIsIdCopied.on();
|
||||
navigator.clipboard.writeText(idToCopy);
|
||||
|
||||
createNotification({
|
||||
text: "Resource ID copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[isIdCopied]
|
||||
);
|
||||
|
||||
const sourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[dataSourceType];
|
||||
|
||||
const [isExpanded, setIsExpanded] = useToggle(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
onClick={setIsExpanded.toggle}
|
||||
className={twMerge(
|
||||
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
)}
|
||||
key={`resource-${id}`}
|
||||
>
|
||||
<Td className="!min-w-[4rem] max-w-0">
|
||||
<div className="flex w-full items-center">
|
||||
<img
|
||||
alt={`${sourceDetails.name} Data Source`}
|
||||
src={`/images/integrations/${sourceDetails.image}`}
|
||||
className="w-5"
|
||||
/>
|
||||
<p className="ml-2 truncate">{sourceDetails.name}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<p>{format(createdAt, "MMM dd yyyy")}</p>
|
||||
<p className="text-mineshaft-300">{format(createdAt, "h:mm aa")}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<div className="w-full items-center">
|
||||
<p className="truncate">{resourceName}</p>
|
||||
<p className="truncate text-xs text-mineshaft-400">{dataSourceName}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="whitespace-nowrap">{rule}</Td>
|
||||
<Td className="whitespace-nowrap">
|
||||
<Tooltip position="left" content={remarks}>
|
||||
<div className="w-min">
|
||||
<Badge
|
||||
variant={status === SecretScanningFindingStatus.Resolved ? "success" : "primary"}
|
||||
className={twMerge(
|
||||
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap",
|
||||
(status === SecretScanningFindingStatus.FalsePositive ||
|
||||
status === SecretScanningFindingStatus.Ignore) &&
|
||||
"bg-mineshaft-400/50 text-bunker-300"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].icon} />
|
||||
<span className="capitalize">{status.replace("-", " ")}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyId(id);
|
||||
}}
|
||||
>
|
||||
Copy Finding ID
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td colSpan={6} className="!border-none p-0">
|
||||
<div
|
||||
className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ${
|
||||
isExpanded ? "max-h-[50rem] opacity-100" : "max-h-0"
|
||||
}`}
|
||||
>
|
||||
<div className="grid gap-4 p-4 2xl:grid-cols-6">
|
||||
<GenericFieldLabel truncate className="col-span-full" label="Description">
|
||||
{details.description}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel truncate label="Date">
|
||||
{format(details.date, "MMM dd yyyy h:mm aa")}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel truncate label="Author">
|
||||
{details.author}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel className="col-span-4" truncate label="Email">
|
||||
{details.email}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel truncate className="col-span-full" label="File">
|
||||
{details.file}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel truncate className="col-span-full" label="Commit">
|
||||
{details.commit}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel className="col-span-full" label="Commit Message">
|
||||
{details.message}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Start Line">{details.startLine}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="End Line">{details.endLine}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Start Column">{details.startColumn}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="End Column">{details.endColumn}</GenericFieldLabel>
|
||||
<GenericFieldLabel className="col-span-full" label="Link">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer text-mineshaft-300 underline underline-offset-2 hover:text-mineshaft-100"
|
||||
href={details.link}
|
||||
>
|
||||
{details.link}
|
||||
</a>
|
||||
</GenericFieldLabel>
|
||||
|
||||
<Button
|
||||
onClick={() => onUpdate(finding)}
|
||||
colorSchema="secondary"
|
||||
leftIcon={
|
||||
<FontAwesomeIcon
|
||||
className={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].className}
|
||||
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].icon}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Update Status
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Spinner } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useListSecretScanningFindings } from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningFindingsTable } from "./SecretScanningFindingsTable";
|
||||
|
||||
export const SecretScanningFindingsSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: findings = [], isPending: isFindingsPending } = useListSecretScanningFindings(
|
||||
currentWorkspace.id,
|
||||
{
|
||||
refetchInterval: 30000
|
||||
}
|
||||
);
|
||||
|
||||
if (isFindingsPending)
|
||||
return (
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Findings</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/secret-scanning/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Secret leaks discovered across your data sources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SecretScanningFindingsTable findings={findings} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
SECRET_SCANNING_DATA_SOURCE_MAP,
|
||||
SECRET_SCANNING_FINDING_STATUS_ICON_MAP
|
||||
} from "@app/helpers/secretScanningV2";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import {
|
||||
SecretScanningDataSource,
|
||||
SecretScanningFindingStatus,
|
||||
TSecretScanningFinding
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningFindingRow } from "./SecretScanningFindingRow";
|
||||
import { SecretScanningUpdateFindingModal } from "./SecretScanningUpdateFindingModal";
|
||||
|
||||
enum FindingsOrderBy {
|
||||
ResourceName = "resource-name",
|
||||
Status = "status",
|
||||
Rule = "rule",
|
||||
Timestamp = "timestamp"
|
||||
}
|
||||
|
||||
type DataSourceFilters = {
|
||||
dataSourceTypes: SecretScanningDataSource[];
|
||||
status: SecretScanningFindingStatus[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
findings: TSecretScanningFinding[];
|
||||
};
|
||||
|
||||
const getStatusOrderValue = (status: SecretScanningFindingStatus) => {
|
||||
switch (status) {
|
||||
case SecretScanningFindingStatus.Unresolved:
|
||||
return 0;
|
||||
case SecretScanningFindingStatus.Ignore:
|
||||
return 1;
|
||||
case SecretScanningFindingStatus.FalsePositive:
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
export const SecretScanningFindingsTable = ({ findings }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["updateFinding"] as const);
|
||||
|
||||
const { search: initSearch, status: initStatus } = useSearch({
|
||||
from: ROUTE_PATHS.SecretScanning.FindingsPage.id
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<DataSourceFilters>({
|
||||
dataSourceTypes: [],
|
||||
status: initStatus ? [initStatus] : []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<FindingsOrderBy>(FindingsOrderBy.Timestamp, { initPerPage: 20, initSearch });
|
||||
|
||||
const filteredFindings = useMemo(
|
||||
() =>
|
||||
findings
|
||||
.filter((finding) => {
|
||||
const { rule, resourceName, dataSourceType, dataSourceName, status, scanId } = finding;
|
||||
|
||||
if (filters.dataSourceTypes.length && !filters.dataSourceTypes.includes(dataSourceType))
|
||||
return false;
|
||||
|
||||
if (filters.status.length && !filters.status.includes(status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (search.includes("scanId")) {
|
||||
return search.split(":")[1] === scanId;
|
||||
}
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
SECRET_SCANNING_DATA_SOURCE_MAP[dataSourceType].name
|
||||
.toLowerCase()
|
||||
.includes(searchValue) ||
|
||||
resourceName.toLowerCase().includes(searchValue) ||
|
||||
dataSourceName.toLowerCase().includes(searchValue) ||
|
||||
rule.toLowerCase().includes(searchValue)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [findingOne, findingTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case FindingsOrderBy.Timestamp:
|
||||
return (
|
||||
new Date(findingTwo.createdAt).getTime() - new Date(findingOne.createdAt).getTime()
|
||||
);
|
||||
case FindingsOrderBy.Rule:
|
||||
return findingOne.rule.toLowerCase().localeCompare(findingTwo.rule.toLowerCase());
|
||||
case FindingsOrderBy.Status:
|
||||
return (
|
||||
getStatusOrderValue(findingOne.status) - getStatusOrderValue(findingTwo.status)
|
||||
);
|
||||
case FindingsOrderBy.ResourceName:
|
||||
default:
|
||||
return findingOne.resourceName
|
||||
.toLowerCase()
|
||||
.localeCompare(findingTwo.resourceName.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[findings, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredFindings.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: FindingsOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: FindingsOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: FindingsOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered = Boolean(filters.dataSourceTypes.length || filters.status.length);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search findings..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter findings"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{Object.values(SecretScanningFindingStatus).map((status) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: prev.status.includes(status)
|
||||
? prev.status.filter((s) => s !== status)
|
||||
: [...prev.status, status]
|
||||
}));
|
||||
}}
|
||||
key={status}
|
||||
icon={
|
||||
filters.status.includes(status) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].icon}
|
||||
className={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[status].className}
|
||||
/>
|
||||
<span className="capitalize">{status.replace("-", " ")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Platform</DropdownMenuLabel>
|
||||
{findings.length ? (
|
||||
[...new Set(findings.map(({ dataSourceType: type }) => type))].map((type) => {
|
||||
const { name, image } = SECRET_SCANNING_DATA_SOURCE_MAP[type];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
dataSourceTypes: prev.dataSourceTypes.includes(type)
|
||||
? prev.dataSourceTypes.filter((a) => a !== type)
|
||||
: [...prev.dataSourceTypes, type]
|
||||
}));
|
||||
}}
|
||||
key={type}
|
||||
icon={
|
||||
filters.dataSourceTypes.includes(type) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${name} integration`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Data Source Findings</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="min-w-[10rem]">Platform</Th>
|
||||
<Th className="w-1/4">
|
||||
<div className="flex items-center">
|
||||
Timestamp
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(FindingsOrderBy.Timestamp)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(FindingsOrderBy.Timestamp)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.Timestamp)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
Resource Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(FindingsOrderBy.ResourceName)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(FindingsOrderBy.ResourceName)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.ResourceName)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/4">
|
||||
<div className="flex items-center">
|
||||
Rule
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(FindingsOrderBy.Rule)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(FindingsOrderBy.Rule)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.Rule)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/4">
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(FindingsOrderBy.Status)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(FindingsOrderBy.Status)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(FindingsOrderBy.Status)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredFindings.slice(offset, perPage * page).map((finding) => (
|
||||
<SecretScanningFindingRow
|
||||
key={finding.id}
|
||||
finding={finding}
|
||||
onUpdate={() => handlePopUpOpen("updateFinding", finding)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredFindings.length) && (
|
||||
<Pagination
|
||||
count={filteredFindings.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!filteredFindings?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
findings.length
|
||||
? "No findings match search..."
|
||||
: "This project has not uncovered any secret leaks"
|
||||
}
|
||||
icon={faSearch}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<SecretScanningUpdateFindingModal
|
||||
isOpen={popUp.updateFinding.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("updateFinding", isOpen)}
|
||||
finding={popUp.updateFinding.data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { SECRET_SCANNING_FINDING_STATUS_ICON_MAP } from "@app/helpers/secretScanningV2";
|
||||
import {
|
||||
SecretScanningFindingStatus,
|
||||
TSecretScanningFinding,
|
||||
useUpdateSecretScanningFinding
|
||||
} from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
type Props = {
|
||||
finding?: TSecretScanningFinding;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const FormSchema = z.object({
|
||||
remarks: z.string().optional(),
|
||||
status: z.nativeEnum(SecretScanningFindingStatus)
|
||||
});
|
||||
|
||||
type FormType = z.infer<typeof FormSchema>;
|
||||
|
||||
type ContentProps = {
|
||||
finding: TSecretScanningFinding;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
const Content = ({ finding, onComplete }: ContentProps) => {
|
||||
const updateFinding = useUpdateSecretScanningFinding();
|
||||
|
||||
const { handleSubmit, control } = useForm<FormType>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
status: finding.status,
|
||||
remarks: finding.remarks ?? ""
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormType) => {
|
||||
try {
|
||||
await updateFinding.mutateAsync({
|
||||
...data,
|
||||
findingId: finding.id,
|
||||
projectId: finding.projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Finding status successfully updated"
|
||||
});
|
||||
|
||||
onComplete();
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update Finding status"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl label="Status" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].icon}
|
||||
iconClassName={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].className}
|
||||
>
|
||||
{Object.values(SecretScanningFindingStatus).map((status) => {
|
||||
return (
|
||||
<SelectItem className="capitalize" value={status} key={status}>
|
||||
{status.replace("-", " ")}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="remarks"
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl label="Remarks" isError={Boolean(error)} errorText={error?.message}>
|
||||
<TextArea className="h-40 !resize-none" {...field} />
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateFinding.isPending}
|
||||
isDisabled={updateFinding.isPending}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Update Status
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const SecretScanningUpdateFindingModal = ({ finding, isOpen, onOpenChange }: Props) => {
|
||||
if (!finding) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Resolve Finding" subTitle="Mark this finding as resolved">
|
||||
<Content finding={finding} onComplete={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SecretScanningFindingsSection";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretScanningFindingStatus } from "@app/hooks/api/secretScanningV2";
|
||||
|
||||
import { SecretScanningFindingsPage } from "./SecretScanningFindingsPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/findings"
|
||||
)({
|
||||
validateSearch: zodValidator(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.nativeEnum(SecretScanningFindingStatus).optional().catch(undefined)
|
||||
})
|
||||
),
|
||||
beforeLoad: ({ context }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Findings"
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
component: SecretScanningFindingsPage
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
|
||||
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
|
||||
</Helmet>
|
||||
<div className="w-full max-w-7xl">
|
||||
<PageHeader title={t("settings.project.title")} />
|
||||
<Tabs defaultValue="tab-project-general">
|
||||
<TabList>
|
||||
<Tab value="tab-project-general">General</Tab>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Project}
|
||||
>
|
||||
{(isAllowed) =>
|
||||
isAllowed && <Tab value="tab-project-secret-scannings">Rule Settings</Tab>
|
||||
}
|
||||
</ProjectPermissionCan>
|
||||
</TabList>
|
||||
<TabPanel value="tab-project-general">
|
||||
<ProjectGeneralTab />
|
||||
</TabPanel>
|
||||
{/* <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
|
||||
{(isAllowed) =>
|
||||
isAllowed && (
|
||||
<TabPanel value="tab-project-ssh">
|
||||
<ProjectSshTab />
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
</ProjectPermissionCan> */}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useProjectPermission, useSubscription, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { useUpdateWorkspaceAuditLogsRetention } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
const formSchema = z.object({
|
||||
auditLogsRetentionDays: z.coerce.number().min(0)
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export const AuditLogsRetentionSection = () => {
|
||||
const { mutateAsync: updateAuditLogsRetention } = useUpdateWorkspaceAuditLogsRetention();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { membership } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting, isDirty },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
auditLogsRetentionDays:
|
||||
currentWorkspace?.auditLogsRetentionDays ?? subscription?.auditLogsRetentionDays ?? 0
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
const handleAuditLogsRetentionSubmit = async ({ auditLogsRetentionDays }: TForm) => {
|
||||
try {
|
||||
if (!subscription?.auditLogs) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can only configure audit logs retention if you upgrade your plan."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription && auditLogsRetentionDays > subscription?.auditLogsRetentionDays) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"To update your audit logs retention period to a higher value, upgrade your plan."
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAuditLogsRetention({
|
||||
auditLogsRetentionDays,
|
||||
projectSlug: currentWorkspace.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated audit logs retention period",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed updating audit logs retention period",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// render only for dedicated/self-hosted instances of Infisical
|
||||
if (
|
||||
window.location.origin.includes("https://app.infisical.com") ||
|
||||
window.location.origin.includes("https://gamma.infisical.com")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="text-xl font-semibold">Audit Logs Retention</p>
|
||||
</div>
|
||||
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
|
||||
Set the number of days to keep your project audit logs.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(handleAuditLogsRetentionSubmit)} autoComplete="off">
|
||||
<div className="max-w-xs">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={0}
|
||||
name="auditLogsRetentionDays"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Number of days"
|
||||
>
|
||||
<Input {...field} type="number" min={1} step={1} isDisabled={!isAdmin} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!isAdmin || !isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection";
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { LeaveProjectModal } from "@app/components/v2/LeaveProjectModal";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useDeleteWorkspace, useGetWorkspaceUsers, useLeaveProject } from "@app/hooks/api";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
export const DeleteProjectSection = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteWorkspace",
|
||||
"leaveWorkspace"
|
||||
] as const);
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { hasProjectRole, membership } = useProjectPermission();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const [isDeleting, setIsDeleting] = useToggle();
|
||||
const [isLeaving, setIsLeaving] = useToggle();
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
const leaveProject = useLeaveProject();
|
||||
const { data: members, isPending: isMembersLoading } = useGetWorkspaceUsers(
|
||||
currentWorkspace?.id || ""
|
||||
);
|
||||
|
||||
// If isNoAccessMember is true, then the user can't read the workspace members. So we need to handle this case separately.
|
||||
const isNoAccessMember = hasProjectRole("no-access");
|
||||
|
||||
const isOnlyAdminMember = useMemo(() => {
|
||||
if (!members || !membership || !hasProjectRole("admin")) return false;
|
||||
|
||||
const adminMembers = members.filter(
|
||||
(member) => member.roles.map((r) => r.role).includes("admin") && member.id !== membership.id // exclude the current user
|
||||
);
|
||||
|
||||
return !adminMembers.length;
|
||||
}, [members, membership]);
|
||||
|
||||
const handleDeleteWorkspaceSubmit = async () => {
|
||||
setIsDeleting.on();
|
||||
try {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
await deleteWorkspace.mutateAsync({
|
||||
workspaceID: currentWorkspace?.id
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
handlePopUpClose("deleteWorkspace");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete project",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting.off();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveWorkspaceSubmit = async () => {
|
||||
console.log({
|
||||
currentWorkspace,
|
||||
currentOrg,
|
||||
members,
|
||||
isNoAccessMember,
|
||||
membership
|
||||
});
|
||||
|
||||
try {
|
||||
setIsLeaving.on();
|
||||
|
||||
if (!currentWorkspace?.id || !currentOrg?.id) return;
|
||||
|
||||
// If there's no members, and the user has access to read members, something went wrong.
|
||||
if (!members && !isNoAccessMember) return;
|
||||
|
||||
// If the user has elevated permissions and can read members:
|
||||
if (!isNoAccessMember) {
|
||||
if (!members) return;
|
||||
|
||||
if (members.length < 2) {
|
||||
createNotification({
|
||||
text: "You can't leave the project as you are the only member",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If the user has access to read members, and there's less than 1 admin member excluding the current user, they can't leave the project.
|
||||
if (isOnlyAdminMember) {
|
||||
createNotification({
|
||||
text: "You can't leave a project with no admin members left. Promote another member to admin first.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If it's actually a no-access member, then we don't really care about the members.
|
||||
|
||||
await leaveProject.mutateAsync({
|
||||
workspaceId: currentWorkspace.id
|
||||
});
|
||||
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to leave project",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsLeaving.off();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
|
||||
<div className="space-x-4">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Project}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isLoading={isDeleting}
|
||||
isDisabled={!isAllowed || isDeleting}
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handlePopUpOpen("deleteWorkspace")}
|
||||
>
|
||||
{`Delete ${currentWorkspace?.name}`}
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{!isOnlyAdminMember && (
|
||||
<Button
|
||||
disabled={isMembersLoading || (members && members?.length < 2)}
|
||||
isLoading={isLeaving}
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handlePopUpOpen("leaveWorkspace")}
|
||||
>
|
||||
{`Leave ${currentWorkspace?.name}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteWorkspace.isOpen}
|
||||
title="Are you sure want to delete this project?"
|
||||
subTitle={`Permanently delete ${currentWorkspace?.name} and all of its data. This action is not reversible, so please be careful.`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteWorkspace", isOpen)}
|
||||
deleteKey="confirm"
|
||||
buttonText="Delete Project"
|
||||
onDeleteApproved={handleDeleteWorkspaceSubmit}
|
||||
/>
|
||||
|
||||
<LeaveProjectModal
|
||||
isOpen={popUp.leaveWorkspace.isOpen}
|
||||
title="Are you sure want to leave this project?"
|
||||
subTitle={`If you leave ${currentWorkspace?.name} you will lose access to the project and it's contents.`}
|
||||
onChange={(isOpen) => handlePopUpToggle("leaveWorkspace", isOpen)}
|
||||
deleteKey="confirm"
|
||||
buttonText="Leave Project"
|
||||
onLeaveApproved={handleLeaveWorkspaceSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
|
||||
|
||||
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
|
||||
import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<ProjectOverviewChangeSection showSlugField />
|
||||
<AuditLogsRetentionSection />
|
||||
<DeleteProjectSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectGeneralTab } from "./ProjectGeneralTab";
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { ProjectSshConfigCasSection } from "./components";
|
||||
|
||||
export const ProjectSshTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<ProjectSshConfigCasSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, Select, SelectItem } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useGetProjectSshConfig,
|
||||
useListWorkspaceSshCas,
|
||||
useUpdateProjectSshConfig
|
||||
} from "@app/hooks/api";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
defaultUserSshCaId: z.string(),
|
||||
defaultHostSshCaId: z.string()
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
export const ProjectSshConfigCasSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id);
|
||||
const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id);
|
||||
const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sshConfig) {
|
||||
reset({
|
||||
defaultUserSshCaId: sshConfig.defaultUserSshCaId || "",
|
||||
defaultHostSshCaId: sshConfig.defaultHostSshCaId || ""
|
||||
});
|
||||
}
|
||||
}, [sshConfig]);
|
||||
|
||||
const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => {
|
||||
try {
|
||||
await updateProjectSshConfig({
|
||||
projectId: currentWorkspace.id,
|
||||
defaultUserSshCaId: defaultUserSshCaId || undefined,
|
||||
defaultHostSshCaId: defaultHostSshCaId || undefined
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated SSH project settings",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update SSH project settings",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<p className="mb-8 text-xl font-semibold">Certificate Authorities</p>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultUserSshCaId"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Default User CA"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="min-w-[20rem]"
|
||||
>
|
||||
{sshCas?.map(({ id, friendlyName }) => (
|
||||
<SelectItem value={String(id || "")} key={friendlyName}>
|
||||
{friendlyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultHostSshCaId"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Default Host CA"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="min-w-[20rem]"
|
||||
>
|
||||
{sshCas?.map(({ id, friendlyName }) => (
|
||||
<SelectItem value={String(id || "")} key={friendlyName}>
|
||||
{friendlyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isAllowed}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectSshTab } from "./ProjectSshTab";
|
||||
@@ -0,0 +1 @@
|
||||
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout/settings"
|
||||
)({
|
||||
component: SettingsPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Settings"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { workspaceKeys } from "@app/hooks/api";
|
||||
import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
|
||||
import { fetchWorkspaceById } from "@app/hooks/api/workspace/queries";
|
||||
import { ProjectLayout } from "@app/layouts/ProjectLayout";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-scanning/$projectId/_secret-scanning-layout"
|
||||
)({
|
||||
component: ProjectLayout,
|
||||
beforeLoad: async ({ params, context }) => {
|
||||
const project = await context.queryClient.ensureQueryData({
|
||||
queryKey: workspaceKeys.getWorkspaceById(params.projectId),
|
||||
queryFn: () => fetchWorkspaceById(params.projectId)
|
||||
});
|
||||
|
||||
await context.queryClient.ensureQueryData({
|
||||
queryKey: roleQueryKeys.getUserProjectPermissions({
|
||||
workspaceId: params.projectId
|
||||
}),
|
||||
queryFn: () => fetchUserProjectPermissions({ workspaceId: params.projectId })
|
||||
});
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Secret Scanning",
|
||||
link: linkOptions({ to: "/organization/secret-scanning/overview" })
|
||||
},
|
||||
{
|
||||
label: project.name,
|
||||
link: linkOptions({
|
||||
to: "/secret-scanning/$projectId/data-sources",
|
||||
params: { projectId: project.id }
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user