Merge pull request #2988 from Infisical/daniel/audit-logs-searchability

feat(radar): pagination and filtering
This commit is contained in:
Daniel Hougaard
2025-01-16 01:24:24 +01:00
committed by GitHub
12 changed files with 497 additions and 129 deletions
@@ -1,5 +1,6 @@
export {
useCreateNewInstallationSession,
useExportSecretScanningRisks,
useLinkGitAppInstallationWithOrg,
useUpdateRiskStatus
} from "./mutation";
@@ -2,7 +2,12 @@ import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { RiskStatus, TGitAppOrg, TSecretScanningGitRisks } from "./types";
import {
RiskStatus,
SecretScanningResolvedStatus,
TGitAppOrg,
TSecretScanningGitRisks
} from "./types";
export const useCreateNewInstallationSession = () => {
return useMutation<{ sessionId: string }, object, { organizationId: string }>({
@@ -43,3 +48,31 @@ export const useLinkGitAppInstallationWithOrg = () => {
}
});
};
export const useExportSecretScanningRisks = () => {
return useMutation<
TSecretScanningGitRisks[],
object,
{
orgId: string;
filter: {
repositoryNames?: string[];
resolvedStatus?: SecretScanningResolvedStatus;
};
}
>({
mutationFn: async ({ filter, orgId }) => {
const params = new URLSearchParams({
...(filter.resolvedStatus && { resolvedStatus: filter.resolvedStatus }),
...(filter.repositoryNames && { repositoryNames: filter.repositoryNames.join(",") })
});
const { data } = await apiRequest.get<{
risks: TSecretScanningGitRisks[];
}>(`/api/v1/secret-scanning/organization/${orgId}/risks/export`, {
params
});
return data.risks;
}
});
};
@@ -2,11 +2,19 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TSecretScanningGitRisks } from "./types";
import { SecretScanningOrderBy, SecretScanningRiskFilter, TSecretScanningGitRisks } from "./types";
export const secretScanningQueryKeys = {
getInstallationStatus: (orgId: string) => ["secret-scanning-installation-status", { orgId }],
getRisksByOrganizatio: (orgId: string) => ["secret-scanning-risks", { orgId }]
getRisksByOrganization: (
orgId: string,
sort: {
offset: number;
limit: number;
orderBy: SecretScanningOrderBy;
},
filter: SecretScanningRiskFilter
) => ["secret-scanning-risks", { orgId, sort, filter }]
};
const fetchSecretScanningInstallationStatus = async (organizationId: string) => {
@@ -22,15 +30,43 @@ export const useGetSecretScanningInstallationStatus = (orgId: string) =>
queryFn: () => fetchSecretScanningInstallationStatus(orgId)
});
const fetchSecretScanningRisksByOrgId = async (oranizationId: string) => {
const { data } = await apiRequest.get<{ risks: TSecretScanningGitRisks[] }>(
`/api/v1/secret-scanning/organization/${oranizationId}/risks`
);
return data.risks;
const fetchSecretScanningRisksByOrgId = async (
organizationId: string,
sort: {
offset: number;
limit: number;
orderBy: SecretScanningOrderBy;
},
filter: SecretScanningRiskFilter
) => {
const params = new URLSearchParams({
offset: String(sort.offset),
limit: String(sort.limit),
orderBy: sort.orderBy,
...(filter.resolvedStatus && { resolvedStatus: filter.resolvedStatus }),
...(filter.repositoryNames && { repositoryNames: filter.repositoryNames.join(",") })
});
const { data } = await apiRequest.get<{
risks: TSecretScanningGitRisks[];
totalCount: number;
repos: string[];
}>(`/api/v1/secret-scanning/organization/${organizationId}/risks`, {
params
});
return data;
};
export const useGetSecretScanningRisks = (orgId: string) =>
export const useGetSecretScanningRisks = (
orgId: string,
sort: {
offset: number;
limit: number;
orderBy: SecretScanningOrderBy;
},
filter: SecretScanningRiskFilter
) =>
useQuery({
queryKey: secretScanningQueryKeys.getRisksByOrganizatio(orgId),
queryFn: () => fetchSecretScanningRisksByOrgId(orgId)
queryKey: secretScanningQueryKeys.getRisksByOrganization(orgId, sort, filter),
queryFn: () => fetchSecretScanningRisksByOrgId(orgId, sort, filter)
});
@@ -5,6 +5,21 @@ export enum RiskStatus {
UNRESOLVED = "UNRESOLVED"
}
export enum SecretScanningOrderBy {
CreatedAt = "createdAt"
}
export enum SecretScanningResolvedStatus {
All = "all",
Resolved = "resolved",
Unresolved = "unresolved"
}
export type SecretScanningRiskFilter = {
repositoryNames?: string[];
resolvedStatus?: SecretScanningResolvedStatus;
};
export type TSecretScanningGitRisks = {
id: string;
description: string;
@@ -1,9 +1,11 @@
import { useEffect } from "react";
import { Helmet } from "react-helmet";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSearch } from "@tanstack/react-router";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, NoticeBanner } from "@app/components/v2";
import { Button, NoticeBanner, Pagination } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
@@ -12,28 +14,58 @@ import {
useServerConfig
} from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { usePagination, usePopUp } from "@app/hooks";
import {
useCreateNewInstallationSession,
useGetSecretScanningInstallationStatus,
useGetSecretScanningRisks,
useLinkGitAppInstallationWithOrg
} from "@app/hooks/api/secretScanning";
import { SecretScanningOrderBy } from "@app/hooks/api/secretScanning/types";
import { ExportSecretScansModal } from "./components/ExportSecretScansModal";
import { SecretScanningFilter } from "./components/SecretScanningFilters";
import { SecretScanningFilterFormData, secretScanningFilterFormSchema } from "./components/types";
import { SecretScanningLogsTable } from "./components";
const PER_PAGE_INIT = 25;
export const SecretScanningPage = withPermission(
() => {
const queryParams = useSearch({
from: ROUTE_PATHS.Organization.SecretScanning.id
});
const { control, watch } = useForm<SecretScanningFilterFormData>({
resolver: zodResolver(secretScanningFilterFormSchema),
defaultValues: {}
});
const { config } = useServerConfig();
const { currentOrg } = useOrganization();
const organizationId = currentOrg.id;
const { isPending, data: gitRisks } = useGetSecretScanningRisks(organizationId);
const { offset, limit, orderBy, setPage, perPage, page, setPerPage } = usePagination(
SecretScanningOrderBy.CreatedAt,
{ initPerPage: PER_PAGE_INIT }
);
const repositoryNames = watch("repositoryNames");
const resolvedStatus = watch("resolved");
const { isPending, data: risksData } = useGetSecretScanningRisks(
organizationId,
{
offset,
limit,
orderBy
},
{
repositoryNames:
repositoryNames?.length > 0 ? repositoryNames.map((repo) => repo.name) : undefined,
resolvedStatus
}
);
const { mutateAsync: linkGitAppInstallationWithOrganization } =
useLinkGitAppInstallationWithOrg();
@@ -72,7 +104,7 @@ export const SecretScanningPage = withPermission(
};
return (
<div>
<div className="py-2">
<Helmet>
<title>Secret scanning</title>
<link rel="icon" href="/infisical.ico" />
@@ -141,21 +173,33 @@ export const SecretScanningPage = withPermission(
)}
</div>
<div className="mt-8 space-y-3">
<div className="flex w-full justify-end">
<Button
onClick={() => handlePopUpToggle("exportSecretScans", true)}
variant="solid"
colorSchema="secondary"
>
Export
</Button>
</div>
<SecretScanningLogsTable gitRisks={gitRisks} isPending={isPending} />
{integrationEnabled && (
<div className="flex w-full items-center justify-end">
<SecretScanningFilter
repositories={risksData?.repos || []}
handlePopUpToggle={handlePopUpToggle}
control={control}
/>
</div>
)}
<SecretScanningLogsTable gitRisks={risksData?.risks} isPending={isPending} />
{!isPending &&
risksData?.totalCount !== undefined &&
risksData.totalCount >= PER_PAGE_INIT && (
<Pagination
className="rounded-md"
count={risksData.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</div>
</div>
</div>
<ExportSecretScansModal
gitRisks={gitRisks || []}
repositories={risksData?.repos || []}
handlePopUpToggle={handlePopUpToggle}
popUp={popUp}
/>
@@ -1,11 +1,12 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import FileSaver from "file-saver";
import { z } from "zod";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { TSecretScanningGitRisks } from "@app/hooks/api/secretScanning/types";
import { useOrganization } from "@app/context";
import { useExportSecretScanningRisks } from "@app/hooks/api/secretScanning";
import { SecretScanningResolvedStatus } from "@app/hooks/api/secretScanning/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { convertJsonToCsv } from "@app/lib/fn/csv";
@@ -15,7 +16,7 @@ type Props = {
popUpName: keyof UsePopUpState<["exportSecretScans"]>,
state?: boolean
) => void;
gitRisks: TSecretScanningGitRisks[];
repositories: string[];
};
enum ExportFormat {
@@ -23,71 +24,52 @@ enum ExportFormat {
Csv = "csv"
}
enum ExportStatus {
All = "all",
Unresolved = "unresolved",
Resolved = "resolved"
}
const formSchema = z.object({
githubOrganization: z.string().trim(),
githubRepository: z.string().trim(),
status: z.nativeEnum(ExportStatus),
status: z.nativeEnum(SecretScanningResolvedStatus),
exportFormat: z.nativeEnum(ExportFormat)
});
type TFormSchema = z.infer<typeof formSchema>;
export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: Props) => {
export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, repositories }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync } = useExportSecretScanningRisks();
const {
control,
handleSubmit,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
exportFormat: ExportFormat.Json,
status: ExportStatus.All,
githubOrganization: "all",
status: SecretScanningResolvedStatus.All,
githubRepository: "all"
}
});
const selectedOrganization = watch("githubOrganization");
const uniqueOrganizations = useMemo(() => {
const organizations = gitRisks.map((risk) => risk.repositoryFullName.split("/")[0]);
return Array.from(new Set(organizations));
}, [gitRisks]);
const uniqueRepositories = useMemo(() => {
const repositories = gitRisks
.filter((risk) => risk.repositoryFullName.split("/")[0] === selectedOrganization)
.map((risk) => risk.repositoryFullName.split("/")[1]);
return Array.from(new Set(repositories));
}, [gitRisks, selectedOrganization]);
const onFormSubmit = async (data: TFormSchema) => {
const gitRisks = await mutateAsync({
orgId: currentOrg.id,
filter: {
repositoryNames: data.githubRepository !== "all" ? [data.githubRepository] : undefined,
resolvedStatus: data.status
}
});
const filteredRisks = gitRisks
.filter((risk) =>
// eslint-disable-next-line no-nested-ternary
data.status === ExportStatus.All
data.status === SecretScanningResolvedStatus.All
? true
: data.status === ExportStatus.Resolved
: data.status === SecretScanningResolvedStatus.Resolved
? risk.isResolved
: !risk.isResolved
)
.filter((risk) => {
if (data.githubOrganization === "all") return true;
if (data.githubRepository === "all")
return risk.repositoryFullName.split("/")[0] === data.githubOrganization;
return risk.repositoryFullName === `${data.githubOrganization}/${data.githubRepository}`;
if (data.githubRepository === "all") return true;
return risk.repositoryFullName === data.githubRepository;
});
const formattedRisks = filteredRisks.map((risk) => {
@@ -148,27 +130,6 @@ export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: P
</Button>
]}
>
<Controller
control={control}
name="status"
render={({ field: { value, onChange } }) => (
<FormControl label="Risk Status">
<Select
defaultValue="all"
value={value}
onValueChange={onChange}
className="w-full"
>
{Object.values(ExportStatus).map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="exportFormat"
@@ -192,19 +153,18 @@ export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: P
<Controller
control={control}
name="githubOrganization"
name="status"
render={({ field: { value, onChange } }) => (
<FormControl label="GitHub Organization">
<FormControl label="Risk Status">
<Select
defaultValue="all"
value={value}
onValueChange={onChange}
className="w-full"
>
<SelectItem value="all">All Organizations</SelectItem>
{uniqueOrganizations.map((orgName) => (
<SelectItem key={orgName} value={orgName}>
{orgName}
{Object.values(SecretScanningResolvedStatus).map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</Select>
@@ -212,29 +172,27 @@ export const ExportSecretScansModal = ({ popUp, handlePopUpToggle, gitRisks }: P
)}
/>
{selectedOrganization && selectedOrganization !== "all" && (
<Controller
control={control}
name="githubRepository"
render={({ field: { value, onChange } }) => (
<FormControl label="GitHub Repository">
<Select
defaultValue="all"
value={value}
onValueChange={onChange}
className="w-full"
>
<SelectItem value="all">All Repositories</SelectItem>
{uniqueRepositories.map((repoName) => (
<SelectItem key={repoName} value={repoName}>
{repoName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="githubRepository"
render={({ field: { value, onChange } }) => (
<FormControl label="GitHub Repository">
<Select
defaultValue="all"
value={value}
onValueChange={onChange}
className="w-full"
>
<SelectItem value="all">All Repositories</SelectItem>
{repositories.map((repoName) => (
<SelectItem key={repoName} value={repoName}>
{repoName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</ModalContent>
</Modal>
</form>
@@ -0,0 +1,89 @@
import { Control, Controller } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Button, FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import { SecretScanningResolvedStatus } from "@app/hooks/api/secretScanning/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { SecretScanningFilterFormData } from "./types";
type Props = {
control: Control<SecretScanningFilterFormData>;
repositories: string[];
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["exportSecretScans"]>,
state?: boolean
) => void;
};
export const SecretScanningFilter = ({ repositories, control, handlePopUpToggle }: Props) => {
return (
<div className={twMerge("flex w-full flex-wrap items-center justify-between bg-bunker-800")}>
<div className="flex items-center -space-x-8">
<Controller
control={control}
name="repositoryNames"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Repository"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-96"
>
<FilterableSelect
value={value}
isClearable
isMulti
onChange={onChange}
placeholder="Select a repository..."
options={repositories.map((repository) => ({
name: repository
}))}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="resolved"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Status"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-44"
>
<Select
defaultValue={SecretScanningResolvedStatus.All}
placeholder={SecretScanningResolvedStatus.All}
value={value}
onValueChange={onChange}
className="w-full"
>
{Object.values(SecretScanningResolvedStatus).map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)} risks
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div>
<Button
className="mt-[0.45rem]"
onClick={() => handlePopUpToggle("exportSecretScans", true)}
variant="solid"
colorSchema="secondary"
>
Export
</Button>
</div>
</div>
);
};
@@ -0,0 +1,22 @@
import { z } from "zod";
import { SecretScanningResolvedStatus } from "@app/hooks/api/secretScanning/types";
export const secretScanningFilterFormSchema = z.object({
repositoryNames: z.array(z.object({ name: z.string() })),
resolved: z
.nativeEnum(SecretScanningResolvedStatus)
.default(SecretScanningResolvedStatus.All)
.optional()
});
export type SecretScanningFilterFormData = z.infer<typeof secretScanningFilterFormSchema>;
export type SetValueType = (
name: keyof SecretScanningFilterFormData,
value: any,
options?: {
shouldValidate?: boolean;
shouldDirty?: boolean;
}
) => void;