From 7935e926acb8d8aee593201c1ce94ec4d9cc7b46 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:08:17 +0100 Subject: [PATCH] feat(ui): replace route-based provider flow with modal wizard (#10156) --- .../add-credentials/page.tsx | 78 ----- .../connect-account/page.tsx | 7 - .../providers/(set-up-provider)/layout.tsx | 32 -- .../test-connection/page.tsx | 46 --- .../update-credentials/page.tsx | 76 ----- .../providers/add-provider-button.tsx | 14 +- .../organizations/aws-method-selector.tsx | 61 ++++ .../organizations/org-account-selection.tsx | 47 +++ .../organizations/org-launch-scan.tsx | 41 +++ .../organizations/org-setup-form.tsx | 51 +++ .../providers/radio-group-provider.tsx | 37 ++- .../table/data-table-row-actions.tsx | 45 ++- .../use-provider-wizard-controller.test.tsx | 188 +++++++++++ .../hooks/use-provider-wizard-controller.ts | 244 +++++++++++++++ ui/components/providers/wizard/index.ts | 3 + .../wizard/provider-wizard-modal.tsx | 273 ++++++++++++++++ .../provider-wizard-modal.utils.test.ts | 52 ++++ .../wizard/provider-wizard-modal.utils.ts | 29 ++ .../providers/wizard/steps/connect-step.tsx | 84 +++++ .../wizard/steps/credentials-step.tsx | 263 ++++++++++++++++ .../providers/wizard/steps/footer-controls.ts | 29 ++ ui/components/providers/wizard/steps/index.ts | 5 + .../providers/wizard/steps/launch-step.tsx | 15 + .../wizard/steps/test-connection-step.tsx | 141 +++++++++ ui/components/providers/wizard/types.ts | 12 + .../providers/wizard/wizard-stepper.tsx | 160 ++++++++++ .../forms/add-via-credentials-form.tsx | 23 +- .../workflow/forms/add-via-role-form.tsx | 23 +- .../workflow/forms/base-credentials-form.tsx | 199 ++++++------ .../workflow/forms/connect-account-form.tsx | 260 ++++++++++++---- .../alibabacloud/select-via-alibabacloud.tsx | 7 + .../aws/select-via-aws.tsx | 11 +- .../cloudflare/select-via-cloudflare.tsx | 7 + .../gcp/add-via-service-account-form.tsx | 23 +- .../gcp/select-via-gcp.tsx | 11 +- .../github/select-via-github.tsx | 11 +- .../m365/select-via-m365.tsx | 11 +- .../workflow/forms/test-connection-form.tsx | 206 +++++++----- .../forms/update-via-credentials-form.tsx | 23 +- .../workflow/forms/update-via-role-form.tsx | 23 +- .../update-via-service-account-key-form.tsx | 23 +- ui/components/providers/workflow/index.ts | 2 - .../workflow/provider-title-docs.tsx | 30 +- .../providers/workflow/vertical-steps.tsx | 294 ------------------ .../workflow/workflow-add-provider.tsx | 97 ------ ui/components/scans/no-providers-added.tsx | 62 ++-- ui/hooks/use-credentials-form.ts | 53 +++- 47 files changed, 2445 insertions(+), 987 deletions(-) delete mode 100644 ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx delete mode 100644 ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx delete mode 100644 ui/app/(prowler)/providers/(set-up-provider)/layout.tsx delete mode 100644 ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx delete mode 100644 ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx create mode 100644 ui/components/providers/organizations/aws-method-selector.tsx create mode 100644 ui/components/providers/organizations/org-account-selection.tsx create mode 100644 ui/components/providers/organizations/org-launch-scan.tsx create mode 100644 ui/components/providers/organizations/org-setup-form.tsx create mode 100644 ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx create mode 100644 ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts create mode 100644 ui/components/providers/wizard/index.ts create mode 100644 ui/components/providers/wizard/provider-wizard-modal.tsx create mode 100644 ui/components/providers/wizard/provider-wizard-modal.utils.test.ts create mode 100644 ui/components/providers/wizard/provider-wizard-modal.utils.ts create mode 100644 ui/components/providers/wizard/steps/connect-step.tsx create mode 100644 ui/components/providers/wizard/steps/credentials-step.tsx create mode 100644 ui/components/providers/wizard/steps/footer-controls.ts create mode 100644 ui/components/providers/wizard/steps/index.ts create mode 100644 ui/components/providers/wizard/steps/launch-step.tsx create mode 100644 ui/components/providers/wizard/steps/test-connection-step.tsx create mode 100644 ui/components/providers/wizard/types.ts create mode 100644 ui/components/providers/wizard/wizard-stepper.tsx delete mode 100644 ui/components/providers/workflow/vertical-steps.tsx delete mode 100644 ui/components/providers/workflow/workflow-add-provider.tsx diff --git a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx deleted file mode 100644 index e413ccd67b..0000000000 --- a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { getProvider } from "@/actions/providers/providers"; -import { - AddViaCredentialsForm, - AddViaRoleForm, -} from "@/components/providers/workflow/forms"; -import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud"; -import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws"; -import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare"; -import { - AddViaServiceAccountForm, - SelectViaGCP, -} from "@/components/providers/workflow/forms/select-credentials-type/gcp"; -import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github"; -import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365"; -import { getProviderFormType } from "@/lib/provider-helpers"; -import { ProviderType } from "@/types/providers"; - -interface Props { - searchParams: Promise<{ type: ProviderType; id: string; via?: string }>; -} - -export default async function AddCredentialsPage({ searchParams }: Props) { - const resolvedSearchParams = await searchParams; - const { type: providerType, via, id: providerId } = resolvedSearchParams; - const formType = getProviderFormType(providerType, via); - - // Fetch provider data to get the UID (needed for OCI) - let providerUid: string | undefined; - if (providerId) { - const formData = new FormData(); - formData.append("id", providerId); - const providerResponse = await getProvider(formData); - if (providerResponse?.data?.attributes?.uid) { - providerUid = providerResponse.data.attributes.uid; - } - } - - switch (formType) { - case "selector": - if (providerType === "aws") return ; - if (providerType === "gcp") return ; - if (providerType === "github") - return ; - if (providerType === "m365") return ; - if (providerType === "alibabacloud") - return ; - if (providerType === "cloudflare") - return ; - return null; - - case "credentials": - return ( - - ); - - case "role": - return ( - - ); - - case "service-account": - return ( - - ); - - default: - return null; - } -} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx deleted file mode 100644 index eead6c2561..0000000000 --- a/ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -import { ConnectAccountForm } from "@/components/providers/workflow/forms"; - -export default function ConnectAccountPage() { - return ; -} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/layout.tsx b/ui/app/(prowler)/providers/(set-up-provider)/layout.tsx deleted file mode 100644 index afaf3ed092..0000000000 --- a/ui/app/(prowler)/providers/(set-up-provider)/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import "@/styles/globals.css"; - -import { Spacer } from "@heroui/spacer"; -import React from "react"; - -import { WorkflowAddProvider } 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/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx deleted file mode 100644 index 161025cf02..0000000000 --- a/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { redirect } from "next/navigation"; -import React, { Suspense } from "react"; - -import { getProvider } from "@/actions/providers"; -import { SkeletonProviderWorkflow } from "@/components/providers/workflow"; -import { TestConnectionForm } from "@/components/providers/workflow/forms"; - -interface Props { - searchParams: Promise<{ type: string; id: string; updated: string }>; -} - -export default async function TestConnectionPage({ searchParams }: Props) { - const resolvedSearchParams = await searchParams; - const providerId = resolvedSearchParams.id; - - if (!providerId) { - redirect("/providers/connect-account"); - } - - return ( - }> - - - ); -} - -async function SSRTestConnection({ - searchParams, -}: { - searchParams: { type: string; id: string; updated: 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/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx deleted file mode 100644 index cd6af05043..0000000000 --- a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { redirect } from "next/navigation"; -import React from "react"; - -import { getProvider } from "@/actions/providers/providers"; -import { CredentialsUpdateInfo } from "@/components/providers"; -import { - UpdateViaCredentialsForm, - UpdateViaRoleForm, -} from "@/components/providers/workflow/forms"; -import { UpdateViaServiceAccountForm } from "@/components/providers/workflow/forms/update-via-service-account-key-form"; -import { getProviderFormType } from "@/lib/provider-helpers"; -import { ProviderType } from "@/types/providers"; - -interface Props { - searchParams: Promise<{ - type: ProviderType; - id: string; - via?: string; - secretId?: string; - }>; -} - -export default async function UpdateCredentialsPage({ searchParams }: Props) { - const resolvedSearchParams = await searchParams; - const { type: providerType, via, id: providerId } = resolvedSearchParams; - - if (!providerId) { - redirect("/providers"); - } - - const formType = getProviderFormType(providerType, via); - - const formData = new FormData(); - formData.append("id", providerId); - const providerResponse = await getProvider(formData); - - if (providerResponse?.errors) { - redirect("/providers"); - } - - const providerUid = providerResponse?.data?.attributes?.uid; - - switch (formType) { - case "selector": - return ( - - ); - - case "credentials": - return ( - - ); - - case "role": - return ( - - ); - - case "service-account": - return ( - - ); - - default: - return null; - } -} diff --git a/ui/components/providers/add-provider-button.tsx b/ui/components/providers/add-provider-button.tsx index 7846616357..223d3a390d 100644 --- a/ui/components/providers/add-provider-button.tsx +++ b/ui/components/providers/add-provider-button.tsx @@ -1,18 +1,22 @@ "use client"; -import Link from "next/link"; +import { useState } from "react"; +import { ProviderWizardModal } from "@/components/providers/wizard"; import { Button } from "@/components/shadcn"; import { AddIcon } from "../icons"; export const AddProviderButton = () => { + const [open, setOpen] = useState(false); + return ( - + + + ); }; diff --git a/ui/components/providers/organizations/aws-method-selector.tsx b/ui/components/providers/organizations/aws-method-selector.tsx new file mode 100644 index 0000000000..2e7d40566f --- /dev/null +++ b/ui/components/providers/organizations/aws-method-selector.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Ban, Box, Boxes } from "lucide-react"; + +import { RadioCard } from "@/components/providers/radio-card"; + +interface AwsMethodSelectorProps { + onSelectSingle: () => void; + onSelectOrganizations: () => void; +} + +export function AwsMethodSelector({ + onSelectSingle, + onSelectOrganizations, +}: AwsMethodSelectorProps) { + const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + + return ( +
+

+ Select a method to add your accounts to Prowler. +

+ + + + + {!isCloudEnv && } + +
+ ); +} + +function CtaBadge() { + return ( + +
+ + Available in Prowler Cloud + +
+
+ ); +} diff --git a/ui/components/providers/organizations/org-account-selection.tsx b/ui/components/providers/organizations/org-account-selection.tsx new file mode 100644 index 0000000000..e1d83a60f2 --- /dev/null +++ b/ui/components/providers/organizations/org-account-selection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "@/components/providers/wizard/steps/footer-controls"; + +interface OrgAccountSelectionProps { + onBack: () => void; + onNext: () => void; + onSkip: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +export function OrgAccountSelection({ + onBack, + onNext, + onSkip, + onFooterChange, +}: OrgAccountSelectionProps) { + useEffect(() => { + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showSecondaryAction: true, + secondaryActionLabel: "Skip", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onSecondaryAction: onSkip, + showAction: true, + actionLabel: "Continue", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: onNext, + }); + }, [onBack, onFooterChange, onNext, onSkip]); + + return ( +
+

Account selection

+

+ Account discovery and selection are introduced in the next chained PR. +

+
+ ); +} diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx new file mode 100644 index 0000000000..5503fefff0 --- /dev/null +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect } from "react"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "@/components/providers/wizard/steps/footer-controls"; + +interface OrgLaunchScanProps { + onClose: () => void; + onBack: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +export function OrgLaunchScan({ + onClose, + onBack, + onFooterChange, +}: OrgLaunchScanProps) { + useEffect(() => { + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: true, + actionLabel: "Close", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: onClose, + }); + }, [onBack, onClose, onFooterChange]); + + return ( +
+

Launch scan

+

+ Organizations scan launch flow is completed in the next chained PR. +

+
+ ); +} diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx new file mode 100644 index 0000000000..5af1e0f390 --- /dev/null +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect } from "react"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "@/components/providers/wizard/steps/footer-controls"; +import { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations"; + +interface OrgSetupFormProps { + onBack: () => void; + onNext: () => void; + onFooterChange: (config: WizardFooterConfig) => void; + onPhaseChange: (phase: OrgSetupPhase) => void; + initialPhase?: OrgSetupPhase; +} + +export function OrgSetupForm({ + onBack, + onNext, + onFooterChange, + onPhaseChange, + initialPhase = ORG_SETUP_PHASE.DETAILS, +}: OrgSetupFormProps) { + useEffect(() => { + onPhaseChange(initialPhase); + }, [initialPhase, onPhaseChange]); + + useEffect(() => { + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: true, + actionLabel: "Continue", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: onNext, + }); + }, [onBack, onFooterChange, onNext]); + + return ( +
+

AWS Organizations

+

+ The full AWS Organizations setup step is included in the next chained + PR. +

+
+ ); +} diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index 7f7a4af2d5..b4bf22af4b 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -114,7 +114,7 @@ export const RadioGroupProvider: FC = ({ name="providerType" control={control} render={({ field }) => ( -
+
= ({ />
-
+
{filteredProviders.length > 0 ? ( filteredProviders.map((provider) => { @@ -152,19 +144,26 @@ export const RadioGroupProvider: FC = ({ aria-selected={isSelected} onClick={() => field.onChange(provider.value)} className={cn( - "flex w-full cursor-pointer items-center gap-3 rounded-lg border p-4 text-left transition-all", - "hover:border-button-primary", - "focus-visible:border-button-primary focus-visible:ring-button-primary focus:outline-none focus-visible:ring-1", + "flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors", + "focus-visible:border-primary focus-visible:outline-none", isSelected - ? "border-button-primary bg-bg-neutral-tertiary" - : "border-border-neutral-secondary bg-bg-neutral-secondary", + ? "border-primary bg-bg-neutral-tertiary" + : "border-border-neutral-primary bg-bg-neutral-tertiary hover:border-primary", isInvalid && "border-bg-fail", )} > - - - {provider.label} - +
+ {isSelected && ( +
+ )} +
+ +
+ + + {provider.label} + +
); }) diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 023f563006..adbb5f0e9d 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -2,11 +2,11 @@ import { Row } from "@tanstack/react-table"; import { Pencil, PlugZap, Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import { checkConnectionProvider } from "@/actions/providers/providers"; import { VerticalDotsIcon } from "@/components/icons"; +import { ProviderWizardModal } from "@/components/providers/wizard"; import { Button } from "@/components/shadcn"; import { ActionDropdown, @@ -14,26 +14,27 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; +import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; +import { ProviderProps } from "@/types/providers"; import { EditForm } from "../forms"; import { DeleteForm } from "../forms/delete-form"; -interface DataTableRowActionsProps { +interface DataTableRowActionsProps { row: Row; } -export function DataTableRowActions({ - row, -}: DataTableRowActionsProps) { - const router = useRouter(); +export function DataTableRowActions({ row }: DataTableRowActionsProps) { const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isWizardOpen, setIsWizardOpen] = useState(false); const [loading, setLoading] = 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; - const providerSecretId = - (row.original as any).relationships?.secret?.data?.id || null; + const provider = row.original; + const providerId = provider.id; + const providerType = provider.attributes.provider; + const providerUid = provider.attributes.uid; + const providerAlias = provider.attributes.alias ?? null; + const providerSecretId = provider.relationships.secret.data?.id ?? null; const handleTestConnection = async () => { setLoading(true); @@ -43,7 +44,7 @@ export function DataTableRowActions({ setLoading(false); }; - const hasSecret = (row.original as any).relationships?.secret?.data; + const hasSecret = Boolean(provider.relationships.secret.data); return ( <> @@ -66,6 +67,20 @@ export function DataTableRowActions({ > +
({ } label={hasSecret ? "Update Credentials" : "Add Credentials"} - onSelect={() => - router.push( - `/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`, - ) - } + onSelect={() => setIsWizardOpen(true)} /> } diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx new file mode 100644 index 0000000000..6830d65a4c --- /dev/null +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -0,0 +1,188 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useOrgSetupStore } from "@/store/organizations/store"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { ORG_WIZARD_STEP } from "@/types/organizations"; +import { + PROVIDER_WIZARD_MODE, + PROVIDER_WIZARD_STEP, +} from "@/types/provider-wizard"; + +import { useProviderWizardController } from "./use-provider-wizard-controller"; + +const { pushMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ + data: null, + status: "unauthenticated", + }), +})); + +describe("useProviderWizardController", () => { + beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); + pushMock.mockReset(); + useProviderWizardStore.getState().reset(); + useOrgSetupStore.getState().reset(); + }); + + it("hydrates update mode when initial data is provided", async () => { + // Given + const onOpenChange = vi.fn(); + + // When + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + initialData: { + providerId: "provider-1", + providerType: "aws", + providerUid: "111111111111", + providerAlias: "production", + secretId: "secret-1", + mode: PROVIDER_WIZARD_MODE.UPDATE, + }, + }), + ); + + // Then + await waitFor(() => { + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + }); + expect(result.current.modalTitle).toBe("Update Provider Credentials"); + expect(result.current.isProviderFlow).toBe(true); + expect(result.current.docsLink).toBe( + "https://goto.prowler.com/provider-aws", + ); + + const state = useProviderWizardStore.getState(); + expect(state.providerId).toBe("provider-1"); + expect(state.providerType).toBe("aws"); + expect(state.providerUid).toBe("111111111111"); + expect(state.providerAlias).toBe("production"); + expect(state.secretId).toBe("secret-1"); + expect(state.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE); + }); + + it("switches into and out of organizations flow", () => { + // Given + const onOpenChange = vi.fn(); + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + }), + ); + + // When + act(() => { + result.current.openOrganizationsFlow(); + }); + + // Then + expect(result.current.wizardVariant).toBe("organizations"); + expect(result.current.isProviderFlow).toBe(false); + expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.SETUP); + + // When + act(() => { + result.current.backToProviderFlow(); + }); + + // Then + expect(result.current.wizardVariant).toBe("provider"); + expect(result.current.isProviderFlow).toBe(true); + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); + }); + + it("moves to launch step after a successful connection test in add mode", () => { + // Given + const onOpenChange = vi.fn(); + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + }), + ); + + // When + act(() => { + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + result.current.handleTestSuccess(); + }); + + // Then + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("closes and navigates when launch footer action is triggered", () => { + // Given + const onOpenChange = vi.fn(); + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + }), + ); + + // When + act(() => { + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); + }); + + const { resolvedFooterConfig } = result.current; + act(() => { + resolvedFooterConfig.onAction?.(); + }); + + // Then + expect(pushMock).toHaveBeenCalledWith("/scans"); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); + }); + + it("does not reset organizations step when org store updates while modal is open", () => { + // Given + const onOpenChange = vi.fn(); + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + }), + ); + + act(() => { + result.current.openOrganizationsFlow(); + result.current.setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + }); + + // When + act(() => { + useOrgSetupStore + .getState() + .setOrganization("org-1", "My Org", "o-abc123def4"); + useOrgSetupStore.getState().setDiscovery("disc-1", { + roots: [], + organizational_units: [], + accounts: [], + }); + }); + + // Then + expect(result.current.wizardVariant).toBe("organizations"); + expect(result.current.orgCurrentStep).toBe(ORG_WIZARD_STEP.VALIDATE); + }); +}); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts new file mode 100644 index 0000000000..f6675bd4f0 --- /dev/null +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -0,0 +1,244 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { getProviderHelpText } from "@/lib/external-urls"; +import { useOrgSetupStore } from "@/store/organizations/store"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { + ORG_SETUP_PHASE, + ORG_WIZARD_STEP, + OrgSetupPhase, + OrgWizardStep, +} from "@/types/organizations"; +import { + PROVIDER_WIZARD_MODE, + PROVIDER_WIZARD_STEP, + ProviderWizardStep, +} from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +import { getProviderWizardModalTitle } from "../provider-wizard-modal.utils"; +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "../steps/footer-controls"; +import type { ProviderWizardInitialData } from "../types"; + +const WIZARD_VARIANT = { + PROVIDER: "provider", + ORGANIZATIONS: "organizations", +} as const; + +type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT]; + +const EMPTY_FOOTER_CONFIG: WizardFooterConfig = { + showBack: false, + backLabel: "Back", + showSecondaryAction: false, + secondaryActionLabel: "", + secondaryActionVariant: "outline", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + showAction: false, + actionLabel: "Next", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, +}; + +interface UseProviderWizardControllerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData?: ProviderWizardInitialData; +} + +export function useProviderWizardController({ + open, + onOpenChange, + initialData, +}: UseProviderWizardControllerProps) { + const initialProviderId = initialData?.providerId ?? null; + const initialProviderType = initialData?.providerType ?? null; + const initialProviderUid = initialData?.providerUid ?? null; + const initialProviderAlias = initialData?.providerAlias ?? null; + const initialSecretId = initialData?.secretId ?? null; + const initialVia = initialData?.via ?? null; + const initialMode = initialData?.mode ?? null; + const router = useRouter(); + const [wizardVariant, setWizardVariant] = useState( + WIZARD_VARIANT.PROVIDER, + ); + const [currentStep, setCurrentStep] = useState( + PROVIDER_WIZARD_STEP.CONNECT, + ); + const [orgCurrentStep, setOrgCurrentStep] = useState( + ORG_WIZARD_STEP.SETUP, + ); + const [footerConfig, setFooterConfig] = + useState(EMPTY_FOOTER_CONFIG); + const [providerTypeHint, setProviderTypeHint] = useState( + null, + ); + const [orgSetupPhase, setOrgSetupPhase] = useState( + ORG_SETUP_PHASE.DETAILS, + ); + + const { + reset: resetProviderWizard, + setProvider, + setVia, + setSecretId, + setMode, + mode, + providerType, + } = useProviderWizardStore(); + const { reset: resetOrgWizard } = useOrgSetupStore(); + + useEffect(() => { + if (!open) { + return; + } + + if (initialProviderId && initialProviderType && initialProviderUid) { + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setProvider({ + id: initialProviderId, + type: initialProviderType, + uid: initialProviderUid, + alias: initialProviderAlias, + }); + setVia(initialVia); + setSecretId(initialSecretId); + setMode( + initialMode || + (initialSecretId + ? PROVIDER_WIZARD_MODE.UPDATE + : PROVIDER_WIZARD_MODE.ADD), + ); + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(initialProviderType); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + return; + } + + resetProviderWizard(); + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + }, [ + initialMode, + initialProviderAlias, + initialProviderId, + initialProviderType, + initialProviderUid, + initialSecretId, + initialVia, + open, + resetOrgWizard, + resetProviderWizard, + setMode, + setProvider, + setSecretId, + setVia, + ]); + + const handleClose = () => { + resetProviderWizard(); + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + onOpenChange(false); + }; + + const handleDialogOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + onOpenChange(true); + return; + } + handleClose(); + }; + + const handleTestSuccess = () => { + if (mode === PROVIDER_WIZARD_MODE.UPDATE) { + handleClose(); + return; + } + + setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); + }; + + const openOrganizationsFlow = () => { + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + }; + + const backToProviderFlow = () => { + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + }; + + const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER; + const docsLink = getProviderHelpText( + isProviderFlow ? (providerTypeHint ?? providerType ?? "") : "aws", + ).link; + const resolvedFooterConfig: WizardFooterConfig = + isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH + ? { + showBack: true, + backLabel: "Back", + onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST), + showSecondaryAction: false, + secondaryActionLabel: "", + secondaryActionVariant: "outline", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + showAction: true, + actionLabel: "Go to scans", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: () => { + handleClose(); + router.push("/scans"); + }, + } + : footerConfig; + const modalTitle = getProviderWizardModalTitle(mode); + + return { + currentStep, + docsLink, + footerConfig, + handleClose, + handleDialogOpenChange, + handleTestSuccess, + isProviderFlow, + modalTitle, + openOrganizationsFlow, + orgCurrentStep, + orgSetupPhase, + providerTypeHint, + resolvedFooterConfig, + setCurrentStep, + setFooterConfig, + setOrgCurrentStep, + setOrgSetupPhase, + setProviderTypeHint, + backToProviderFlow, + wizardVariant, + }; +} diff --git a/ui/components/providers/wizard/index.ts b/ui/components/providers/wizard/index.ts new file mode 100644 index 0000000000..0a232803b9 --- /dev/null +++ b/ui/components/providers/wizard/index.ts @@ -0,0 +1,3 @@ +export * from "./provider-wizard-modal"; +export * from "./steps"; +export * from "./wizard-stepper"; diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx new file mode 100644 index 0000000000..71cc88b8d2 --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { ExternalLink, Info } from "lucide-react"; + +import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection"; +import { OrgLaunchScan } from "@/components/providers/organizations/org-launch-scan"; +import { OrgSetupForm } from "@/components/providers/organizations/org-setup-form"; +import { Button } from "@/components/shadcn/button/button"; +import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog"; +import { Modal } from "@/components/shadcn/modal"; +import { useScrollHint } from "@/hooks/use-scroll-hint"; +import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; +import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard"; + +import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; +import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils"; +import { ConnectStep } from "./steps/connect-step"; +import { CredentialsStep } from "./steps/credentials-step"; +import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls"; +import { LaunchStep } from "./steps/launch-step"; +import { TestConnectionStep } from "./steps/test-connection-step"; +import type { ProviderWizardInitialData } from "./types"; +import { WizardStepper } from "./wizard-stepper"; + +interface ProviderWizardModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData?: ProviderWizardInitialData; +} + +export function ProviderWizardModal({ + open, + onOpenChange, + initialData, +}: ProviderWizardModalProps) { + const { + backToProviderFlow, + currentStep, + docsLink, + handleClose, + handleDialogOpenChange, + handleTestSuccess, + isProviderFlow, + modalTitle, + openOrganizationsFlow, + orgCurrentStep, + orgSetupPhase, + resolvedFooterConfig, + setCurrentStep, + setFooterConfig, + setOrgCurrentStep, + setOrgSetupPhase, + setProviderTypeHint, + wizardVariant, + } = useProviderWizardController({ + open, + onOpenChange, + initialData, + }); + const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`; + const { containerRef, showScrollHint, handleScroll } = useScrollHint({ + enabled: open, + refreshToken: scrollHintRefreshToken, + }); + + return ( + + + + {modalTitle} + +
+ + For assistance connecting a Cloud Provider visit + +
+
+ +
+
+ {isProviderFlow ? ( + + ) : ( + + )} +
+
+ +
+
+ {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)} + onSelectOrganizations={openOrganizationsFlow} + onFooterChange={setFooterConfig} + onProviderTypeChange={setProviderTypeHint} + /> + )} + + {isProviderFlow && + currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && ( + setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} + onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)} + onFooterChange={setFooterConfig} + /> + )} + + {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && ( + + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS) + } + onFooterChange={setFooterConfig} + /> + )} + + {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( + + )} + + {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + }} + onFooterChange={setFooterConfig} + onPhaseChange={setOrgSetupPhase} + initialPhase={orgSetupPhase} + /> + )} + + {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS); + }} + onNext={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onSkip={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onFooterChange={setFooterConfig} + /> + )} + + {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + }} + onFooterChange={setFooterConfig} + /> + )} +
+ + {showScrollHint && ( +
+
+
+ + Scroll to see more + +
+
+ )} +
+
+ + {(resolvedFooterConfig.showBack || + resolvedFooterConfig.showSecondaryAction || + resolvedFooterConfig.showAction) && ( +
+
+
+ {resolvedFooterConfig.showBack && ( + + )} +
+
+ {resolvedFooterConfig.showSecondaryAction && ( + + )} + + {resolvedFooterConfig.showAction && ( + + )} +
+
+
+ )} + + ); +} diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts new file mode 100644 index 0000000000..8875c5ea2a --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; +import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; + +import { + getOrganizationsStepperOffset, + getProviderWizardModalTitle, +} from "./provider-wizard-modal.utils"; + +describe("getOrganizationsStepperOffset", () => { + it("keeps step 1 active during organization details", () => { + const offset = getOrganizationsStepperOffset( + ORG_WIZARD_STEP.SETUP, + ORG_SETUP_PHASE.DETAILS, + ); + + expect(offset).toBe(0); + }); + + it("moves to step 2 during credentials phase", () => { + const offset = getOrganizationsStepperOffset( + ORG_WIZARD_STEP.SETUP, + ORG_SETUP_PHASE.ACCESS, + ); + + expect(offset).toBe(1); + }); + + it("uses step 2+ offset for later wizard steps", () => { + const offset = getOrganizationsStepperOffset( + ORG_WIZARD_STEP.VALIDATE, + ORG_SETUP_PHASE.DETAILS, + ); + + expect(offset).toBe(1); + }); +}); + +describe("getProviderWizardModalTitle", () => { + it("returns add title for add mode", () => { + const title = getProviderWizardModalTitle(PROVIDER_WIZARD_MODE.ADD); + + expect(title).toBe("Adding A Cloud Provider"); + }); + + it("returns update title for update mode", () => { + const title = getProviderWizardModalTitle(PROVIDER_WIZARD_MODE.UPDATE); + + expect(title).toBe("Update Provider Credentials"); + }); +}); diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.ts new file mode 100644 index 0000000000..de62adb50d --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.ts @@ -0,0 +1,29 @@ +import { + ORG_SETUP_PHASE, + ORG_WIZARD_STEP, + OrgSetupPhase, + OrgWizardStep, +} from "@/types/organizations"; +import { + PROVIDER_WIZARD_MODE, + ProviderWizardMode, +} from "@/types/provider-wizard"; + +export function getOrganizationsStepperOffset( + currentStep: OrgWizardStep, + setupPhase: OrgSetupPhase, +) { + if (currentStep === ORG_WIZARD_STEP.SETUP) { + return setupPhase === ORG_SETUP_PHASE.ACCESS ? 1 : 0; + } + + return 1; +} + +export function getProviderWizardModalTitle(mode: ProviderWizardMode) { + if (mode === PROVIDER_WIZARD_MODE.UPDATE) { + return "Update Provider Credentials"; + } + + return "Adding A Cloud Provider"; +} diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx new file mode 100644 index 0000000000..ab040c1e43 --- /dev/null +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { + ConnectAccountForm, + ConnectAccountSuccessData, +} from "@/components/providers/workflow/forms"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "./footer-controls"; + +interface ConnectStepProps { + onNext: () => void; + onSelectOrganizations: () => void; + onFooterChange: (config: WizardFooterConfig) => void; + onProviderTypeChange: (providerType: ProviderType | null) => void; +} + +export function ConnectStep({ + onNext, + onSelectOrganizations, + onFooterChange, + onProviderTypeChange, +}: ConnectStepProps) { + const { setProvider, setVia, setSecretId, setMode } = + useProviderWizardStore(); + const backHandlerRef = useRef<(() => void) | null>(null); + const [uiState, setUiState] = useState({ + showBack: false, + showAction: false, + actionLabel: "Next", + actionDisabled: true, + isLoading: false, + }); + + const formId = "provider-wizard-connect-form"; + + const handleSuccess = (data: ConnectAccountSuccessData) => { + setProvider({ + id: data.id, + type: data.providerType, + uid: data.uid, + alias: data.alias, + }); + setVia(null); + setSecretId(null); + setMode(PROVIDER_WIZARD_MODE.ADD); + onNext(); + }; + + useEffect(() => { + onFooterChange({ + showBack: uiState.showBack, + backLabel: "Back", + backDisabled: uiState.isLoading, + onBack: () => backHandlerRef.current?.(), + showAction: uiState.showAction, + actionLabel: uiState.actionLabel, + actionDisabled: uiState.actionDisabled || uiState.isLoading, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + }, [onFooterChange, uiState]); + + return ( + { + backHandlerRef.current = handler; + }} + /> + ); +} diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx new file mode 100644 index 0000000000..0cd60c56d5 --- /dev/null +++ b/ui/components/providers/wizard/steps/credentials-step.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getProviderFormType } from "@/lib/provider-helpers"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +import { + AddViaCredentialsForm, + AddViaRoleForm, + UpdateViaCredentialsForm, + UpdateViaRoleForm, +} from "../../workflow/forms"; +import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud"; +import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws"; +import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare"; +import { + AddViaServiceAccountForm, + SelectViaGCP, +} from "../../workflow/forms/select-credentials-type/gcp"; +import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/github"; +import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365"; +import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form"; +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "./footer-controls"; + +interface CredentialsStepProps { + onNext: () => void; + onBack: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +export function CredentialsStep({ + onNext, + onBack, + onFooterChange, +}: CredentialsStepProps) { + const { providerId, providerType, providerUid, via, secretId, mode, setVia } = + useProviderWizardStore(); + const [isFormLoading, setIsFormLoading] = useState(false); + const [isFormValid, setIsFormValid] = useState(false); + + const formId = "provider-wizard-credentials-form"; + const hasProviderContext = Boolean(providerType && providerId); + const formType = + providerType && providerId + ? getProviderFormType(providerType, via || undefined) + : null; + const shouldUseUpdateForms = + mode === PROVIDER_WIZARD_MODE.UPDATE && Boolean(secretId); + + const handleBack = () => { + if (via) { + setVia(null); + return; + } + onBack(); + }; + + const handleViaChange = (value: string) => { + setVia(value); + }; + + useEffect(() => { + setIsFormValid(false); + }, [formType, via]); + + useEffect(() => { + if (!hasProviderContext) { + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: false, + actionLabel: "Authenticate", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + }); + return; + } + + const isSelector = formType === "selector"; + + onFooterChange({ + showBack: true, + backLabel: "Back", + backDisabled: isFormLoading, + onBack: () => { + if (via) { + setVia(null); + return; + } + onBack(); + }, + showAction: !isSelector, + actionLabel: "Authenticate", + actionDisabled: isFormLoading || !isFormValid, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + }, [ + hasProviderContext, + formType, + formId, + isFormLoading, + isFormValid, + onBack, + onFooterChange, + setVia, + via, + ]); + + if (!providerType || !providerId) { + return ( +
+

+ Provider details are missing. Go back and select a provider. +

+
+ ); + } + + if (formType === "selector") { + if (providerType === "aws") { + return ( + + ); + } + if (providerType === "gcp") { + return ( + + ); + } + if (providerType === "github") { + return ( + + ); + } + if (providerType === "m365") { + return ( + + ); + } + if (providerType === "alibabacloud") { + return ( + + ); + } + if (providerType === "cloudflare") { + return ( + + ); + } + return null; + } + + const commonFormProps = { + via, + onSuccess: onNext, + onBack: handleBack, + providerUid: providerUid || undefined, + formId, + hideActions: true, + onLoadingChange: setIsFormLoading, + onValidityChange: setIsFormValid, + validationMode: "onChange" as const, + }; + + if (formType === "credentials") { + if (shouldUseUpdateForms) { + return ( + + ); + } + + return ( + + ); + } + + if (formType === "role") { + if (shouldUseUpdateForms) { + return ( + + ); + } + + return ( + + ); + } + + if (formType === "service-account") { + if (shouldUseUpdateForms) { + return ( + + ); + } + + return ( + + ); + } + + return ( +
+

+ Select a credential type to continue. +

+
+ ); +} diff --git a/ui/components/providers/wizard/steps/footer-controls.ts b/ui/components/providers/wizard/steps/footer-controls.ts new file mode 100644 index 0000000000..ff41ae8061 --- /dev/null +++ b/ui/components/providers/wizard/steps/footer-controls.ts @@ -0,0 +1,29 @@ +export const WIZARD_FOOTER_ACTION_TYPE = { + BUTTON: "button", + SUBMIT: "submit", +} as const; + +export type WizardFooterActionType = + (typeof WIZARD_FOOTER_ACTION_TYPE)[keyof typeof WIZARD_FOOTER_ACTION_TYPE]; + +export type WizardFooterSecondaryActionVariant = "outline" | "link"; + +export interface WizardFooterConfig { + showBack: boolean; + backLabel: string; + backDisabled?: boolean; + onBack?: () => void; + showSecondaryAction?: boolean; + secondaryActionLabel?: string; + secondaryActionDisabled?: boolean; + secondaryActionVariant?: WizardFooterSecondaryActionVariant; + secondaryActionType?: WizardFooterActionType; + secondaryActionFormId?: string; + onSecondaryAction?: () => void; + showAction: boolean; + actionLabel: string; + actionDisabled?: boolean; + actionType: WizardFooterActionType; + actionFormId?: string; + onAction?: () => void; +} diff --git a/ui/components/providers/wizard/steps/index.ts b/ui/components/providers/wizard/steps/index.ts new file mode 100644 index 0000000000..f2028f9dfa --- /dev/null +++ b/ui/components/providers/wizard/steps/index.ts @@ -0,0 +1,5 @@ +export * from "./connect-step"; +export * from "./credentials-step"; +export * from "./footer-controls"; +export * from "./launch-step"; +export * from "./test-connection-step"; diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx new file mode 100644 index 0000000000..73f5bed03f --- /dev/null +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { CheckCircle2 } from "lucide-react"; + +export function LaunchStep() { + return ( +
+ +

Provider connected successfully

+

+ Continue with the action button to go to scans. +

+
+ ); +} diff --git a/ui/components/providers/wizard/steps/test-connection-step.tsx b/ui/components/providers/wizard/steps/test-connection-step.tsx new file mode 100644 index 0000000000..0c1ffea896 --- /dev/null +++ b/ui/components/providers/wizard/steps/test-connection-step.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { getProvider } from "@/actions/providers"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; + +import { + TestConnectionForm, + TestConnectionProviderData, +} from "../../workflow/forms/test-connection-form"; +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "./footer-controls"; + +interface TestConnectionStepProps { + onSuccess: () => void; + onResetCredentials: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +export function TestConnectionStep({ + onSuccess, + onResetCredentials, + onFooterChange, +}: TestConnectionStepProps) { + const { providerId, providerType, mode } = useProviderWizardStore(); + const [providerData, setProviderData] = + useState(null); + const [isLoadingProvider, setIsLoadingProvider] = useState(true); + const [isFormLoading, setIsFormLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const formId = "provider-wizard-test-connection-form"; + + useEffect(() => { + let isMounted = true; + + async function loadProvider() { + if (!providerId || !providerType) { + setErrorMessage("Provider information is missing."); + setIsLoadingProvider(false); + return; + } + + setIsLoadingProvider(true); + setErrorMessage(null); + + const formData = new FormData(); + formData.append("id", providerId); + + const response = await getProvider(formData); + + if (!isMounted) { + return; + } + + if (response?.errors?.length) { + setErrorMessage( + response.errors[0]?.detail || "Failed to load provider.", + ); + setProviderData(null); + setIsLoadingProvider(false); + return; + } + + setProviderData(response as TestConnectionProviderData); + setIsLoadingProvider(false); + } + + loadProvider(); + + return () => { + isMounted = false; + }; + }, [providerId, providerType]); + + useEffect(() => { + const canSubmit = !isLoadingProvider && !errorMessage && !!providerData; + + onFooterChange({ + showBack: true, + backLabel: "Back", + backDisabled: isFormLoading, + onBack: onResetCredentials, + showAction: canSubmit, + actionLabel: + mode === PROVIDER_WIZARD_MODE.UPDATE + ? "Check connection" + : "Launch scan", + actionDisabled: isFormLoading, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + }, [ + errorMessage, + isFormLoading, + isLoadingProvider, + mode, + onFooterChange, + onResetCredentials, + providerData, + ]); + + if (isLoadingProvider) { + return ( +
+ +
+ ); + } + + if (errorMessage || !providerData || !providerId || !providerType) { + return ( +
+

+ {errorMessage || "Unable to load provider details."} +

+
+ ); + } + + return ( + + ); +} diff --git a/ui/components/providers/wizard/types.ts b/ui/components/providers/wizard/types.ts new file mode 100644 index 0000000000..ab48b7fc70 --- /dev/null +++ b/ui/components/providers/wizard/types.ts @@ -0,0 +1,12 @@ +import { ProviderWizardMode } from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +export interface ProviderWizardInitialData { + providerId: string; + providerType: ProviderType; + providerUid: string; + providerAlias: string | null; + secretId?: string | null; + via?: string | null; + mode?: ProviderWizardMode; +} diff --git a/ui/components/providers/wizard/wizard-stepper.tsx b/ui/components/providers/wizard/wizard-stepper.tsx new file mode 100644 index 0000000000..44039becec --- /dev/null +++ b/ui/components/providers/wizard/wizard-stepper.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react"; +import { ReactElement } from "react"; + +import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons"; +import { cn } from "@/lib/utils"; +import { IconComponent, IconSvgProps } from "@/types/components"; + +interface WizardStepperProps { + currentStep: number; + stepOffset?: number; +} + +interface StepConfig { + label: string; + description: string; + icon: IconComponent; +} + +const STEPS: StepConfig[] = [ + { + label: "Link a Cloud Provider", + description: "Enter the provider details you would like to add in Prowler.", + icon: FolderGit2, + }, + { + label: "Authenticate Credentials", + description: + "Authorize a secure connection between Prowler and your provider.", + icon: KeyRound, + }, + { + label: "Validate Connection", + description: + "Review provider resources and test the connection to Prowler.", + icon: Rocket, + }, + { + label: "Launch Scan", + description: "Scan newly connected resources.", + icon: ProwlerShort, + }, +]; + +export function WizardStepper({ + currentStep, + stepOffset = 0, +}: WizardStepperProps) { + const activeVisualStep = Math.max( + 0, + Math.min(currentStep + stepOffset, STEPS.length - 1), + ); + + return ( + + ); +} + +interface StepCircleProps { + isComplete: boolean; + isActive: boolean; + icon: IconComponent; +} + +function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) { + if (isComplete) { + return ( +
+ +
+ ); + } + + if (isActive) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} + +function StepConnector({ isComplete }: { isComplete: boolean }) { + if (isComplete) { + return
; + } + + return ( +
+ ); +} + +function StepIcon({ + icon: Icon, + className, +}: { + icon: IconComponent; + className: string; +}) { + if (isCustomSvgIcon(Icon)) { + return ; + } + return ; +} + +function isCustomSvgIcon( + icon: IconComponent, +): icon is (props: IconSvgProps) => ReactElement { + return !("displayName" in icon && typeof icon.displayName === "string"); +} diff --git a/ui/components/providers/workflow/forms/add-via-credentials-form.tsx b/ui/components/providers/workflow/forms/add-via-credentials-form.tsx index 93fba9a548..7fdbf4befc 100644 --- a/ui/components/providers/workflow/forms/add-via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/add-via-credentials-form.tsx @@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form"; export const AddViaCredentialsForm = ({ searchParams, providerUid, + via, + onSuccess, + onBack, + formId, + hideActions, + onLoadingChange, + onValidityChange, }: { searchParams: { type: string; id: string }; providerUid?: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; }) => { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -19,7 +33,7 @@ export const AddViaCredentialsForm = ({ return await addCredentialsProvider(formData); }; - const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`; + const successNavigationUrl = "/providers"; return ( ); diff --git a/ui/components/providers/workflow/forms/add-via-role-form.tsx b/ui/components/providers/workflow/forms/add-via-role-form.tsx index 417f0843f9..0e180c0bc6 100644 --- a/ui/components/providers/workflow/forms/add-via-role-form.tsx +++ b/ui/components/providers/workflow/forms/add-via-role-form.tsx @@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form"; export const AddViaRoleForm = ({ searchParams, providerUid, + via, + onSuccess, + onBack, + formId, + hideActions, + onLoadingChange, + onValidityChange, }: { searchParams: { type: string; id: string }; providerUid?: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; }) => { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -19,7 +33,7 @@ export const AddViaRoleForm = ({ return await addCredentialsProvider(formData); }; - const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`; + const successNavigationUrl = "/providers"; return ( ); diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx index de97647e51..9e0a44a53d 100644 --- a/ui/components/providers/workflow/forms/base-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -2,6 +2,7 @@ import { Divider } from "@heroui/divider"; import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react"; +import { useEffect } from "react"; import { Control, UseFormSetValue } from "react-hook-form"; import { Button } from "@/components/shadcn"; @@ -62,8 +63,16 @@ type BaseCredentialsFormProps = { providerUid?: string; onSubmit: (formData: FormData) => Promise; successNavigationUrl: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; submitButtonText?: string; showBackButton?: boolean; + validationMode?: "onSubmit" | "onChange"; }; export const BaseCredentialsForm = ({ @@ -72,15 +81,24 @@ export const BaseCredentialsForm = ({ providerUid, onSubmit, successNavigationUrl, + via, + onSuccess, + onBack, + formId, + hideActions = false, + onLoadingChange, + onValidityChange, submitButtonText = "Next", showBackButton = true, + validationMode, }: BaseCredentialsFormProps) => { const { form, isLoading, + isValid, handleSubmit, handleBackStep, - searchParamsObj, + effectiveVia, externalId, } = useCredentialsForm({ providerType, @@ -88,13 +106,26 @@ export const BaseCredentialsForm = ({ providerUid, onSubmit, successNavigationUrl, + via, + onSuccess, + onBack, + validationMode, }); + useEffect(() => { + onLoadingChange?.(isLoading); + }, [isLoading, onLoadingChange]); + + useEffect(() => { + onValidityChange?.(isValid); + }, [isValid, onValidityChange]); + const templateLinks = getAWSCredentialsTemplateLinks(externalId); return (
@@ -120,7 +151,7 @@ export const BaseCredentialsForm = ({ - {providerType === "aws" && searchParamsObj.get("via") === "role" && ( + {providerType === "aws" && effectiveVia === "role" && ( } setValue={ @@ -130,7 +161,7 @@ export const BaseCredentialsForm = ({ templateLinks={templateLinks} /> )} - {providerType === "aws" && searchParamsObj.get("via") !== "role" && ( + {providerType === "aws" && effectiveVia !== "role" && ( } /> @@ -140,36 +171,30 @@ export const BaseCredentialsForm = ({ control={form.control as unknown as Control} /> )} - {providerType === "m365" && - searchParamsObj.get("via") === "app_client_secret" && ( - - } - /> - )} - {providerType === "m365" && - searchParamsObj.get("via") === "app_certificate" && ( - - } - /> - )} - {providerType === "gcp" && - searchParamsObj.get("via") === "service-account" && ( - } - /> - )} - {providerType === "gcp" && - searchParamsObj.get("via") !== "service-account" && ( - - } - /> - )} + {providerType === "m365" && effectiveVia === "app_client_secret" && ( + + } + /> + )} + {providerType === "m365" && effectiveVia === "app_certificate" && ( + + } + /> + )} + {providerType === "gcp" && effectiveVia === "service-account" && ( + } + /> + )} + {providerType === "gcp" && effectiveVia !== "service-account" && ( + } + /> + )} {providerType === "kubernetes" && ( } @@ -178,7 +203,7 @@ export const BaseCredentialsForm = ({ {providerType === "github" && ( )} {providerType === "iac" && ( @@ -198,71 +223,69 @@ export const BaseCredentialsForm = ({ } /> )} - {providerType === "alibabacloud" && - searchParamsObj.get("via") === "role" && ( - - } - /> - )} - {providerType === "alibabacloud" && - searchParamsObj.get("via") !== "role" && ( - - } - /> - )} - {providerType === "cloudflare" && - searchParamsObj.get("via") === "api_token" && ( - - } - /> - )} - {providerType === "cloudflare" && - searchParamsObj.get("via") === "api_key" && ( - - } - /> - )} + {providerType === "alibabacloud" && effectiveVia === "role" && ( + + } + /> + )} + {providerType === "alibabacloud" && effectiveVia !== "role" && ( + + } + /> + )} + {providerType === "cloudflare" && effectiveVia === "api_token" && ( + + } + /> + )} + {providerType === "cloudflare" && effectiveVia === "api_key" && ( + + } + /> + )} {providerType === "openstack" && ( } /> )} -
- {showBackButton && requiresBackButton(searchParamsObj.get("via")) && ( + {!hideActions && ( +
+ {showBackButton && requiresBackButton(effectiveVia) && ( + + )} - )} - -
+
+ )} ); diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index bd3444fd50..4cec74b237 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -3,11 +3,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { useForm, UseFormReturn } from "react-hook-form"; import { z } from "zod"; import { addProvider } from "@/actions/providers/providers"; +import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector"; import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs"; import { Button } from "@/components/shadcn"; import { useToast } from "@/components/ui"; @@ -19,6 +20,29 @@ import { RadioGroupProvider } from "../../radio-group-provider"; export type FormValues = z.infer; +export interface ConnectAccountSuccessData { + id: string; + providerType: ProviderType; + uid: string; + alias: string | null; +} + +interface ConnectAccountFormProps { + onSuccess?: (data: ConnectAccountSuccessData) => void; + onSelectOrganizations?: () => void; + onProviderTypeChange?: (providerType: ProviderType | null) => void; + formId?: string; + hideNavigation?: boolean; + onUiStateChange?: (state: { + showBack: boolean; + showAction: boolean; + actionLabel: string; + actionDisabled: boolean; + isLoading: boolean; + }) => void; + onBackHandlerChange?: (handler: () => void) => void; +} + // Helper function for labels and placeholders const getProviderFieldDetails = (providerType?: ProviderType) => { switch (providerType) { @@ -90,9 +114,50 @@ const getProviderFieldDetails = (providerType?: ProviderType) => { } }; -export const ConnectAccountForm = () => { +function applyBackStep({ + prevStep, + awsMethod, + form, + setPrevStep, + setAwsMethod, +}: { + prevStep: number; + awsMethod: "single" | null; + form: Pick, "setValue">; + setPrevStep: Dispatch>; + setAwsMethod: Dispatch>; +}) { + // If in UID form after choosing single, go back to method selector + if (prevStep === 2 && awsMethod === "single") { + setAwsMethod(null); + form.setValue("providerUid", ""); + form.setValue("providerAlias", ""); + return; + } + + setPrevStep((prev) => prev - 1); + // Deselect the providerType if the user is going back to the first step + if (prevStep === 2) { + form.setValue("providerType", undefined as unknown as ProviderType); + setAwsMethod(null); + } + // Reset the providerUid and providerAlias fields when going back + form.setValue("providerUid", ""); + form.setValue("providerAlias", ""); +} + +export const ConnectAccountForm = ({ + onSuccess, + onSelectOrganizations, + onProviderTypeChange, + formId, + hideNavigation = false, + onUiStateChange, + onBackHandlerChange, +}: ConnectAccountFormProps) => { const { toast } = useToast(); const [prevStep, setPrevStep] = useState(1); + const [awsMethod, setAwsMethod] = useState<"single" | null>(null); const router = useRouter(); const formSchema = addProviderFormSchema; @@ -104,9 +169,12 @@ export const ConnectAccountForm = () => { providerUid: "", providerAlias: "", }, + mode: "onChange", + reValidateMode: "onChange", }); const providerType = form.watch("providerType"); + const providerUid = form.watch("providerUid"); const providerFieldDetails = getProviderFieldDetails(providerType); const isLoading = form.formState.isSubmitting; @@ -161,10 +229,20 @@ export const ConnectAccountForm = () => { // Go to the next step after successful submission const { id, - attributes: { provider: providerType }, + attributes: { provider: createdProviderType, uid, alias }, } = data.data; - router.push(`/providers/add-credentials?type=${providerType}&id=${id}`); + if (onSuccess) { + onSuccess({ + id, + providerType: createdProviderType, + uid: uid || values.providerUid, + alias: alias ?? values.providerAlias ?? null, + }); + return; + } + + router.push("/providers"); } } catch (error: unknown) { console.error("Error during submission:", error); @@ -180,14 +258,13 @@ export const ConnectAccountForm = () => { }; const handleBackStep = () => { - setPrevStep((prev) => prev - 1); - //Deselect the providerType if the user is going back to the first step - if (prevStep === 2) { - form.setValue("providerType", undefined as unknown as ProviderType); - } - // Reset the providerUid and providerAlias fields when going back - form.setValue("providerUid", ""); - form.setValue("providerAlias", ""); + applyBackStep({ + prevStep, + awsMethod, + form, + setPrevStep, + setAwsMethod, + }); }; useEffect(() => { @@ -196,9 +273,51 @@ export const ConnectAccountForm = () => { } }, [providerType]); + useEffect(() => { + onProviderTypeChange?.(providerType ?? null); + }, [onProviderTypeChange, providerType]); + + useEffect(() => { + onBackHandlerChange?.(() => { + applyBackStep({ + prevStep, + awsMethod, + form, + setPrevStep, + setAwsMethod, + }); + }); + }, [onBackHandlerChange, prevStep, awsMethod, form]); + + useEffect(() => { + const canSubmit = + prevStep === 2 && + (providerType !== "aws" || awsMethod === "single") && + providerUid.trim().length > 0 && + form.formState.isValid; + + onUiStateChange?.({ + showBack: prevStep === 2, + showAction: + prevStep === 2 && (providerType !== "aws" || awsMethod === "single"), + actionLabel: "Next", + actionDisabled: !canSubmit || isLoading, + isLoading, + }); + }, [ + awsMethod, + form.formState.isValid, + isLoading, + onUiStateChange, + prevStep, + providerType, + providerUid, + ]); + return (
@@ -210,64 +329,77 @@ export const ConnectAccountForm = () => { errorMessage={form.formState.errors.providerType?.message} /> )} - {/* Step 2: UID, alias, and credentials (if AWS) */} - {prevStep === 2 && ( + {/* Step 2: AWS method selector (only for AWS, before choosing method) */} + {prevStep === 2 && providerType === "aws" && awsMethod === null && ( <> - - setAwsMethod("single")} + onSelectOrganizations={() => { + onSelectOrganizations?.(); + }} /> )} - {/* Navigation buttons */} -
- {/* Show "Back" button only in Step 2 */} - {prevStep === 2 && ( - + {/* Step 2: UID, alias form (non-AWS or AWS single account) */} + {prevStep === 2 && + (providerType !== "aws" || awsMethod === "single") && ( + <> + + + + )} - {/* Show "Next" button in Step 2 */} - {prevStep === 2 && ( - + )} + {prevStep === 2 && + (providerType !== "aws" || awsMethod === "single") && ( + )} - {isLoading ? "Loading" : "Next"} - - )} -
+
+ )} ); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/alibabacloud/select-via-alibabacloud.tsx b/ui/components/providers/workflow/forms/select-credentials-type/alibabacloud/select-via-alibabacloud.tsx index 67627482a6..2a83629b70 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/alibabacloud/select-via-alibabacloud.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/alibabacloud/select-via-alibabacloud.tsx @@ -9,10 +9,12 @@ import { RadioGroupAlibabaCloudViaCredentialsTypeForm } from "./radio-group-alib interface SelectViaAlibabaCloudProps { initialVia?: string; + onViaChange?: (via: string) => void; } export const SelectViaAlibabaCloud = ({ initialVia, + onViaChange, }: SelectViaAlibabaCloudProps) => { const router = useRouter(); const form = useForm({ @@ -22,6 +24,11 @@ export const SelectViaAlibabaCloud = ({ }); const handleSelectionChange = (value: string) => { + if (onViaChange) { + onViaChange(value); + return; + } + const url = new URL(window.location.href); url.searchParams.set("via", value); router.push(url.toString()); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx index 01f7fe8e7c..a7554c4cf2 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx @@ -9,9 +9,13 @@ import { RadioGroupAWSViaCredentialsTypeForm } from "./radio-group-aws-via-crede interface SelectViaAWSProps { initialVia?: string; + onViaChange?: (via: string) => void; } -export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => { +export const SelectViaAWS = ({ + initialVia, + onViaChange, +}: SelectViaAWSProps) => { const router = useRouter(); const form = useForm({ defaultValues: { @@ -20,6 +24,11 @@ export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => { }); const handleSelectionChange = (value: string) => { + if (onViaChange) { + onViaChange(value); + return; + } + const url = new URL(window.location.href); url.searchParams.set("via", value); router.push(url.toString()); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/select-via-cloudflare.tsx b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/select-via-cloudflare.tsx index 33c9482372..24011ff56d 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/select-via-cloudflare.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/select-via-cloudflare.tsx @@ -9,10 +9,12 @@ import { RadioGroupCloudflareViaCredentialsTypeForm } from "./radio-group-cloudf interface SelectViaCloudflareProps { initialVia?: string; + onViaChange?: (value: string) => void; } export const SelectViaCloudflare = ({ initialVia, + onViaChange, }: SelectViaCloudflareProps) => { const router = useRouter(); const form = useForm({ @@ -22,6 +24,11 @@ export const SelectViaCloudflare = ({ }); const handleSelectionChange = (value: string) => { + if (onViaChange) { + onViaChange(value); + return; + } + const url = new URL(window.location.href); url.searchParams.set("via", value); router.push(url.toString()); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/add-via-service-account-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/gcp/add-via-service-account-form.tsx index 56590a3c67..4913da857e 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/gcp/add-via-service-account-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/add-via-service-account-form.tsx @@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "../../base-credentials-form"; export const AddViaServiceAccountForm = ({ searchParams, providerUid, + via, + onSuccess, + onBack, + formId, + hideActions, + onLoadingChange, + onValidityChange, }: { searchParams: { type: ProviderType; id: string }; providerUid?: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; }) => { const providerType = searchParams.type; const providerId = searchParams.id; @@ -19,7 +33,7 @@ export const AddViaServiceAccountForm = ({ return await addCredentialsProvider(formData); }; - const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`; + const successNavigationUrl = "/providers"; return ( ); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx b/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx index b998005c8b..5d52c3e4a8 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx @@ -9,9 +9,13 @@ import { RadioGroupGCPViaCredentialsTypeForm } from "./radio-group-gcp-via-crede interface SelectViaGCPProps { initialVia?: string; + onViaChange?: (via: string) => void; } -export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => { +export const SelectViaGCP = ({ + initialVia, + onViaChange, +}: SelectViaGCPProps) => { const router = useRouter(); const form = useForm({ defaultValues: { @@ -20,6 +24,11 @@ export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => { }); const handleSelectionChange = (value: string) => { + if (onViaChange) { + onViaChange(value); + return; + } + const url = new URL(window.location.href); url.searchParams.set("via", value); router.push(url.toString()); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/github/select-via-github.tsx b/ui/components/providers/workflow/forms/select-credentials-type/github/select-via-github.tsx index 9fe491f4ca..7822c4de22 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/github/select-via-github.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/github/select-via-github.tsx @@ -9,9 +9,13 @@ import { RadioGroupGitHubViaCredentialsTypeForm } from "./radio-group-github-via interface SelectViaGitHubProps { initialVia?: string; + onViaChange?: (via: string) => void; } -export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => { +export const SelectViaGitHub = ({ + initialVia, + onViaChange, +}: SelectViaGitHubProps) => { const router = useRouter(); const form = useForm({ defaultValues: { @@ -20,6 +24,11 @@ export const SelectViaGitHub = ({ initialVia }: SelectViaGitHubProps) => { }); const handleSelectionChange = (value: string) => { + if (onViaChange) { + onViaChange(value); + return; + } + const url = new URL(window.location.href); url.searchParams.set("via", value); router.push(url.toString()); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/m365/select-via-m365.tsx b/ui/components/providers/workflow/forms/select-credentials-type/m365/select-via-m365.tsx index 020e4ed0e1..71bd260fff 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/m365/select-via-m365.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/m365/select-via-m365.tsx @@ -9,9 +9,13 @@ import { RadioGroupM365ViaCredentialsTypeForm } from "./radio-group-m365-via-cre interface SelectViaM365Props { initialVia?: string; + onViaChange?: (via: string) => void; } -export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => { +export const SelectViaM365 = ({ + initialVia, + onViaChange, +}: SelectViaM365Props) => { const router = useRouter(); const form = useForm({ defaultValues: { @@ -20,6 +24,11 @@ export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => { }); const handleSelectionChange = (value: string) => { + if (onViaChange) { + onViaChange(value); + return; + } + const url = new URL(window.location.href); url.searchParams.set("via", value); router.push(url.toString()); diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index 9076b8bd9c..d6ae5dd1e8 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -6,7 +6,7 @@ import { Icon } from "@iconify/react"; import { Loader2 } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -28,40 +28,53 @@ import { ProviderInfo } from "../.."; type FormValues = z.input; -export const TestConnectionForm = ({ - searchParams, - providerData, -}: { - searchParams: { type: string; id: string; updated: string }; - providerData: { - data: { - id: string; - type: string; - attributes: { - uid: string; - connection: { - connected: boolean | null; - last_checked_at: string | null; - }; - provider: ProviderType; - alias: string; - scanner_args: Record; +export interface TestConnectionProviderData { + data: { + id: string; + type: string; + attributes: { + uid: string; + connection: { + connected: boolean | null; + last_checked_at: string | null; }; - relationships: { - secret: { - data: { - type: string; - id: string; - } | null; - }; + provider: ProviderType; + alias: string; + scanner_args: Record; + }; + relationships: { + secret: { + data: { + type: string; + id: string; + } | null; }; }; }; -}) => { +} + +interface TestConnectionFormProps { + searchParams: { type: string; id: string; updated: string }; + providerData: TestConnectionProviderData; + onSuccess?: () => void; + onResetCredentials?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; +} + +export const TestConnectionForm = ({ + searchParams, + providerData, + onSuccess, + onResetCredentials: onResetCredentialsCallback, + formId, + hideActions = false, + onLoadingChange, +}: TestConnectionFormProps) => { const { toast } = useToast(); const router = useRouter(); - const providerType = searchParams.type; const providerId = searchParams.id; const [apiErrorMessage, setApiErrorMessage] = useState(null); @@ -85,6 +98,10 @@ export const TestConnectionForm = ({ const isLoading = form.formState.isSubmitting; const isUpdated = searchParams?.updated === "true"; + useEffect(() => { + onLoadingChange?.(isLoading || isResettingCredentials); + }, [isLoading, isResettingCredentials, onLoadingChange]); + const onSubmitClient = async (values: FormValues) => { const formData = new FormData(); formData.append("providerId", values.providerId); @@ -123,7 +140,13 @@ export const TestConnectionForm = ({ error: connected ? null : error || "Unknown error", }); - if (connected && isUpdated) return router.push("/providers"); + if (connected && isUpdated) { + if (onSuccess) { + onSuccess(); + return; + } + return router.push("/providers"); + } if (connected && !isUpdated) { try { @@ -150,6 +173,11 @@ export const TestConnectionForm = ({ description: data.error, }); } else { + if (onSuccess) { + onSuccess(); + return; + } + setIsRedirecting(true); router.push("/scans"); } @@ -174,7 +202,7 @@ export const TestConnectionForm = ({ } }; - const onResetCredentials = async () => { + const handleResetCredentials = async () => { setIsResettingCredentials(true); // Check if provider the provider has no credentials @@ -183,20 +211,23 @@ export const TestConnectionForm = ({ const hasNoCredentials = !providerSecretId; if (hasNoCredentials) { - // If no credentials, redirect to add credentials page - router.push( - `/providers/add-credentials?type=${providerType}&id=${providerId}`, - ); + if (onResetCredentialsCallback) { + onResetCredentialsCallback(); + } else { + router.push("/providers"); + } + setIsResettingCredentials(false); 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}`, - ); + if (onResetCredentialsCallback) { + onResetCredentialsCallback(); + } else { + router.push("/providers"); + } } catch (error) { console.error("Failed to delete credentials:", error); } finally { @@ -226,6 +257,7 @@ export const TestConnectionForm = ({ return (
@@ -299,52 +331,58 @@ export const TestConnectionForm = ({ -
- {apiErrorMessage ? ( - - ) : connectionStatus?.error ? ( - - ) : ( - - )} -
+ {!hideActions && ( +
+ {apiErrorMessage ? ( + + ) : connectionStatus?.error ? ( + + ) : ( + + )} +
+ )}
); diff --git a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx index f838b71478..f9758fb5bb 100644 --- a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx @@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form"; export const UpdateViaCredentialsForm = ({ searchParams, providerUid, + via, + onSuccess, + onBack, + formId, + hideActions, + onLoadingChange, + onValidityChange, }: { searchParams: { type: string; id: string; secretId?: string }; providerUid?: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; }) => { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -20,7 +34,7 @@ export const UpdateViaCredentialsForm = ({ return await updateCredentialsProvider(providerSecretId, formData); }; - const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`; + const successNavigationUrl = "/providers"; return ( ); diff --git a/ui/components/providers/workflow/forms/update-via-role-form.tsx b/ui/components/providers/workflow/forms/update-via-role-form.tsx index 4861b0c5c7..63b3404c9b 100644 --- a/ui/components/providers/workflow/forms/update-via-role-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-role-form.tsx @@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form"; export const UpdateViaRoleForm = ({ searchParams, providerUid, + via, + onSuccess, + onBack, + formId, + hideActions, + onLoadingChange, + onValidityChange, }: { searchParams: { type: string; id: string; secretId?: string }; providerUid?: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; }) => { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -20,7 +34,7 @@ export const UpdateViaRoleForm = ({ return await updateCredentialsProvider(providerSecretId, formData); }; - const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`; + const successNavigationUrl = "/providers"; return ( ); diff --git a/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx b/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx index 2567e1a9c8..6ae6c50ed2 100644 --- a/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx @@ -8,9 +8,23 @@ import { BaseCredentialsForm } from "./base-credentials-form"; export const UpdateViaServiceAccountForm = ({ searchParams, providerUid, + via, + onSuccess, + onBack, + formId, + hideActions, + onLoadingChange, + onValidityChange, }: { searchParams: { type: string; id: string; secretId?: string }; providerUid?: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + formId?: string; + hideActions?: boolean; + onLoadingChange?: (isLoading: boolean) => void; + onValidityChange?: (isValid: boolean) => void; }) => { const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; @@ -20,7 +34,7 @@ export const UpdateViaServiceAccountForm = ({ return await updateCredentialsProvider(providerSecretId, formData); }; - const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`; + const successNavigationUrl = "/providers"; return ( ); diff --git a/ui/components/providers/workflow/index.ts b/ui/components/providers/workflow/index.ts index 65cabc53ce..b2bc271617 100644 --- a/ui/components/providers/workflow/index.ts +++ b/ui/components/providers/workflow/index.ts @@ -1,5 +1,3 @@ export * from "./credentials-role-helper"; export * from "./provider-title-docs"; export * from "./skeleton-provider-workflow"; -export * from "./vertical-steps"; -export * from "./workflow-add-provider"; diff --git a/ui/components/providers/workflow/provider-title-docs.tsx b/ui/components/providers/workflow/provider-title-docs.tsx index 51081f17ce..9afc788e79 100644 --- a/ui/components/providers/workflow/provider-title-docs.tsx +++ b/ui/components/providers/workflow/provider-title-docs.tsx @@ -1,9 +1,7 @@ "use client"; -import { CustomLink } from "@/components/ui/custom/custom-link"; import { getProviderName } from "@/components/ui/entities/get-provider-logo"; import { getProviderLogo } from "@/components/ui/entities/get-provider-logo"; -import { getProviderHelpText } from "@/lib"; import { ProviderType } from "@/types"; export const ProviderTitleDocs = ({ @@ -12,27 +10,13 @@ export const ProviderTitleDocs = ({ providerType: ProviderType; }) => { return ( -
-
- {providerType && getProviderLogo(providerType as ProviderType)} - - {providerType - ? getProviderName(providerType as ProviderType) - : "Unknown Provider"} - -
-
-

- {getProviderHelpText(providerType as string).text} -

- - Read the docs - -
+
+ {providerType && getProviderLogo(providerType as ProviderType)} + + {providerType + ? getProviderName(providerType as ProviderType) + : "Unknown Provider"} +
); }; diff --git a/ui/components/providers/workflow/vertical-steps.tsx b/ui/components/providers/workflow/vertical-steps.tsx deleted file mode 100644 index 2971d3080c..0000000000 --- a/ui/components/providers/workflow/vertical-steps.tsx +++ /dev/null @@ -1,294 +0,0 @@ -"use client"; - -import { cn } from "@heroui/theme"; -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?: - | "primary" - | "secondary" - | "success" - | "warning" - | "danger" - | "default"; - /** - * 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(--heroui-default-300))]", - "[--inactive-color:hsl(var(--heroui-default-300))]", - ]; - - switch (color) { - case "primary": - userColor = "[--step-color:hsl(var(--heroui-primary))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]"; - break; - case "secondary": - userColor = "[--step-color:hsl(var(--heroui-secondary))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]"; - break; - case "success": - userColor = "[--step-color:hsl(var(--heroui-success))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]"; - break; - case "warning": - userColor = "[--step-color:hsl(var(--heroui-warning))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]"; - break; - case "danger": - userColor = "[--step-color:hsl(var(--heroui-error))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]"; - break; - case "default": - userColor = "[--step-color:hsl(var(--heroui-default))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]"; - break; - default: - userColor = "[--step-color:hsl(var(--heroui-primary))]"; - fgColor = "[--step-fg-color:hsl(var(--heroui-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(--heroui-default-300))]", - ); - - return colorsVars; - }, [color, className]); - - return ( - - ); - }, -); - -VerticalSteps.displayName = "VerticalSteps"; diff --git a/ui/components/providers/workflow/workflow-add-provider.tsx b/ui/components/providers/workflow/workflow-add-provider.tsx deleted file mode 100644 index fbb5a3e393..0000000000 --- a/ui/components/providers/workflow/workflow-add-provider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { Progress } from "@heroui/progress"; -import { Spacer } from "@heroui/spacer"; -import { usePathname } from "next/navigation"; -import React from "react"; - -import { VerticalSteps } from "./vertical-steps"; - -const steps = [ - { - title: "Choose your Cloud Provider", - description: - "Select the cloud provider you wish to connect and specify your preferred authentication method from the supported options.", - href: "/providers/connect-account", - }, - { - title: "Enter Authentication Details", - description: - "Provide the necessary credentials to establish a secure connection to your selected cloud provider.", - href: "/providers/add-credentials", - }, - { - title: "Verify Connection & Start Scan", - description: - "Ensure your credentials are correct and start scanning your cloud environment.", - href: "/providers/test-connection", - }, -]; - -const ROUTE_CONFIG: Record< - string, - { - stepIndex: number; - stepOverride?: { index: number; title: string; description: string }; - } -> = { - "/providers/connect-account": { stepIndex: 0 }, - "/providers/add-credentials": { stepIndex: 1 }, - "/providers/test-connection": { stepIndex: 2 }, - "/providers/update-credentials": { - stepIndex: 1, - stepOverride: { - index: 2, - title: "Make sure the new credentials are valid", - description: "Valid credentials will take you back to the providers page", - }, - }, -}; - -export const WorkflowAddProvider = () => { - const pathname = usePathname(); - - const config = ROUTE_CONFIG[pathname] || { stepIndex: 0 }; - const currentStep = config.stepIndex; - - const updatedSteps = steps.map((step, index) => { - if (config.stepOverride && index === config.stepOverride.index) { - return { ...step, ...config.stepOverride }; - } - return step; - }); - - return ( -
-

- Add a Cloud Provider -

-

- Complete these steps to configure your cloud provider and initiate your - first scan. -

- - - -
- ); -}; diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx index 7e9340a6ad..da9d8bf318 100644 --- a/ui/components/scans/no-providers-added.tsx +++ b/ui/components/scans/no-providers-added.tsx @@ -1,39 +1,45 @@ "use client"; -import Link from "next/link"; +import { useState } from "react"; +import { ProviderWizardModal } from "@/components/providers/wizard"; import { Button, Card, CardContent } from "@/components/shadcn"; import { InfoIcon } from "../icons/Icons"; export const NoProvidersAdded = () => { - return ( -
- - -
- -

- No Cloud Providers Configured -

-
-
-

- No cloud providers have been configured. Start by setting up a - cloud provider. -

-
+ const [open, setOpen] = useState(false); - -
-
-
+ return ( + <> +
+ + +
+ +

+ No Cloud Providers Configured +

+
+
+

+ No cloud providers have been configured. Start by setting up a + cloud provider. +

+
+ + +
+
+
+ + ); }; diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts index 970d1d3d7f..10008c70d5 100644 --- a/ui/hooks/use-credentials-form.ts +++ b/ui/hooks/use-credentials-form.ts @@ -21,6 +21,10 @@ type UseCredentialsFormProps = { providerUid?: string; onSubmit: (formData: FormData) => Promise; successNavigationUrl: string; + via?: string | null; + onSuccess?: () => void; + onBack?: () => void; + validationMode?: "onSubmit" | "onChange"; }; export const useCredentialsForm = ({ @@ -29,21 +33,25 @@ export const useCredentialsForm = ({ providerUid, onSubmit, successNavigationUrl, + via: viaOverride, + onSuccess, + onBack, + validationMode = "onChange", }: UseCredentialsFormProps) => { const router = useRouter(); const searchParamsObj = useSearchParams(); const { data: session } = useSession(); - const via = searchParamsObj.get("via"); + const effectiveVia = viaOverride ?? searchParamsObj.get("via"); // Select the appropriate schema based on provider type and via parameter const getFormSchema = () => { - if (providerType === "aws" && via === "role") { + if (providerType === "aws" && effectiveVia === "role") { return addCredentialsRoleFormSchema(providerType); } - if (providerType === "alibabacloud" && via === "role") { + if (providerType === "alibabacloud" && effectiveVia === "role") { return addCredentialsRoleFormSchema(providerType); } - if (providerType === "gcp" && via === "service-account") { + if (providerType === "gcp" && effectiveVia === "service-account") { return addCredentialsServiceAccountFormSchema(providerType); } // For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required @@ -52,7 +60,7 @@ export const useCredentialsForm = ({ providerType === "m365" || providerType === "cloudflare" ) { - return addCredentialsFormSchema(providerType, via); + return addCredentialsFormSchema(providerType, effectiveVia); } return addCredentialsFormSchema(providerType); }; @@ -67,7 +75,7 @@ export const useCredentialsForm = ({ }; // AWS Role credentials - if (providerType === "aws" && via === "role") { + if (providerType === "aws" && effectiveVia === "role") { const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; const defaultCredentialsType = isCloudEnv ? "aws-sdk-default" @@ -86,7 +94,7 @@ export const useCredentialsForm = ({ } // GCP Service Account - if (providerType === "gcp" && via === "service-account") { + if (providerType === "gcp" && effectiveVia === "service-account") { return { ...baseDefaults, [ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: "", @@ -110,7 +118,7 @@ export const useCredentialsForm = ({ }; case "m365": // M365 credentials based on via parameter - if (via === "app_client_secret") { + if (effectiveVia === "app_client_secret") { return { ...baseDefaults, [ProviderCredentialFields.CLIENT_ID]: "", @@ -118,7 +126,7 @@ export const useCredentialsForm = ({ [ProviderCredentialFields.TENANT_ID]: "", }; } - if (via === "app_certificate") { + if (effectiveVia === "app_certificate") { return { ...baseDefaults, [ProviderCredentialFields.CLIENT_ID]: "", @@ -145,19 +153,19 @@ export const useCredentialsForm = ({ }; case "github": // GitHub credentials based on via parameter - if (via === "personal_access_token") { + if (effectiveVia === "personal_access_token") { return { ...baseDefaults, [ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: "", }; } - if (via === "oauth_app") { + if (effectiveVia === "oauth_app") { return { ...baseDefaults, [ProviderCredentialFields.OAUTH_APP_TOKEN]: "", }; } - if (via === "github_app") { + if (effectiveVia === "github_app") { return { ...baseDefaults, [ProviderCredentialFields.GITHUB_APP_ID]: "", @@ -182,7 +190,7 @@ export const useCredentialsForm = ({ [ProviderCredentialFields.ATLAS_PRIVATE_KEY]: "", }; case "alibabacloud": - if (via === "role") { + if (effectiveVia === "role") { return { ...baseDefaults, [ProviderCredentialFields.ALIBABACLOUD_ROLE_ARN]: "", @@ -198,13 +206,13 @@ export const useCredentialsForm = ({ }; case "cloudflare": // Cloudflare credentials based on via parameter - if (via === "api_token") { + if (effectiveVia === "api_token") { return { ...baseDefaults, [ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: "", }; } - if (via === "api_key") { + if (effectiveVia === "api_key") { return { ...baseDefaults, [ProviderCredentialFields.CLOUDFLARE_API_KEY]: "", @@ -228,7 +236,7 @@ export const useCredentialsForm = ({ const form = useForm({ resolver: zodResolver(formSchema), defaultValues: defaultValues, - mode: "onSubmit", + mode: validationMode, reValidateMode: "onChange", criteriaMode: "all", // Show all errors for each field }); @@ -240,6 +248,11 @@ export const useCredentialsForm = ({ // Handler for back button const handleBackStep = () => { + if (onBack) { + onBack(); + return; + } + const currentParams = new URLSearchParams(window.location.search); currentParams.delete("via"); router.push(`?${currentParams.toString()}`); @@ -260,19 +273,25 @@ export const useCredentialsForm = ({ const isSuccess = handleServerResponse(data); if (isSuccess) { + if (onSuccess) { + onSuccess(); + return; + } router.push(successNavigationUrl); } }; - const { isSubmitting, errors } = form.formState; + const { isSubmitting, isValid, errors } = form.formState; return { form, isLoading: isSubmitting, + isValid, errors, handleSubmit, handleBackStep, searchParamsObj, + effectiveVia, externalId: session?.tenantId || "", }; };