diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3e463cfe60..4bf3030402 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,3 @@ -### Motivation - -Why it this PR useful for the project? - ### Description What was done in this PR diff --git a/actions/providers/providers.ts b/actions/providers/providers.ts index 4578786534..cf079892b4 100644 --- a/actions/providers/providers.ts +++ b/actions/providers/providers.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/auth.config"; -import { getErrorMessage, parseStringify } from "@/lib"; +import { getErrorMessage, parseStringify, wait } from "@/lib"; export const getProviders = async ({ page = 1, @@ -114,7 +114,7 @@ export const addProvider = async (formData: FormData) => { const keyServer = process.env.API_BASE_URL; const providerType = formData.get("providerType"); - const providerId = formData.get("providerId"); + const providerUid = formData.get("providerUid"); const providerAlias = formData.get("providerAlias"); const url = new URL(`${keyServer}/providers`); @@ -132,7 +132,7 @@ export const addProvider = async (formData: FormData) => { type: "Provider", attributes: { provider: providerType, - uid: providerId, + uid: providerUid, alias: providerAlias, }, }, @@ -149,11 +149,109 @@ export const addProvider = async (formData: FormData) => { } }; +export const addCredentialsProvider = async (formData: FormData) => { + const session = await auth(); + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/providers/secrets`); + + const secretName = formData.get("secretName"); + const providerId = formData.get("providerId"); + const providerType = formData.get("providerType"); + + const isRole = formData.get("role_arn") !== null; + + let secret = {}; + let secretType = "static"; // Default to static credentials + + if (providerType === "aws") { + if (isRole) { + // Role-based configuration for AWS + secretType = "role"; + secret = { + role_arn: formData.get("role_arn"), + aws_access_key_id: formData.get("aws_access_key_id") || undefined, + aws_secret_access_key: + formData.get("aws_secret_access_key") || undefined, + aws_session_token: formData.get("aws_session_token") || undefined, + session_duration: + parseInt(formData.get("session_duration") as string, 10) || 3600, + external_id: formData.get("external_id") || undefined, + role_session_name: formData.get("role_session_name") || undefined, + }; + } else { + // Static credentials configuration for AWS + secret = { + aws_access_key_id: formData.get("aws_access_key_id"), + aws_secret_access_key: formData.get("aws_secret_access_key"), + aws_session_token: formData.get("aws_session_token") || undefined, + }; + } + } else if (providerType === "azure") { + // Static credentials configuration for Azure + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + tenant_id: formData.get("tenant_id"), + }; + } else if (providerType === "gcp") { + // Static credentials configuration for GCP + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + refresh_token: formData.get("refresh_token"), + }; + } else if (providerType === "kubernetes") { + // Static credentials configuration for Kubernetes + secret = { + kubeconfig_content: formData.get("kubeconfig_content"), + }; + } + + const bodyData = { + data: { + type: "ProviderSecret", + attributes: { + secret_type: secretType, + secret, + name: secretName, + }, + relationships: { + provider: { + data: { + id: providerId, + type: "Provider", + }, + }, + }, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + body: JSON.stringify(bodyData), + }); + const data = await response.json(); + revalidatePath("/providers"); + return parseStringify(data); + } catch (error) { + console.error(error); + return { + error: getErrorMessage(error), + }; + } +}; + export const checkConnectionProvider = async (formData: FormData) => { - // const session = await auth(); + const session = await auth(); const keyServer = process.env.API_BASE_URL; - const providerId = formData.get("id"); + const providerId = formData.get("providerId"); const url = new URL(`${keyServer}/providers/${providerId}/connection`); @@ -162,6 +260,30 @@ export const checkConnectionProvider = async (formData: FormData) => { method: "POST", headers: { Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); + const data = await response.json(); + await wait(1000); + revalidatePath("/providers"); + return parseStringify(data); + } catch (error) { + return { + error: getErrorMessage(error), + }; + } +}; + +export const deleteCredentials = async (secretId: string) => { + const session = await auth(); + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/providers/secrets/${secretId}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers: { + Authorization: `Bearer ${session?.accessToken}`, }, }); const data = await response.json(); @@ -190,6 +312,7 @@ export const deleteProvider = async (formData: FormData) => { }, }); const data = await response.json(); + await wait(1000); revalidatePath("/providers"); return parseStringify(data); } catch (error) { diff --git a/actions/task/index.ts b/actions/task/index.ts new file mode 100644 index 0000000000..078ba8fc62 --- /dev/null +++ b/actions/task/index.ts @@ -0,0 +1 @@ +export * from "./tasks"; diff --git a/actions/task/tasks.ts b/actions/task/tasks.ts new file mode 100644 index 0000000000..fdf75c7cea --- /dev/null +++ b/actions/task/tasks.ts @@ -0,0 +1,24 @@ +"use server"; + +import { auth } from "@/auth.config"; +import { getErrorMessage, parseStringify } from "@/lib"; + +export const getTask = async (taskId: string) => { + const session = await auth(); + + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/tasks/${taskId}`); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); + const data = await response.json(); + return parseStringify(data); + } catch (error) { + return { error: getErrorMessage(error) }; + } +}; diff --git a/app/(prowler)/findings/page.tsx b/app/(prowler)/findings/page.tsx index f75638f792..1c277cad0c 100644 --- a/app/(prowler)/findings/page.tsx +++ b/app/(prowler)/findings/page.tsx @@ -18,6 +18,8 @@ export default async function Findings({ }: { searchParams: SearchParamsProps; }) { + const searchParamsKey = JSON.stringify(searchParams || {}); + return ( <>
@@ -25,7 +27,7 @@ export default async function Findings({ - }> + }> diff --git a/app/(prowler)/page.tsx b/app/(prowler)/page.tsx index 261ca1c00f..335b53b4f4 100644 --- a/app/(prowler)/page.tsx +++ b/app/(prowler)/page.tsx @@ -1,17 +1,13 @@ import { Spacer } from "@nextui-org/react"; -import { SeverityChart, StatusChart } from "@/components/charts"; -import { AttackSurface } from "@/components/overview"; import { Header } from "@/components/ui"; -import { CustomBox } from "@/components/ui/custom"; export default function Home() { return ( <>
- - -
+ + {/*
-
+
*/} ); } diff --git a/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx b/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx new file mode 100644 index 0000000000..6a525990fa --- /dev/null +++ b/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx @@ -0,0 +1,35 @@ +import { redirect } from "next/navigation"; +import React from "react"; + +import { + ViaCredentialsForm, + ViaRoleForm, +} from "@/components/providers/workflow/forms"; + +interface Props { + searchParams: { type: string; id: string; via?: string }; +} + +export default function AddCredentialsPage({ searchParams }: Props) { + if ( + !searchParams.type || + !searchParams.id || + (searchParams.type === "aws" && !searchParams.via) + ) { + redirect("/providers/connect-account"); + } + + const useCredentialsForm = + (searchParams.type === "aws" && searchParams.via === "credentials") || + (searchParams.type !== "aws" && !searchParams.via); + + const useRoleForm = + searchParams.type === "aws" && searchParams.via === "role"; + + return ( + <> + {useCredentialsForm && } + {useRoleForm && } + + ); +} diff --git a/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx b/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx new file mode 100644 index 0000000000..67094101f5 --- /dev/null +++ b/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import React from "react"; + +import { ConnectAccountForm } from "@/components/providers/workflow/forms"; + +export default function ConnectAccountPage() { + return ; +} diff --git a/app/(prowler)/providers/(set-up-provider)/launch-scan/page.tsx b/app/(prowler)/providers/(set-up-provider)/launch-scan/page.tsx new file mode 100644 index 0000000000..3e88a5ea46 --- /dev/null +++ b/app/(prowler)/providers/(set-up-provider)/launch-scan/page.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import React from "react"; + +import { getProvider } from "@/actions/providers"; +import { LaunchScanForm } from "@/components/providers/workflow/forms"; + +interface Props { + searchParams: { type: string; id: string }; +} + +export default async function LaunchScanPage({ searchParams }: Props) { + const providerId = searchParams.id; + + if (!providerId) { + redirect("/providers/connect-account"); + } + + const formData = new FormData(); + formData.append("id", providerId); + + const providerData = await getProvider(formData); + + const isConnected = providerData?.data?.attributes?.connection?.connected; + + if (!isConnected) { + redirect("/providers/connect-account"); + } + + return ( + + ); +} diff --git a/app/(prowler)/providers/(set-up-provider)/layout.tsx b/app/(prowler)/providers/(set-up-provider)/layout.tsx new file mode 100644 index 0000000000..788ce9cd96 --- /dev/null +++ b/app/(prowler)/providers/(set-up-provider)/layout.tsx @@ -0,0 +1,32 @@ +import "@/styles/globals.css"; + +import { Spacer } from "@nextui-org/react"; +import React from "react"; + +import { Workflow } from "@/components/providers/workflow"; +import { NavigationHeader } from "@/components/ui"; + +interface ProviderLayoutProps { + children: React.ReactNode; +} + +export default function ProviderLayout({ children }: ProviderLayoutProps) { + return ( + <> + + +
+
+ +
+
+ {children} +
+
+ + ); +} diff --git a/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx b/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx new file mode 100644 index 0000000000..13cac50f6d --- /dev/null +++ b/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation"; +import React, { Suspense } from "react"; + +import { getProvider } from "@/actions/providers"; +import { TestConnectionForm } from "@/components/providers/workflow/forms"; + +interface Props { + searchParams: { type: string; id: string }; +} + +export default async function TestConnectionPage({ searchParams }: Props) { + const providerId = searchParams.id; + + if (!providerId) { + redirect("/providers/connect-account"); + } + + return ( + Loading...

}> + +
+ ); +} + +async function SSRTestConnection({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) { + const formData = new FormData(); + formData.append("id", searchParams.id); + + const providerData = await getProvider(formData); + if (providerData.errors) { + redirect("/providers/connect-account"); + } + + return ( + + ); +} diff --git a/app/(prowler)/providers/page.tsx b/app/(prowler)/providers/page.tsx index 02860e2a0b..177b403745 100644 --- a/app/(prowler)/providers/page.tsx +++ b/app/(prowler)/providers/page.tsx @@ -26,7 +26,6 @@ export default async function Providers({ - diff --git a/components/findings/table/column-findings.tsx b/components/findings/table/column-findings.tsx index 3391207093..9483ee9b86 100644 --- a/components/findings/table/column-findings.tsx +++ b/components/findings/table/column-findings.tsx @@ -2,12 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"; -import { - DataTableColumnHeader, - SeverityBadge, - Status, - StatusBadge, -} from "@/components/ui/table"; +import { SeverityBadge, Status, StatusBadge } from "@/components/ui/table"; import { FindingProps } from "@/types"; import { DataTableRowActions } from "./data-table-row-actions"; @@ -20,7 +15,6 @@ const statusMap: Record<"PASS" | "FAIL" | "MANUAL" | "MUTED", Status> = { }; const getFindingsData = (row: { original: FindingProps }) => { - console.log(row.original); return row.original; }; @@ -94,7 +88,7 @@ export const ColumnFindings: ColumnDef[] = [ }, { accessorKey: "status", - header: "Scan Status", + header: "Status", cell: ({ row }) => { const { attributes: { status }, diff --git a/components/findings/table/data-table-row-actions.tsx b/components/findings/table/data-table-row-actions.tsx index 9513bb11a8..f96f804fc9 100644 --- a/components/findings/table/data-table-row-actions.tsx +++ b/components/findings/table/data-table-row-actions.tsx @@ -10,7 +10,6 @@ import { } from "@nextui-org/react"; import { // AddNoteBulkIcon, - DeleteDocumentBulkIcon, EditDocumentBulkIcon, } from "@nextui-org/shared-icons"; import { Row } from "@tanstack/react-table"; @@ -31,10 +30,8 @@ const iconClasses = export function DataTableRowActions({ row, }: DataTableRowActionsProps) { - // const [isEditOpen, setIsEditOpen] = useState(false); - // const [isDeleteOpen, setIsDeleteOpen] = useState(false); - // const providerId = (row.original as { id: string }).id; - // const providerAlias = (row.original as any).attributes?.alias; + const findingId = (row.original as { id: string }).id; + console.log(findingId); return ( <> {/* = ({ ); -export const WifiIcon: React.FC = ({ - size = 24, - width, - height, - ...props -}) => ( - -); - -export const WifiOffIcon: React.FC = ({ - size = 24, - width, - height, - ...props -}) => ( - -); - -export const WifiPendingIcon: React.FC = ({ - size = 24, - width, - height, - ...props -}) => ( - -); export const DeleteIcon: React.FC = ({ size, @@ -860,3 +758,30 @@ export const AddIcon: React.FC = ({ ); }; + +export const ScheduleIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; diff --git a/components/providers/CheckConnectionProvider.tsx b/components/providers/CheckConnectionProvider.tsx deleted file mode 100644 index d5bc9e727d..0000000000 --- a/components/providers/CheckConnectionProvider.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useRef } from "react"; - -import { checkConnectionProvider } from "@/actions/providers"; - -import { CustomButtonClientAction } from "../ui/custom"; -import { useToast } from "../ui/toast"; - -export const CheckConnectionProvider = ({ id }: { id: string }) => { - const ref = useRef(null); - const { toast } = useToast(); - - async function clientAction(formData: FormData) { - // reset the form - ref.current?.reset(); - // client-side validation - const data = await checkConnectionProvider(formData); - if (data?.errors && data.errors.length > 0) { - const error = data.errors[0]; - const errorMessage = `${error.detail}`; - // show error - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } else { - toast({ - title: "Checking", - description: "The task was launched successfully", - }); - } - } - - return ( -
- - - - ); -}; diff --git a/components/providers/add-provider.tsx b/components/providers/add-provider.tsx index 3033f8b904..c732d41007 100644 --- a/components/providers/add-provider.tsx +++ b/components/providers/add-provider.tsx @@ -1,39 +1,21 @@ "use client"; -import { useState } from "react"; - import { AddIcon } from "../icons"; -import { CustomAlertModal, CustomButton } from "../ui/custom"; -import { AddForm } from "./forms"; +import { CustomButton } from "../ui/custom"; export const AddProvider = () => { - const [isAddOpen, setIsAddOpen] = useState(false); - return ( - <> - + } > - - - -
- setIsAddOpen(true)} - endContent={} - > - Add Account - -
- + Add Account + + ); }; diff --git a/components/providers/forms/add-form.tsx b/components/providers/forms/add-form.tsx deleted file mode 100644 index 3529995cfa..0000000000 --- a/components/providers/forms/add-form.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Dispatch, SetStateAction } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; - -import { addProvider } from "@/actions/providers"; -import { SaveIcon } from "@/components/icons"; -import { useToast } from "@/components/ui"; -import { CustomButton, CustomInput } from "@/components/ui/custom"; -import { Form } from "@/components/ui/form"; -import { addProviderFormSchema } from "@/types"; - -import { RadioGroupProvider } from "../radio-group-provider"; - -export const AddForm = ({ - setIsOpen, -}: { - setIsOpen: Dispatch>; -}) => { - const formSchema = addProviderFormSchema; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - providerType: "", - providerId: "", - providerAlias: "", - }, - }); - - const { toast } = useToast(); - - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: z.infer) => { - const formData = new FormData(); - - Object.entries(values).forEach( - ([key, value]) => value !== undefined && formData.append(key, value), - ); - - const data = await addProvider(formData); - - if (data?.errors && data.errors.length > 0) { - const error = data.errors[0]; - const errorMessage = `${error.detail}`; - // show error - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } else { - toast({ - title: "Success!", - description: "The provider was updated successfully.", - }); - setIsOpen(false); // Close the modal on success - } - }; - - return ( -
- - - - - -
- setIsOpen(false)} - isDisabled={isLoading} - > - Cancel - - - } - > - {isLoading ? <>Loading : Confirm} - -
- - - ); -}; diff --git a/components/providers/forms/index.ts b/components/providers/forms/index.ts index 6b31209f91..a081952ade 100644 --- a/components/providers/forms/index.ts +++ b/components/providers/forms/index.ts @@ -1,3 +1,2 @@ -export * from "./add-form"; export * from "./delete-form"; export * from "./edit-form"; diff --git a/components/providers/index.ts b/components/providers/index.ts index 5093cbf0ac..6fb3bcae31 100644 --- a/components/providers/index.ts +++ b/components/providers/index.ts @@ -1,5 +1,4 @@ export * from "./add-provider"; -export * from "./CheckConnectionProvider"; export * from "./forms/delete-form"; export * from "./provider-info"; export * from "./radio-group-provider"; diff --git a/components/providers/radio-group-provider.tsx b/components/providers/radio-group-provider.tsx index 82ee2edbae..e376e69ff4 100644 --- a/components/providers/radio-group-provider.tsx +++ b/components/providers/radio-group-provider.tsx @@ -1,7 +1,6 @@ "use client"; -import { UseRadioProps } from "@nextui-org/radio/dist/use-radio"; -import { cn, RadioGroup, useRadio, VisuallyHidden } from "@nextui-org/react"; +import { RadioGroup } from "@nextui-org/react"; import React from "react"; import { Control, Controller } from "react-hook-form"; import { z } from "zod"; @@ -11,61 +10,19 @@ import { addProviderFormSchema } from "@/types"; import { AWSProviderBadge, AzureProviderBadge } from "../icons/providers-badge"; import { GCPProviderBadge } from "../icons/providers-badge/GCPProviderBadge"; import { KS8ProviderBadge } from "../icons/providers-badge/KS8ProviderBadge"; +import { CustomRadio } from "../ui/custom"; import { FormMessage } from "../ui/form"; -interface CustomRadioProps extends UseRadioProps { - description?: string; - children?: React.ReactNode; -} - -export const CustomRadio: React.FC = (props) => { - const { - Component, - children, - // description, - getBaseProps, - getWrapperProps, - getInputProps, - getLabelProps, - getLabelWrapperProps, - getControlProps, - } = useRadio(props); - - return ( - - - - - - - -
- {children && {children}} - {/* {description && ( - - {description} - - )} */} -
-
- ); -}; - interface RadioGroupProviderProps { control: Control>; isInvalid: boolean; + errorMessage?: string; } export const RadioGroupProvider: React.FC = ({ control, isInvalid, + errorMessage, }) => { return ( = ({ <> -
+
- AWS + Amazon Web Services
- GCP + Google Cloud Platform
- Azure + Microsoft Azure
@@ -106,7 +63,11 @@ export const RadioGroupProvider: React.FC = ({
- + {errorMessage && ( + + {errorMessage} + + )} )} /> diff --git a/components/providers/table/data-table-row-actions.tsx b/components/providers/table/data-table-row-actions.tsx index 35f14d395c..0642114bb4 100644 --- a/components/providers/table/data-table-row-actions.tsx +++ b/components/providers/table/data-table-row-actions.tsx @@ -20,7 +20,6 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { CustomAlertModal } from "@/components/ui/custom"; -import { CheckConnectionProvider } from "../CheckConnectionProvider"; import { EditForm } from "../forms"; import { DeleteForm } from "../forms/delete-form"; @@ -36,6 +35,7 @@ export function DataTableRowActions({ const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const providerId = (row.original as { id: string }).id; + const providerType = (row.original as any).attributes?.provider; const providerAlias = (row.original as any).attributes?.alias; return ( <> @@ -75,12 +75,13 @@ export function DataTableRowActions({ > } > - + Test Connection ; + +export const ConnectAccountForm = () => { + const { toast } = useToast(); + const [prevStep, setPrevStep] = useState(1); + const router = useRouter(); + + const formSchema = addProviderFormSchema; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerType: undefined, + providerUid: "", + providerAlias: "", + awsCredentialsType: "", + }, + }); + const providerType = form.watch("providerType"); + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formValues = { ...values }; + + // If providerAlias is empty, set default value + if (!formValues.providerAlias.trim()) { + const date = new Date(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const year = date.getFullYear(); + formValues.providerAlias = `${formValues.providerType}:${month}/${day}/${year}`; + } + + const formData = new FormData(); + + Object.entries(formValues).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await addProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/provider": + form.setError("providerType", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/uid": + case "/data/attributes/__all__": + form.setError("providerUid", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/alias": + form.setError("providerAlias", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + setPrevStep(1); + } else { + const { + id, + attributes: { provider: providerType }, + } = data.data; + const credentialsParam = values.awsCredentialsType + ? `&via=${values.awsCredentialsType}` + : ""; + router.push( + `/providers/add-credentials?type=${providerType}&id=${id}${credentialsParam}`, + ); + } + }; + + const handleNextStep = () => { + setPrevStep((prev) => prev + 1); + }; + + const handleBackStep = () => { + setPrevStep((prev) => prev - 1); + }; + + return ( +
+ + {prevStep === 1 && ( + <> + {/* Select a provider */} + + {/* Provider UID */} + + {/* Provider alias */} + + + )} + + {prevStep === 2 && ( + <> + {/* Select AWS credentials type */} + + + )} + +
+ {prevStep === 2 && ( + } + isDisabled={isLoading} + > + Back + + )} + + + ) + } + endContent={ + !isLoading && + prevStep === 1 && + providerType === "aws" && + } + onPress={() => { + if (prevStep === 1 && providerType === "aws") { + handleNextStep(); + } else { + form.handleSubmit(onSubmitClient)(); + } + }} + > + {isLoading ? ( + <>Loading + ) : ( + + {prevStep === 1 && providerType === "aws" ? "Next" : "Save"} + + )} + +
+ + + ); +}; diff --git a/components/providers/workflow/forms/index.ts b/components/providers/workflow/forms/index.ts new file mode 100644 index 0000000000..be1bb3277d --- /dev/null +++ b/components/providers/workflow/forms/index.ts @@ -0,0 +1,6 @@ +export * from "./connect-account-form"; +export * from "./launch-scan-form"; +export * from "./radio-group-aws-via-credentials-form"; +export * from "./test-connection-form"; +export * from "./via-credentials-form"; +export * from "./via-role-form"; diff --git a/components/providers/workflow/forms/launch-scan-form.tsx b/components/providers/workflow/forms/launch-scan-form.tsx new file mode 100644 index 0000000000..86325d437b --- /dev/null +++ b/components/providers/workflow/forms/launch-scan-form.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { scanOnDemand } from "@/actions/scans/scans"; +import { RocketIcon, ScheduleIcon } from "@/components/icons"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { ProviderProps } from "@/types"; // Asegúrate de importar la interfaz correcta +import { launchScanFormSchema } from "@/types/formSchemas"; + +import { ProviderInfo } from "../../provider-info"; + +type FormValues = z.infer>; + +interface LaunchScanFormProps { + searchParams: { type: string; id: string }; + providerData: { + data: { + type: string; + id: string; + attributes: ProviderProps["attributes"]; + }; + }; +} + +export const LaunchScanForm = ({ + searchParams, + providerData, +}: LaunchScanFormProps) => { + const providerType = searchParams.type; + const providerId = searchParams.id; + + const [apiErrorMessage, setApiErrorMessage] = useState(null); + const router = useRouter(); + + const formSchema = launchScanFormSchema(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + scannerArgs: { + checksToExecute: [], + }, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formData = new FormData(); + formData.append("providerId", values.providerId); + + // Generate default scan name using provider type and current date + const date = new Date(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const year = date.getFullYear(); + const defaultScanName = `${providerType}:${month}/${day}/${year}`; + + formData.append("scanName", defaultScanName); + + try { + const data = await scanOnDemand(formData); + + if (data.error) { + setApiErrorMessage(data.error); + form.setError("providerId", { + type: "server", + message: data.error, + }); + } else { + router.push("/scans"); + } + } catch (error) { + form.setError("providerId", { + type: "server", + message: "An unexpected error occurred. Please try again.", + }); + } + }; + + return ( +
+ +
+
+ Launch scan +
+
+ Launch the scan now or schedule it for a later date and time. +
+
+ + {apiErrorMessage && ( +
+

{apiErrorMessage.toLowerCase()}

+
+ )} + + + + + + +
+ } + isDisabled={true} + > + Schedule + + } + > + {isLoading ? <>Loading : Start now} + +
+ + + ); +}; diff --git a/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx b/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx new file mode 100644 index 0000000000..7a870c79ff --- /dev/null +++ b/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { RadioGroup } from "@nextui-org/react"; +import React from "react"; +import { Control, Controller } from "react-hook-form"; + +import { CustomRadio } from "@/components/ui/custom"; +import { FormMessage } from "@/components/ui/form"; + +import { FormValues } from "./connect-account-form"; + +type RadioGroupAWSViaCredentialsFormProps = { + control: Control; + isInvalid: boolean; + errorMessage?: string; +}; + +export const RadioGroupAWSViaCredentialsForm = ({ + control, + isInvalid, + errorMessage, +}: RadioGroupAWSViaCredentialsFormProps) => { + return ( + ( + <> + +
+ Using IAM Role + +
+ Connect assuming IAM Role +
+
+ + Using Credentials + + +
+ Connect via Credentials +
+
+
+
+ {errorMessage && ( + + {errorMessage} + + )} + + )} + /> + ); +}; diff --git a/components/providers/workflow/forms/test-connection-form.tsx b/components/providers/workflow/forms/test-connection-form.tsx new file mode 100644 index 0000000000..6d7ca16dbe --- /dev/null +++ b/components/providers/workflow/forms/test-connection-form.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Icon } from "@iconify/react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + checkConnectionProvider, + deleteCredentials, +} from "@/actions/providers"; +import { getTask } from "@/actions/task/tasks"; +import { CheckIcon, SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { checkTaskStatus } from "@/lib/helper"; +import { ApiError, testConnectionFormSchema } from "@/types"; + +import { ProviderInfo } from "../.."; + +type FormValues = z.infer; + +export const TestConnectionForm = ({ + searchParams, + providerData, +}: { + searchParams: { type: string; id: string }; + providerData: { + data: { + id: string; + type: string; + attributes: { + connection: { + connected: boolean | null; + last_checked_at: string | null; + }; + provider: "aws" | "azure" | "gcp" | "kubernetes"; + alias: string; + scanner_args: Record; + }; + relationships: { + secret: { + data: { + type: string; + id: string; + } | null; + }; + }; + }; + }; +}) => { + const { toast } = useToast(); + const router = useRouter(); + const providerType = searchParams.type; + const providerId = searchParams.id; + console.log({ providerData }, "providerData from test connection form"); + const formSchema = testConnectionFormSchema; + const [apiErrorMessage, setApiErrorMessage] = useState(null); + const [connectionStatus, setConnectionStatus] = useState<{ + connected: boolean; + error: string | null; + } | null>(null); + const [isResettingCredentials, setIsResettingCredentials] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + console.log({ values }, "values from test connection form"); + const formData = new FormData(); + formData.append("providerId", values.providerId); + + const data = await checkConnectionProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + + switch (errorMessage) { + case "Not found.": + setApiErrorMessage(errorMessage); + break; + default: + toast({ + variant: "destructive", + title: `Error ${error.status}`, + description: errorMessage, + }); + } + }); + } else { + const taskId = data.data.id; + setApiErrorMessage(null); + + // Use the helper function to check the task status + const taskResult = await checkTaskStatus(taskId); + + if (taskResult.completed) { + const task = await getTask(taskId); + const { connected, error } = task.data.attributes.result; + + setConnectionStatus({ + connected, + error: connected ? null : error || "Unknown error", + }); + + if (connected) { + router.push( + `/providers/launch-scan?type=${providerType}&id=${providerId}`, + ); + } else { + setConnectionStatus({ + connected: false, + error: error || "Connection failed, please review credentials.", + }); + } + } else { + setConnectionStatus({ + connected: false, + error: taskResult.error || "Unknown error", + }); + } + } + }; + + const onResetCredentials = async () => { + setIsResettingCredentials(true); + + // Check if provider the provider has no credentials + const providerSecretId = + providerData?.data?.relationships?.secret?.data?.id; + const hasNoCredentials = !providerSecretId; + + if (hasNoCredentials) { + // If no credentials, redirect to add credentials page + router.push( + `/providers/add-credentials?type=${providerType}&id=${providerId}`, + ); + return; + } + + // If provider has credentials, delete them first + try { + await deleteCredentials(providerSecretId); + // After successful deletion, redirect to add credentials page + router.push( + `/providers/add-credentials?type=${providerType}&id=${providerId}`, + ); + } catch (error) { + console.error("Failed to delete credentials:", error); + } finally { + setIsResettingCredentials(false); + } + }; + + return ( +
+ +
+
+ Test connection +
+

