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 (
- <>
-
-
-
- >
- );
-}
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}
+
+
+
+
+
+
+ {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.backLabel}
+
+ )}
+
+
+ {resolvedFooterConfig.showSecondaryAction && (
+
+ {resolvedFooterConfig.secondaryActionLabel}
+
+ )}
+
+ {resolvedFooterConfig.showAction && (
+
+ {resolvedFooterConfig.actionLabel}
+
+ )}
+
+
+
+ )}
+
+ );
+}
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 (
);
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 (
);
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 (
);
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);
-
- Get Started
-
-
-
-
+ return (
+ <>
+
+
+
+
+
+
+ No Cloud Providers Configured
+
+
+
+
+ No cloud providers have been configured. Start by setting up a
+ cloud provider.
+
+
+
+ setOpen(true)}
+ >
+ Get Started
+
+
+
+
+
+ >
);
};
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 || "",
};
};