feature: secret scanning architecture and github data source (wip)

This commit is contained in:
Scott Wilson
2025-05-28 22:21:03 -07:00
parent 931ee1e8da
commit e6c97510ca
231 changed files with 11443 additions and 1048 deletions
@@ -0,0 +1,97 @@
import { useState } from "react";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, ModalContent } from "@app/components/v2";
import {
SecretScanningDataSource,
TSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
import { SecretScanningDataSourceForm } from "./forms";
import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader";
import { SecretScanningDataSourceSelect } from "./SecretScanningDataSourceSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onComplete: (dataSource: TSecretScanningDataSource) => void;
selectedDataSource: SecretScanningDataSource | null;
setSelectedDataSource: (selectedDataSource: SecretScanningDataSource | null) => void;
};
const Content = ({ setSelectedDataSource, selectedDataSource, ...props }: ContentProps) => {
if (selectedDataSource) {
return (
<SecretScanningDataSourceForm
onCancel={() => setSelectedDataSource(null)}
type={selectedDataSource}
{...props}
/>
);
}
return <SecretScanningDataSourceSelect onSelect={setSelectedDataSource} />;
};
export const CreateSecretScanningDataSourceModal = ({ onOpenChange, isOpen, ...props }: Props) => {
const [selectedDataSource, setSelectedDataSource] = useState<SecretScanningDataSource | null>(
null
);
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) setSelectedDataSource(null);
onOpenChange(open);
}}
>
<ModalContent
title={
selectedDataSource ? (
<SecretScanningDataSourceModalHeader isConfigured={false} type={selectedDataSource} />
) : (
<div className="flex items-center text-mineshaft-300">
Add Data Source
<a
target="_blank"
href="https://infisical.com/docs/documentation/platform/secret-scanning/overview"
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
)
}
onPointerDownOutside={(e) => e.preventDefault()}
className={selectedDataSource ? "max-w-2xl" : "max-w-3xl"}
subTitle={
selectedDataSource ? undefined : "Select a data source to configure secret scanning for."
}
bodyClassName="overflow-visible"
>
<Content
onComplete={() => {
setSelectedDataSource(null);
onOpenChange(false);
}}
selectedDataSource={selectedDataSource}
setSelectedDataSource={setSelectedDataSource}
{...props}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,66 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
import {
TSecretScanningDataSource,
useDeleteSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
type Props = {
dataSource?: TSecretScanningDataSource;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: () => void;
};
export const DeleteSecretScanningDataSourceModal = ({
isOpen,
onOpenChange,
dataSource,
onComplete
}: Props) => {
const deleteSecretRotation = useDeleteSecretScanningDataSource();
if (!dataSource) return null;
const { id: dataSourceId, name, type, projectId } = dataSource;
const handleDeleteDataSource = async () => {
const dataSourceType = SECRET_SCANNING_DATA_SOURCE_MAP[type].name;
try {
await deleteSecretRotation.mutateAsync({
dataSourceId,
type,
projectId
});
createNotification({
text: `Successfully deleted ${dataSourceType} Data Source`,
type: "success"
});
if (onComplete) onComplete();
onOpenChange(false);
} catch {
createNotification({
text: `Failed to delete ${dataSourceType} Data Source`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDeleteDataSource}
>
<p className="mt-1 font-inter text-sm text-mineshaft-400">
Findings associated with this data source will be preserved.
</p>
</DeleteActionModal>
);
};
@@ -0,0 +1,36 @@
import { Modal, ModalContent } from "@app/components/v2";
import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { SecretScanningDataSourceForm } from "./forms";
import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
dataSource?: TSecretScanningDataSource;
};
export const EditSecretScanningDataSourceModal = ({
dataSource,
onOpenChange,
...props
}: Props) => {
if (!dataSource) return null;
return (
<Modal {...props} onOpenChange={onOpenChange}>
<ModalContent
title={<SecretScanningDataSourceModalHeader isConfigured type={dataSource.type} />}
className="max-w-2xl"
bodyClassName="overflow-visible"
>
<SecretScanningDataSourceForm
onComplete={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
dataSource={dataSource}
type={dataSource.type}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,47 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
type Props = {
type: SecretScanningDataSource;
isConfigured: boolean;
};
export const SecretScanningDataSourceModalHeader = ({ type, isConfigured }: Props) => {
const dataSourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[type];
return (
<div className="flex w-full items-start gap-2">
<img
alt={`${dataSourceDetails.name} logo`}
src={`/images/integrations/${dataSourceDetails.image}`}
className="h-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{dataSourceDetails.name} Data Source
<a
target="_blank"
href={`https://infisical.com/docs/documentation/platform/secret-scanning/${type}`}
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConfigured ? "Edit" : "Connect a"} {dataSourceDetails.name} Data Source
</p>
</div>
</div>
);
};
@@ -0,0 +1,91 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
import {
SecretScanningDataSource,
useSecretScanningDataSourceOptions
} from "@app/hooks/api/secretScanningV2";
type Props = {
onSelect: (type: SecretScanningDataSource) => void;
};
export const SecretScanningDataSourceSelect = ({ onSelect }: Props) => {
const { isPending, data: dataSourceOptions } = useSecretScanningDataSourceOptions();
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-2">
{dataSourceOptions?.map(({ type }) => {
const { image, name, size } = SECRET_SCANNING_DATA_SOURCE_MAP[type];
return (
<button
type="button"
key={type}
onClick={() => onSelect(type)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
width={size}
className="mt-auto"
alt={`${name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more services.</p>
<p>
{`If you don't see the third-party
service you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4 hover:bg-mineshaft-900/50">
<FontAwesomeIcon className="mt-auto text-3xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,90 @@
import { faArrowRotateForward, faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Badge, Tooltip } from "@app/components/v2";
import { SecretScanningScanStatus } from "@app/hooks/api/secretScanningV2";
type Props = {
status: SecretScanningScanStatus;
statusMessage?: string | null;
className?: string;
scannedAt?: string | null;
};
export const SecretScanningScanStatusBadge = ({
status,
statusMessage,
className,
scannedAt
}: Props) => {
if (status === SecretScanningScanStatus.Failed) {
let errorMessage = statusMessage;
if (statusMessage) {
try {
errorMessage = JSON.stringify(JSON.parse(statusMessage), null, 2);
} catch {
errorMessage = statusMessage;
}
}
return (
<Tooltip
position="left"
className="max-w-sm select-text"
content={
<div className="flex flex-col gap-2 whitespace-normal py-1">
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">Failure Reason</div>
</div>
<div className="break-words rounded bg-mineshaft-600 p-2 text-xs">{errorMessage}</div>
{scannedAt && (
<div className="mt-1 text-xs text-mineshaft-400">
Attempted {formatDistance(new Date(scannedAt), new Date(), { addSuffix: true })}
</div>
)}
</div>
</div>
}
>
<div>
<Badge
variant="danger"
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
>
<FontAwesomeIcon icon={faXmark} />
Scan Error
</Badge>
</div>
</Tooltip>
);
}
if (status === SecretScanningScanStatus.Queued || status === SecretScanningScanStatus.Scanning) {
return (
<Badge
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
variant="primary"
>
<FontAwesomeIcon icon={faArrowRotateForward} className="animate-spin" />
<span>Scanning</span>
</Badge>
);
}
return (
<Badge
variant="success"
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap capitalize",
className
)}
>
<FontAwesomeIcon icon={faCheck} />
<span>Complete</span>
</Badge>
);
};
@@ -0,0 +1,126 @@
import { useEffect } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { MultiValue } from "react-select";
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2";
import {
TGitHubRadarConnectionRepository,
useGitHubRadarConnectionListRepositories
} from "@app/hooks/api/appConnections/github-radar";
import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { TSecretScanningDataSourceForm } from "../schemas";
import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField";
enum ScanMethod {
AllRepositories = "all-repositories",
SelectRepositories = "select-repositories"
}
export const GitHubDataSourceConfigFields = () => {
const { control, watch, setValue } = useFormContext<
TSecretScanningDataSourceForm & {
type: SecretScanningDataSource.GitHub;
}
>();
const connectionId = useWatch({ control, name: "connection.id" });
const isUpdate = Boolean(watch("id"));
const { data: repositories, isPending: areRepositoriesLoading } =
useGitHubRadarConnectionListRepositories(connectionId, { enabled: Boolean(connectionId) });
const includeRepos = watch("config.includeRepos");
const scanMethod =
!includeRepos || includeRepos[0] === "*"
? ScanMethod.AllRepositories
: ScanMethod.SelectRepositories;
useEffect(() => {
if (!includeRepos) {
setValue("config.includeRepos", ["*"]);
}
}, [includeRepos]);
return (
<>
<SecretScanningDataSourceConnectionField
isUpdate={isUpdate}
onChange={() => {
if (scanMethod === ScanMethod.SelectRepositories) {
setValue("config.includeRepos", []);
}
}}
/>
<FormControl label="Scan Repositories">
<Select
value={scanMethod}
onValueChange={(val) => {
setValue("config.includeRepos", val === ScanMethod.AllRepositories ? ["*"] : []);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
isDisabled={!connectionId}
>
{Object.values(ScanMethod).map((method) => {
return (
<SelectItem className="capitalize" value={method} key={method}>
{method.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</FormControl>
{scanMethod === ScanMethod.SelectRepositories && (
<Controller
name="config.includeRepos"
defaultValue={["*"]}
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Include Repositories"
helperText={
<Tooltip
className="max-w-md"
content={<>Ensure that your connection has the correct permissions.</>}
>
<div>
<span>Don&#39;t see the repository you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={areRepositoriesLoading && Boolean(connectionId)}
isDisabled={!connectionId}
isMulti
value={repositories?.find((project) => value.includes(project.name))}
onChange={(newValue) => {
onChange(
newValue
? (newValue as MultiValue<TGitHubRadarConnectionRepository>).map(
(p) => p.name
)
: null
);
}}
options={repositories}
placeholder="Select repositories..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.name}
/>
</FormControl>
)}
/>
)}
</>
);
};
@@ -0,0 +1,55 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Switch } from "@app/components/v2";
import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2";
import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { TSecretScanningDataSourceForm } from "../schemas";
import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields";
const COMPONENT_MAP: Record<SecretScanningDataSource, React.FC> = {
[SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields
};
export const SecretScanningDataSourceConfigFields = () => {
const { watch, control } = useFormContext<TSecretScanningDataSourceForm>();
const type = watch("type");
const Component = COMPONENT_MAP[type];
const autoScanDescription = RESOURCE_DESCRIPTION_HELPER[type];
return (
<>
<p className="mb-4 text-sm text-bunker-300">Connect and configure your Data Source.</p>
<Component />
<Controller
control={control}
name="isAutoScanEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={
value
? `Scans will automatically be triggered when a ${autoScanDescription.verb} occurs to ${autoScanDescription.pluralNoun} associated with this data source.`
: "Manually trigger scans to detect secret leaks."
}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="auto-scan-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p className="w-[9.6rem]">Auto-Scan {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
);
}}
/>
</>
);
};
@@ -0,0 +1 @@
export * from "./SecretScanningDataSourceConfigFields";
@@ -0,0 +1,105 @@
import { Controller, useFormContext } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/helpers/secretScanningV2";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { TSecretScanningDataSourceForm } from "./schemas";
type Props = {
onChange?: VoidFunction;
isUpdate?: boolean;
};
export const SecretScanningDataSourceConnectionField = ({
onChange: callback,
isUpdate
}: Props) => {
const { permission } = useOrgPermission();
const { control, watch } = useFormContext<TSecretScanningDataSourceForm>();
const dataSourceType = watch("type");
const app = SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSourceType];
const { data: availableConnections, isPending } = useListAvailableAppConnections(app);
const connectionName = APP_CONNECTION_MAP[app].name;
const canCreateConnection = permission.can(
OrgPermissionAppConnectionActions.Create,
OrgPermissionSubjects.AppConnections
);
const appName = APP_CONNECTION_MAP[app].name;
return (
<>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="App Connections can be created from the Organization Settings page."
isError={Boolean(error)}
errorText={error?.message}
label={`${connectionName} Connection`}
helperText={
isUpdate ? (
"Cannot be updated"
) : (
<p>
Check out{" "}
<a
href={`https://infisical.com/docs/integrations/app-connections/${app}`}
target="_blank"
className="underline"
rel="noopener noreferrer"
>
our docs
</a>{" "}
to ensure your connection has the required permissions for secret scanning.
</p>
)
}
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
if (callback) callback();
}}
isLoading={isPending}
options={availableConnections}
isDisabled={isUpdate}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="connection"
/>
{!isUpdate && availableConnections?.length === 0 && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? (
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link to="/organization/app-connections" className="underline">
App Connections
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p>
)}
</>
);
};
@@ -0,0 +1,51 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { TSecretScanningDataSourceForm } from "./schemas";
export const SecretScanningDataSourceDetailsFields = () => {
const { control } = useFormContext<TSecretScanningDataSourceForm>();
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Provide a name and description for this Data Source.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
helperText="Must be slug-friendly"
isError={Boolean(error)}
errorText={error?.message}
label="Name"
>
<Input autoFocus value={value} onChange={onChange} placeholder="my-data-source" />
</FormControl>
)}
control={control}
name="name"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Description"
>
<TextArea
value={value ?? ""}
onChange={onChange}
placeholder="Provide a description for this data source..."
className="!resize-none"
rows={4}
/>
</FormControl>
)}
control={control}
name="description"
/>
</>
);
};
@@ -0,0 +1,180 @@
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2";
import {
SecretScanningDataSource,
TSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2";
import {
useCreateSecretScanningDataSource,
useUpdateSecretScanningDataSource
} from "@app/hooks/api/secretScanningV2/mutations";
import { SecretScanningDataSourceSchema, TSecretScanningDataSourceForm } from "./schemas";
import { SecretScanningDataSourceConfigFields } from "./SecretScanningDataSourceConfigFields";
import { SecretScanningDataSourceDetailsFields } from "./SecretScanningDataSourceDetailsFields";
import { SecretScanningDataSourceReviewFields } from "./SecretScanningDataSourceReviewFields";
type Props = {
onComplete: (dataSource: TSecretScanningDataSource) => void;
type: SecretScanningDataSource;
onCancel: () => void;
dataSource?: TSecretScanningDataSource;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TSecretScanningDataSourceForm)[] }[] =
[
{ name: "Configuration", key: "config", fields: ["config", "isAutoScanEnabled", "connection"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
];
export const SecretScanningDataSourceForm = ({ type, onComplete, onCancel, dataSource }: Props) => {
const createDataSource = useCreateSecretScanningDataSource();
const updateDataSource = useUpdateSecretScanningDataSource();
const { currentWorkspace } = useWorkspace();
const { name: sourceType } = SECRET_SCANNING_DATA_SOURCE_MAP[type];
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const formMethods = useForm<TSecretScanningDataSourceForm>({
resolver: zodResolver(SecretScanningDataSourceSchema),
defaultValues: dataSource ?? {
type,
isAutoScanEnabled: true // scott: this may need to be derived from type in the future
},
reValidateMode: "onChange"
});
const onSubmit = async ({ connection, ...formData }: TSecretScanningDataSourceForm) => {
const mutation = dataSource
? updateDataSource.mutateAsync({
dataSourceId: dataSource.id,
projectId: dataSource.projectId,
...formData
})
: createDataSource.mutateAsync({
...formData,
connectionId: connection?.id,
projectId: currentWorkspace.id
});
try {
const rotation = await mutation;
createNotification({
text: `Successfully ${dataSource ? "updated" : "created"} ${sourceType} Data Source`,
type: "success"
});
onComplete(rotation);
} catch (err: any) {
createNotification({
title: `Failed to ${dataSource ? "update" : "create"} ${sourceType} Data Source`,
text: err.message,
type: "error"
});
}
};
const handlePrev = () => {
if (selectedTabIndex === 0) {
onCancel();
return;
}
setSelectedTabIndex((prev) => prev - 1);
};
const {
handleSubmit,
trigger,
formState: { isSubmitting }
} = formMethods;
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
const handleNext = async () => {
if (isFinalStep) {
handleSubmit(onSubmit)();
return;
}
const isValid = await isStepValid(selectedTabIndex);
if (!isValid) return;
setSelectedTabIndex((prev) => prev + 1);
};
const isTabEnabled = async (index: number) => {
let isEnabled = true;
for (let i = index - 1; i >= 0; i -= 1) {
// eslint-disable-next-line no-await-in-loop
isEnabled = isEnabled && (await isStepValid(i));
}
return isEnabled;
};
return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
<FormProvider {...formMethods}>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
{FORM_TABS.map((tab, index) => (
<Tab
onClick={async (e) => {
e.preventDefault();
const isEnabled = await isTabEnabled(index);
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
}}
className={({ selected }) =>
`w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
key={tab.key}
>
{index + 1}. {tab.name}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<SecretScanningDataSourceConfigFields />
</Tab.Panel>
<Tab.Panel>
<SecretScanningDataSourceDetailsFields />
</Tab.Panel>
<Tab.Panel>
<SecretScanningDataSourceReviewFields />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button
onClick={handleNext}
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema="secondary"
>
{isFinalStep ? `${dataSource ? "Update" : "Create"} Data Source` : "Next"}
</Button>
<Button onClick={handlePrev} colorSchema="secondary">
Back
</Button>
</div>
</form>
);
};
@@ -0,0 +1,27 @@
import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { TSecretScanningDataSourceForm } from "../schemas";
import { SecretScanningDataSourceConfigReviewSection } from "./shared";
export const GitHubDataSourceReviewFields = () => {
const { watch } = useFormContext<
TSecretScanningDataSourceForm & {
type: SecretScanningDataSource.GitHub;
}
>();
const [{ includeRepos }, connection] = watch(["config", "connection"]);
const shouldScanAll = includeRepos[0] === "*";
return (
<SecretScanningDataSourceConfigReviewSection>
{connection && <GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>}
<GenericFieldLabel label="Scan Repositories">
{shouldScanAll ? "All" : includeRepos.join(", ")}
</GenericFieldLabel>
</SecretScanningDataSourceConfigReviewSection>
);
};
@@ -0,0 +1,34 @@
import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { TSecretScanningDataSourceForm } from "../schemas";
import { GitHubDataSourceReviewFields } from "./GitHubDataSourceReviewFields";
const COMPONENT_MAP: Record<SecretScanningDataSource, React.FC> = {
[SecretScanningDataSource.GitHub]: GitHubDataSourceReviewFields
};
export const SecretScanningDataSourceReviewFields = () => {
const { watch } = useFormContext<TSecretScanningDataSourceForm>();
const { type, name, description } = watch();
const Component = COMPONENT_MAP[type];
return (
<div className="mb-4 flex flex-col gap-6">
<Component />
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretScanningDataSourceReviewFields";
@@ -0,0 +1,16 @@
import { ReactNode } from "react";
type Props = {
children: ReactNode;
};
export const SecretScanningDataSourceConfigReviewSection = ({ children }: Props) => {
return (
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Configuration</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">{children}</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretScanningDataSourceConfigReviewSection";
@@ -0,0 +1 @@
export * from "./SecretScanningDataSourceForm";
@@ -0,0 +1,18 @@
import { z } from "zod";
import { slugSchema } from "@app/lib/schemas";
type SchemaOptions = {
isConnectionRequired: boolean;
};
export const BaseSecretScanningDataSourceSchema = ({ isConnectionRequired }: SchemaOptions) =>
z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").nullish(),
connection: isConnectionRequired
? z.object({ name: z.string(), id: z.string().uuid() })
: z.null().or(z.undefined()),
isAutoScanEnabled: z.boolean(),
id: z.string().uuid().optional()
});
@@ -0,0 +1,14 @@
import { z } from "zod";
import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2";
import { BaseSecretScanningDataSourceSchema } from "./base-secret-scanning-data-source-schema";
export const GitHubDataSourceSchema = z
.object({
type: z.literal(SecretScanningDataSource.GitHub),
config: z.object({
includeRepos: z.string().array().min(1, "One or more repositories required")
})
})
.merge(BaseSecretScanningDataSourceSchema({ isConnectionRequired: true }));
@@ -0,0 +1,9 @@
import { z } from "zod";
import { GitHubDataSourceSchema } from "./github-data-source-schema";
export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [
GitHubDataSourceSchema
]);
export type TSecretScanningDataSourceForm = z.infer<typeof SecretScanningDataSourceSchema>;
@@ -0,0 +1,3 @@
export * from "./CreateSecretScanningDataSourceModal";
export * from "./EditSecretScanningDataSourceModal";
export * from "./SecretScanningScanStatus";
+6 -2
View File
@@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge";
interface IProps {
children: React.ReactNode;
className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
const badgeVariants = cva(
@@ -23,9 +24,12 @@ const badgeVariants = cva(
export type BadgeProps = VariantProps<typeof badgeVariants> & IProps;
export const Badge = ({ children, className, variant }: BadgeProps) => {
export const Badge = ({ children, className, variant, ...props }: BadgeProps) => {
return (
<div className={twMerge(badgeVariants({ variant: variant || "primary" }), className)}>
<div
className={twMerge(badgeVariants({ variant: variant || "primary" }), className)}
{...props}
>
{children}
</div>
);
+3 -1
View File
@@ -18,6 +18,7 @@ type Props = {
isDisabled?: boolean;
icon?: IconProp;
isMulti?: boolean;
iconClassName?: string;
};
export type SelectProps = Omit<SelectPrimitive.SelectProps, "disabled"> & Props;
@@ -33,6 +34,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
dropdownContainerClassName,
position,
containerClassName,
iconClassName,
...props
},
ref
@@ -58,7 +60,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
)}
>
<div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
{props.icon && <FontAwesomeIcon icon={props.icon} />}
{props.icon && <FontAwesomeIcon icon={props.icon} className={iconClassName} />}
<div className="flex-1 truncate">
<SelectPrimitive.Value placeholder={placeholder} />
</div>