+ Ensure all required credentials and configurations are completed + accurately. A successful connection will enable the option to + initiate a scan in the following step. +

+
+ + {apiErrorMessage && ( +
+

{`Provider ID ${apiErrorMessage.toLowerCase()}. Please check and try again.`}

+
+ )} + + {connectionStatus && !connectionStatus.connected && ( + <> +
+
+ +
+
+

+ {connectionStatus.error || "Unknown error"} +

+
+
+

+ It seems there was an issue with your credentials. Please review + your credentials and try again. +

+ + )} + + + + + +
+ {apiErrorMessage ? ( + + + Back to providers + + ) : connectionStatus?.error ? ( + } + isDisabled={isResettingCredentials} + > + {isResettingCredentials ? ( + <>Loading + ) : ( + Reset credentials + )} + + ) : ( + } + > + {isLoading ? <>Loading : Test connection} + + )} +
+ + + ); +}; diff --git a/components/providers/workflow/forms/via-credentials-form.tsx b/components/providers/workflow/forms/via-credentials-form.tsx new file mode 100644 index 0000000000..176ed40a00 --- /dev/null +++ b/components/providers/workflow/forms/via-credentials-form.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsFormSchema, + ApiError, + AWSCredentials, + AzureCredentials, + GCPCredentials, + KubernetesCredentials, +} from "@/types"; + +import { AWScredentialsForm } from "./via-credentials/aws-credentials-form"; +import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; +import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form"; +import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; + +type CredentialsFormSchema = z.infer< + ReturnType +>; + +// Add this type intersection to include all fields +type FormType = CredentialsFormSchema & + AWSCredentials & + AzureCredentials & + GCPCredentials & + KubernetesCredentials; + +export const ViaCredentialsForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const providerType = searchParams.type; + const providerId = searchParams.id; + const formSchema = addCredentialsFormSchema(providerType); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "aws" + ? { + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + } + : providerType === "azure" + ? { + client_id: "", + client_secret: "", + tenant_id: "", + } + : providerType === "gcp" + ? { + client_id: "", + client_secret: "", + refresh_token: "", + } + : providerType === "kubernetes" + ? { + kubeconfig_content: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormType) => { + console.log("via credentials form", values); + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await addCredentialsProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/secret/aws_access_key_id": + form.setError("aws_access_key_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/aws_secret_access_key": + form.setError("aws_secret_access_key", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/aws_session_token": + form.setError("aws_session_token", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/client_id": + form.setError("client_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/client_secret": + form.setError("client_secret", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/tenant_id": + form.setError("tenant_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/kubeconfig_content": + form.setError("kubeconfig_content", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/name": + form.setError("secretName", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}`, + ); + } + }; + + return ( +
+ + + + + {providerType === "aws" && ( + } + /> + )} + {providerType === "azure" && ( + } + /> + )} + {providerType === "gcp" && ( + } + /> + )} + {providerType === "kubernetes" && ( + } + /> + )} + +
+ } + > + {isLoading ? <>Loading : Save} + +
+ + + ); +}; diff --git a/components/providers/workflow/forms/via-credentials/aws-credentials-form.tsx b/components/providers/workflow/forms/via-credentials/aws-credentials-form.tsx new file mode 100644 index 0000000000..a024d138ff --- /dev/null +++ b/components/providers/workflow/forms/via-credentials/aws-credentials-form.tsx @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { AWSCredentials } from "@/types"; + +export const AWScredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your AWS credentials. +
+
+ + + + + ); +}; diff --git a/components/providers/workflow/forms/via-credentials/azure-credentials-form.tsx b/components/providers/workflow/forms/via-credentials/azure-credentials-form.tsx new file mode 100644 index 0000000000..b78642b8d2 --- /dev/null +++ b/components/providers/workflow/forms/via-credentials/azure-credentials-form.tsx @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { AzureCredentials } from "@/types"; + +export const AzureCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your Azure credentials. +
+
+ + + + + ); +}; diff --git a/components/providers/workflow/forms/via-credentials/gcp-credentials-form.tsx b/components/providers/workflow/forms/via-credentials/gcp-credentials-form.tsx new file mode 100644 index 0000000000..784f173f49 --- /dev/null +++ b/components/providers/workflow/forms/via-credentials/gcp-credentials-form.tsx @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { GCPCredentials } from "@/types"; + +export const GCPcredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your GCP credentials. +
+
+ + + + + ); +}; diff --git a/components/providers/workflow/forms/via-credentials/index.ts b/components/providers/workflow/forms/via-credentials/index.ts new file mode 100644 index 0000000000..d3198a1d0e --- /dev/null +++ b/components/providers/workflow/forms/via-credentials/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-credentials-form"; +export * from "./azure-credentials-form"; +export * from "./gcp-credentials-form"; +export * from "./k8s-credentials-form"; diff --git a/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx b/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx new file mode 100644 index 0000000000..0725d134eb --- /dev/null +++ b/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx @@ -0,0 +1,34 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { KubernetesCredentials } from "@/types"; + +export const KubernetesCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your Kubernetes credentials. +
+
+ + + ); +}; diff --git a/components/providers/workflow/forms/via-role-form.tsx b/components/providers/workflow/forms/via-role-form.tsx new file mode 100644 index 0000000000..39ba301447 --- /dev/null +++ b/components/providers/workflow/forms/via-role-form.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsRoleFormSchema, + ApiError, + AWSCredentialsRole, +} from "@/types"; + +import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; + +export const ViaRoleForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const providerType = searchParams.type; + const providerId = searchParams.id; + + const formSchema = addCredentialsRoleFormSchema(providerType); + type FormSchemaType = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "aws" + ? { + role_arn: "", + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + session_duration: 3600, + external_id: "", + role_session_name: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormSchemaType) => { + console.log("via ROLE form", values); + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => + value !== undefined && formData.append(key, String(value)), + ); + + const data = await addCredentialsProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/secret/role_arn": + form.setError("role_arn" as keyof FormSchemaType, { + type: "server", + message: errorMessage, + }); + break; + + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}`, + ); + } + }; + + return ( +
+ + + + + {providerType === "aws" && ( + } + /> + )} + +
+ } + > + {isLoading ? <>Loading : Save} + +
+ + + ); +}; diff --git a/components/providers/workflow/forms/via-role/aws-role-form.tsx b/components/providers/workflow/forms/via-role/aws-role-form.tsx new file mode 100644 index 0000000000..eb7f5d3a43 --- /dev/null +++ b/components/providers/workflow/forms/via-role/aws-role-form.tsx @@ -0,0 +1,105 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { AWSCredentialsRole } from "@/types"; + +export const AWSCredentialsRoleForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect assuming IAM Role +
+
+ Please provide the information for your AWS credentials. +
+
+ + + Optional fields + + + + + +
+ + +
+ + ); +}; diff --git a/components/providers/workflow/forms/via-role/index.ts b/components/providers/workflow/forms/via-role/index.ts new file mode 100644 index 0000000000..763c90c0c0 --- /dev/null +++ b/components/providers/workflow/forms/via-role/index.ts @@ -0,0 +1 @@ +export * from "./aws-role-form"; diff --git a/components/providers/workflow/index.ts b/components/providers/workflow/index.ts new file mode 100644 index 0000000000..f245196eab --- /dev/null +++ b/components/providers/workflow/index.ts @@ -0,0 +1,2 @@ +export * from "./vertical-steps"; +export * from "./workflow"; diff --git a/components/providers/workflow/vertical-steps.tsx b/components/providers/workflow/vertical-steps.tsx new file mode 100644 index 0000000000..74eaa32747 --- /dev/null +++ b/components/providers/workflow/vertical-steps.tsx @@ -0,0 +1,291 @@ +"use client"; + +import type { ButtonProps } from "@nextui-org/react"; +import { cn } from "@nextui-org/react"; +import { useControlledState } from "@react-stately/utils"; +import { domAnimation, LazyMotion, m } from "framer-motion"; +import type { ComponentProps } from "react"; +import React from "react"; + +export type VerticalStepProps = { + className?: string; + description?: React.ReactNode; + title?: React.ReactNode; +}; + +export interface VerticalStepsProps + extends React.HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: ButtonProps["color"]; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * Whether to hide the progress bars. + * + * @default false + */ + hideProgressBars?: boolean; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +export const VerticalSteps = React.forwardRef< + HTMLButtonElement, + VerticalStepsProps +>( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + hideProgressBars = false, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + const colors = React.useMemo(() => { + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:var(--step-fg-color)]", + "[--active-border-color:var(--step-color)]", + "[--active-color:var(--step-color)]", + "[--complete-background-color:var(--step-color)]", + "[--complete-border-color:var(--step-color)]", + "[--inactive-border-color:hsl(var(--nextui-default-300))]", + "[--inactive-color:hsl(var(--nextui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + case "secondary": + userColor = "[--step-color:hsl(var(--nextui-secondary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-secondary-foreground))]"; + break; + case "success": + userColor = "[--step-color:hsl(var(--nextui-success))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-success-foreground))]"; + break; + case "warning": + userColor = "[--step-color:hsl(var(--nextui-warning))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-warning-foreground))]"; + break; + case "danger": + userColor = "[--step-color:hsl(var(--nextui-error))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-error-foreground))]"; + break; + case "default": + userColor = "[--step-color:hsl(var(--nextui-default))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-default-foreground))]"; + break; + default: + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + } + + if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor); + if (!className?.includes("--step-color")) colorsVars.unshift(userColor); + if (!className?.includes("--inactive-bar-color")) + colorsVars.push( + "[--inactive-bar-color:hsl(var(--nextui-default-300))]", + ); + + return colorsVars; + }, [color, className]); + + return ( + + ); + }, +); + +VerticalSteps.displayName = "VerticalSteps"; diff --git a/components/providers/workflow/workflow.tsx b/components/providers/workflow/workflow.tsx new file mode 100644 index 0000000000..da5fa18f99 --- /dev/null +++ b/components/providers/workflow/workflow.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Progress, Spacer } from "@nextui-org/react"; +import { usePathname } from "next/navigation"; +import React from "react"; + +import { VerticalSteps } from "./vertical-steps"; + +const steps = [ + { + title: "Add your cloud account", + description: + "Select the cloud provider of the account you want to connect and choose whether to use IAM role or credentials for access.", + href: "/providers/connect-account", + }, + { + title: "Add credentials to your cloud account", + description: "Add the credentials needed to connect to your cloud account.", + href: "/providers/add-credentials", + }, + { + title: "Test connection", + description: + "Test your connection to verify that the credentials provided are valid for accessing your cloud account.", + href: "/providers/test-connection", + }, + { + title: "Launch scan", + description: + "Launch the scan now or schedule it for a later date and time.", + href: "/providers/launch-scan", + }, +]; + +export const Workflow = () => { + const pathname = usePathname(); + + // Calculate current step based on pathname + const currentStepIndex = steps.findIndex((step) => + pathname.endsWith(step.href), + ); + const currentStep = currentStepIndex === -1 ? 0 : currentStepIndex; + + return ( +
+

+ Add a cloud account +

+

+ Follow the steps to configure your cloud account. This allows you to + launch the first scan when the process is complete. +

+ + + +
+ ); +}; diff --git a/components/ui/custom/custom-button.tsx b/components/ui/custom/custom-button.tsx index b2a6cf355a..eeffd07783 100644 --- a/components/ui/custom/custom-button.tsx +++ b/components/ui/custom/custom-button.tsx @@ -1,6 +1,7 @@ import { Button, CircularProgress } from "@nextui-org/react"; import type { PressEvent } from "@react-types/shared"; import clsx from "clsx"; +import Link from "next/link"; import React from "react"; import { NextUIColors, NextUIVariants } from "@/types"; @@ -50,6 +51,7 @@ interface CustomButtonProps { isLoading?: boolean; isIconOnly?: boolean; ref?: React.RefObject; + asLink?: string; } export const CustomButton = React.forwardRef< @@ -73,11 +75,14 @@ export const CustomButton = React.forwardRef< isDisabled = false, isLoading = false, isIconOnly, + asLink, ...props }, ref, ) => (
diff --git a/lib/helper.ts b/lib/helper.ts index 7eeee17a6f..aed48d9478 100644 --- a/lib/helper.ts +++ b/lib/helper.ts @@ -1,17 +1,61 @@ +import { getTask } from "@/actions/task"; import { MetaDataProps } from "@/types"; +export async function checkTaskStatus( + taskId: string, +): Promise<{ completed: boolean; error?: string }> { + const MAX_RETRIES = 20; // Define the maximum number of attempts before stopping the polling + const RETRY_DELAY = 1000; // Delay time between each poll (in milliseconds) + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const task = await getTask(taskId); + + if (task.error) { + // eslint-disable-next-line no-console + console.error(`Error retrieving task: ${task.error}`); + return { completed: false, error: task.error }; + } + + const state = task.data.attributes.state; + + switch (state) { + case "completed": + return { completed: true }; + case "failed": + return { completed: false, error: task.data.attributes.result.error }; + case "available": + case "scheduled": + case "executing": + // Continue waiting if the task is still in progress + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + break; + default: + console.warn(`Unexpected task state: ${state}`); + return { completed: false, error: "Unexpected task state" }; + } + } + + return { completed: false, error: "Max retries exceeded" }; +} + +export const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + // Helper function to create dictionaries by type -export const createDict = ( - type: string, - data: any, - includedField: string = "included", -) => - Object.fromEntries( - data[includedField] - .filter((item: { type: string }) => item.type === type) - .map((item: { id: string }) => [item.id, item]), +export function createDict(type: string, data: any) { + const includedField = data?.included?.filter( + (item: { type: string }) => item.type === type, ); + if (!includedField || includedField.length === 0) { + return {}; + } + + return Object.fromEntries( + includedField.map((item: { id: string }) => [item.id, item]), + ); +} + export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value)); export const convertFileToUrl = (file: File) => URL.createObjectURL(file); diff --git a/tailwind.config.js b/tailwind.config.js index 8efddab52b..9d06ef139a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -23,7 +23,7 @@ module.exports = { theme: { midnight: "#030921", pale: "#f3fcff", - green: "#6af400", + green: "#8ce112", purple: "#5001d0", coral: "#ff5356", orange: "#f69000", @@ -83,7 +83,7 @@ module.exports = { }, }, danger: "#E11D48", - action: "#6af400", + action: "#9FD655", }, fontFamily: { sans: ["var(--font-sans)"], @@ -177,13 +177,21 @@ module.exports = { dark: { colors: { primary: { - DEFAULT: "#6af400", + DEFAULT: "#9FD655", foreground: "#000000", }, - focus: "#6af400", + focus: "#9FD655", background: "#030921", }, }, + light: { + colors: { + primary: { + DEFAULT: "#9FD655", + foreground: "#000000", + }, + }, + }, }, }), ], diff --git a/types/components.ts b/types/components.ts index 086e6e4a60..b6c4b59da2 100644 --- a/types/components.ts +++ b/types/components.ts @@ -26,6 +26,52 @@ export type NextUIColors = | "danger" | "default"; +export type AWSCredentials = { + aws_access_key_id: string; + aws_secret_access_key: string; + aws_session_token: string; + secretName: string; + providerId: string; +}; + +export type AWSCredentialsRole = { + role_arn: string; + aws_access_key_id?: string; + aws_secret_access_key?: string; + aws_session_token?: string; + external_id?: string; + role_session_name?: string; + session_duration?: number; +}; + +export type AzureCredentials = { + client_id: string; + client_secret: string; + tenant_id: string; + secretName: string; + providerId: string; +}; + +export type GCPCredentials = { + client_id: string; + client_secret: string; + refresh_token: string; + secretName: string; + providerId: string; +}; + +export type KubernetesCredentials = { + kubeconfig_content: string; + secretName: string; + providerId: string; +}; + +export type CredentialsFormSchema = + | AWSCredentials + | AzureCredentials + | GCPCredentials + | KubernetesCredentials; + export interface SearchParamsProps { [key: string]: string | string[] | undefined; } diff --git a/types/formSchemas.ts b/types/formSchemas.ts index e1df1285b8..a90ffc8c02 100644 --- a/types/formSchemas.ts +++ b/types/formSchemas.ts @@ -31,12 +31,112 @@ export const scheduleScanFormSchema = () => scheduleDate: z.string(), }); -export const addProviderFormSchema = z.object({ - providerType: z.string(), - providerAlias: z.string(), +export const addProviderFormSchema = z + .object({ + providerType: z.enum(["aws", "azure", "gcp", "kubernetes"], { + required_error: "Please select a provider type", + }), + }) + .and( + z.discriminatedUnion("providerType", [ + z.object({ + providerType: z.literal("aws"), + providerAlias: z.string(), + providerUid: z.string(), + awsCredentialsType: z.string().min(1, { + message: "Please select the type of credentials you want to use", + }), + }), + z.object({ + providerType: z.literal("azure"), + providerAlias: z.string(), + providerUid: z.string(), + awsCredentialsType: z.string().optional(), + }), + z.object({ + providerType: z.literal("gcp"), + providerAlias: z.string(), + providerUid: z.string(), + awsCredentialsType: z.string().optional(), + }), + z.object({ + providerType: z.literal("kubernetes"), + providerAlias: z.string(), + providerUid: z.string(), + awsCredentialsType: z.string().optional(), + }), + ]), + ); + +export const addCredentialsFormSchema = (providerType: string) => + z.object({ + secretName: z.string().optional(), + providerId: z.string(), + providerType: z.string(), + ...(providerType === "aws" + ? { + aws_access_key_id: z + .string() + .nonempty("AWS Access Key ID is required"), + aws_secret_access_key: z + .string() + .nonempty("AWS Secret Access Key is required"), + aws_session_token: z.string().optional(), + } + : providerType === "azure" + ? { + client_id: z.string().nonempty("Client ID is required"), + client_secret: z.string().nonempty("Client Secret is required"), + tenant_id: z.string().nonempty("Tenant ID is required"), + } + : providerType === "gcp" + ? { + client_id: z.string().nonempty("Client ID is required"), + client_secret: z.string().nonempty("Client Secret is required"), + refresh_token: z.string().nonempty("Refresh Token is required"), + } + : providerType === "kubernetes" + ? { + kubeconfig_content: z + .string() + .nonempty("Kubeconfig Content is required"), + } + : {}), + }); + +export const addCredentialsRoleFormSchema = (providerType: string) => + providerType === "aws" + ? z.object({ + providerId: z.string(), + providerType: z.string(), + role_arn: z.string().optional(), + aws_access_key_id: z.string().optional(), + aws_secret_access_key: z.string().optional(), + aws_session_token: z.string().optional(), + session_duration: z.number().optional(), + external_id: z.string().optional(), + role_session_name: z.string().optional(), + }) + : z.object({ + providerId: z.string(), + providerType: z.string(), + }); + +export const testConnectionFormSchema = z.object({ providerId: z.string(), }); +export const launchScanFormSchema = () => + z.object({ + providerId: z.string(), + providerType: z.string(), + scannerArgs: z + .object({ + checksToExecute: z.array(z.string()).optional(), + }) + .optional(), + }); + export const editProviderFormSchema = (currentAlias: string) => z.object({ alias: z