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

View File

@@ -1,9 +1,13 @@
import { z } from "zod";
import { GitAppOrgSchema, SecretScanningGitRisksSchema } from "@app/db/schemas";
import { SecretScanningRiskStatus } from "@app/ee/services/secret-scanning/secret-scanning-types";
import {
SecretScanningResolvedStatus,
SecretScanningRiskStatus
} from "@app/ee/services/secret-scanning/secret-scanning-types";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -97,6 +101,45 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) =
}
});
server.route({
url: "/organization/:organizationId/risks/export",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({ organizationId: z.string().trim() }),
querystring: z.object({
repositoryNames: z
.string()
.optional()
.nullable()
.transform((val) => (val ? val.split(",") : undefined)),
resolvedStatus: z.nativeEnum(SecretScanningResolvedStatus).optional()
}),
response: {
200: z.object({
risks: SecretScanningGitRisksSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const risks = await server.services.secretScanning.getAllRisksByOrg({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
orgId: req.params.organizationId,
filter: {
repositoryNames: req.query.repositoryNames,
resolvedStatus: req.query.resolvedStatus
}
});
return { risks };
}
});
server.route({
url: "/organization/:organizationId/risks",
method: "GET",
@@ -105,20 +148,46 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) =
},
schema: {
params: z.object({ organizationId: z.string().trim() }),
querystring: z.object({
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(20000).default(100),
orderBy: z.enum(["createdAt", "name"]).default("createdAt"),
orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.DESC),
repositoryNames: z
.string()
.optional()
.nullable()
.transform((val) => (val ? val.split(",") : undefined)),
resolvedStatus: z.nativeEnum(SecretScanningResolvedStatus).optional()
}),
response: {
200: z.object({ risks: SecretScanningGitRisksSchema.array() })
200: z.object({
risks: SecretScanningGitRisksSchema.array(),
totalCount: z.number(),
repos: z.array(z.string())
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { risks } = await server.services.secretScanning.getRisksByOrg({
const { risks, totalCount, repos } = await server.services.secretScanning.getRisksByOrg({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
orgId: req.params.organizationId
orgId: req.params.organizationId,
filter: {
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
orderDirection: req.query.orderDirection,
repositoryNames: req.query.repositoryNames,
resolvedStatus: req.query.resolvedStatus
}
});
return { risks };
return { risks, totalCount, repos };
}
});

View File

@@ -1,9 +1,12 @@
import { Knex } from "knex";
import knex, { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TSecretScanningGitRisksInsert } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretScanningResolvedStatus, TGetOrgRisksDTO } from "./secret-scanning-types";
export type TSecretScanningDALFactory = ReturnType<typeof secretScanningDALFactory>;
@@ -19,5 +22,70 @@ export const secretScanningDALFactory = (db: TDbClient) => {
}
};
return { ...gitRiskOrm, upsert };
const findByOrgId = async (orgId: string, filter: TGetOrgRisksDTO["filter"], tx?: Knex) => {
try {
// Find statements
const sqlQuery = (tx || db.replicaNode())(TableName.SecretScanningGitRisk)
// eslint-disable-next-line func-names
.where(`${TableName.SecretScanningGitRisk}.orgId`, orgId);
if (filter.repositoryNames) {
void sqlQuery.whereIn(`${TableName.SecretScanningGitRisk}.repositoryFullName`, filter.repositoryNames);
}
if (filter.resolvedStatus) {
if (filter.resolvedStatus !== SecretScanningResolvedStatus.All) {
const isResolved = filter.resolvedStatus === SecretScanningResolvedStatus.Resolved;
void sqlQuery.where(`${TableName.SecretScanningGitRisk}.isResolved`, isResolved);
}
}
// Select statements
void sqlQuery
.select(selectAllTableCols(TableName.SecretScanningGitRisk))
.limit(filter.limit)
.offset(filter.offset);
if (filter.orderBy) {
const orderDirection = filter.orderDirection || OrderByDirection.ASC;
void sqlQuery.orderBy(filter.orderBy, orderDirection);
}
const countQuery = (tx || db.replicaNode())(TableName.SecretScanningGitRisk)
.where(`${TableName.SecretScanningGitRisk}.orgId`, orgId)
.count();
const uniqueReposQuery = (tx || db.replicaNode())(TableName.SecretScanningGitRisk)
.where(`${TableName.SecretScanningGitRisk}.orgId`, orgId)
.distinct("repositoryFullName")
.select("repositoryFullName");
// we timeout long running queries to prevent DB resource issues (2 minutes)
const docs = await sqlQuery.timeout(1000 * 120);
const uniqueRepos = await uniqueReposQuery.timeout(1000 * 120);
const totalCount = await countQuery;
return {
risks: docs,
totalCount: Number(totalCount?.[0].count),
repos: uniqueRepos
.filter(Boolean)
.map((r) => r.repositoryFullName!)
.sort((a, b) => a.localeCompare(b))
};
} catch (error) {
if (error instanceof knex.KnexTimeoutError) {
throw new GatewayTimeoutError({
error,
message: "Failed to fetch secret leaks due to timeout. Add more search filters."
});
}
throw new DatabaseError({ error });
}
};
return { ...gitRiskOrm, upsert, findByOrgId };
};

View File

@@ -15,6 +15,7 @@ import { TSecretScanningDALFactory } from "./secret-scanning-dal";
import { TSecretScanningQueueFactory } from "./secret-scanning-queue";
import {
SecretScanningRiskStatus,
TGetAllOrgRisksDTO,
TGetOrgInstallStatusDTO,
TGetOrgRisksDTO,
TInstallAppSessionDTO,
@@ -118,11 +119,21 @@ export const secretScanningServiceFactory = ({
return Boolean(appInstallation);
};
const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetOrgRisksDTO) => {
const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId, filter }: TGetOrgRisksDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning);
const results = await secretScanningDAL.findByOrgId(orgId, filter);
return results;
};
const getAllRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetAllOrgRisksDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning);
const risks = await secretScanningDAL.find({ orgId }, { sort: [["createdAt", "desc"]] });
return { risks };
return risks;
};
const updateRiskStatus = async ({
@@ -189,6 +200,7 @@ export const secretScanningServiceFactory = ({
linkInstallationToOrg,
getOrgInstallationStatus,
getRisksByOrg,
getAllRisksByOrg,
updateRiskStatus,
handleRepoPushEvent,
handleRepoDeleteEvent

View File

@@ -1,4 +1,4 @@
import { TOrgPermission } from "@app/lib/types";
import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export enum SecretScanningRiskStatus {
FalsePositive = "RESOLVED_FALSE_POSITIVE",
@@ -7,6 +7,12 @@ export enum SecretScanningRiskStatus {
Unresolved = "UNRESOLVED"
}
export enum SecretScanningResolvedStatus {
All = "all",
Resolved = "resolved",
Unresolved = "unresolved"
}
export type TInstallAppSessionDTO = TOrgPermission;
export type TLinkInstallSessionDTO = {
@@ -16,7 +22,22 @@ export type TLinkInstallSessionDTO = {
export type TGetOrgInstallStatusDTO = TOrgPermission;
export type TGetOrgRisksDTO = TOrgPermission;
type RiskFilter = {
offset: number;
limit: number;
orderBy?: "createdAt" | "name";
orderDirection?: OrderByDirection;
repositoryNames?: string[];
resolvedStatus?: SecretScanningResolvedStatus;
};
export type TGetOrgRisksDTO = {
filter: RiskFilter;
} & TOrgPermission;
export type TGetAllOrgRisksDTO = {
filter: Omit<RiskFilter, "offset" | "limit" | "orderBy" | "orderDirection">;
} & TOrgPermission;
export type TUpdateRiskStatusDTO = {
riskId: string;

View File

@@ -1,5 +1,6 @@
export {
useCreateNewInstallationSession,
useExportSecretScanningRisks,
useLinkGitAppInstallationWithOrg,
useUpdateRiskStatus
} from "./mutation";

View File

@@ -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;
}
});
};

View File

@@ -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)
});

View File

@@ -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;

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;