feat(ui): replace route-based provider flow with modal wizard (#10156)

This commit is contained in:
Alejandro Bailo
2026-02-25 13:08:17 +01:00
committed by GitHub
parent 231bfd6f41
commit 7935e926ac
47 changed files with 2445 additions and 987 deletions

View File

@@ -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 <SelectViaAWS initialVia={via} />;
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
if (providerType === "alibabacloud")
return <SelectViaAlibabaCloud initialVia={via} />;
if (providerType === "cloudflare")
return <SelectViaCloudflare initialVia={via} />;
return null;
case "credentials":
return (
<AddViaCredentialsForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "role":
return (
<AddViaRoleForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "service-account":
return (
<AddViaServiceAccountForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
default:
return null;
}
}

View File

@@ -1,7 +0,0 @@
import React from "react";
import { ConnectAccountForm } from "@/components/providers/workflow/forms";
export default function ConnectAccountPage() {
return <ConnectAccountForm />;
}

View File

@@ -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 (
<>
<NavigationHeader
title="Connect a Cloud Provider"
icon="icon-park-outline:close-small"
href="/providers"
/>
<Spacer y={8} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowAddProvider />
</div>
<div className="order-2 my-auto lg:col-span-5 lg:col-start-6">
{children}
</div>
</div>
</>
);
}

View File

@@ -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 (
<Suspense fallback={<SkeletonProviderWorkflow />}>
<SSRTestConnection searchParams={resolvedSearchParams} />
</Suspense>
);
}
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 (
<TestConnectionForm
searchParams={searchParams}
providerData={providerData}
/>
);
}

View File

@@ -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 (
<CredentialsUpdateInfo providerType={providerType} initialVia={via} />
);
case "credentials":
return (
<UpdateViaCredentialsForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "role":
return (
<UpdateViaRoleForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
case "service-account":
return (
<UpdateViaServiceAccountForm
searchParams={resolvedSearchParams}
providerUid={providerUid}
/>
);
default:
return null;
}
}

View File

@@ -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 (
<Button asChild>
<Link href="/providers/connect-account">
<>
<Button onClick={() => setOpen(true)}>
Add Cloud Provider
<AddIcon size={20} />
</Link>
</Button>
</Button>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
</>
);
};

View File

@@ -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 (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-sm">
Select a method to add your accounts to Prowler.
</p>
<RadioCard
icon={Box}
title="Add A Single AWS Cloud Account"
onClick={onSelectSingle}
/>
<RadioCard
icon={isCloudEnv ? Boxes : Ban}
title="Add Multiple Accounts With AWS Organizations"
onClick={onSelectOrganizations}
disabled={!isCloudEnv}
>
{!isCloudEnv && <CtaBadge />}
</RadioCard>
</div>
);
}
function CtaBadge() {
return (
<a
href="https://prowler.com/pricing"
target="_blank"
rel="noopener noreferrer"
className="flex h-[52px] shrink-0 items-center justify-center rounded-lg px-4 py-3 transition-opacity hover:opacity-90"
style={{
backgroundImage:
"linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)",
}}
>
<div className="flex items-center gap-1.5">
<span className="text-primary-foreground text-sm leading-6 font-bold">
Available in Prowler Cloud
</span>
</div>
</a>
);
}

View File

@@ -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 (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 py-6">
<h3 className="text-base font-semibold">Account selection</h3>
<p className="text-muted-foreground text-sm">
Account discovery and selection are introduced in the next chained PR.
</p>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 py-6">
<h3 className="text-base font-semibold">Launch scan</h3>
<p className="text-muted-foreground text-sm">
Organizations scan launch flow is completed in the next chained PR.
</p>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-0 flex-1 flex-col justify-center gap-2 py-6">
<h3 className="text-base font-semibold">AWS Organizations</h3>
<p className="text-muted-foreground text-sm">
The full AWS Organizations setup step is included in the next chained
PR.
</p>
</div>
);
}

View File

@@ -114,7 +114,7 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
name="providerType"
control={control}
render={({ field }) => (
<div className="flex h-[calc(100vh-200px)] flex-col px-4">
<div className="flex flex-col px-4">
<div className="relative z-10 shrink-0 pb-4">
<SearchInput
aria-label="Search providers"
@@ -125,19 +125,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
/>
</div>
<div className="minimal-scrollbar relative flex-1 overflow-y-auto pr-3">
<div className="relative">
<div
role="listbox"
aria-label="Select a provider"
className="flex flex-col gap-3"
style={{
maskImage:
"linear-gradient(to bottom, transparent, black 24px)",
WebkitMaskImage:
"linear-gradient(to bottom, transparent, black 24px)",
paddingTop: "24px",
marginTop: "-24px",
}}
>
{filteredProviders.length > 0 ? (
filteredProviders.map((provider) => {
@@ -152,19 +144,26 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
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",
)}
>
<BadgeComponent size={26} />
<span className="text-text-neutral-primary text-sm font-medium">
{provider.label}
</span>
<div className="border-border-neutral-primary bg-bg-input-primary flex size-[18px] shrink-0 items-center justify-center rounded-full border shadow-xs">
{isSelected && (
<div className="bg-primary size-2.5 rounded-full" />
)}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<BadgeComponent size={26} />
<span className="text-foreground text-sm leading-6">
{provider.label}
</span>
</div>
</button>
);
})

View File

@@ -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<ProviderProps> {
interface DataTableRowActionsProps {
row: Row<ProviderProps>;
}
export function DataTableRowActions<ProviderProps>({
row,
}: DataTableRowActionsProps<ProviderProps>) {
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<ProviderProps>({
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<ProviderProps>({
>
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
</Modal>
<ProviderWizardModal
open={isWizardOpen}
onOpenChange={setIsWizardOpen}
initialData={{
providerId,
providerType,
providerUid,
providerAlias,
secretId: providerSecretId,
mode: providerSecretId
? PROVIDER_WIZARD_MODE.UPDATE
: PROVIDER_WIZARD_MODE.ADD,
}}
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
@@ -78,11 +93,7 @@ export function DataTableRowActions<ProviderProps>({
<ActionDropdownItem
icon={<Pencil />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
onSelect={() =>
router.push(
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
)
}
onSelect={() => setIsWizardOpen(true)}
/>
<ActionDropdownItem
icon={<PlugZap />}

View File

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

View File

@@ -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<WizardVariant>(
WIZARD_VARIANT.PROVIDER,
);
const [currentStep, setCurrentStep] = useState<ProviderWizardStep>(
PROVIDER_WIZARD_STEP.CONNECT,
);
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
);
const [orgSetupPhase, setOrgSetupPhase] = useState<OrgSetupPhase>(
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,
};
}

View File

@@ -0,0 +1,3 @@
export * from "./provider-wizard-modal";
export * from "./steps";
export * from "./wizard-stepper";

View File

@@ -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 (
<Modal
open={open}
onOpenChange={handleDialogOpenChange}
size="4xl"
className="flex !h-[90vh] !max-h-[90vh] !min-h-[90vh] !w-[calc(100vw-24px)] !max-w-[1192px] flex-col overflow-hidden p-4 sm:!w-[calc(100vw-40px)] sm:p-6 lg:!w-[calc(100vw-64px)] lg:p-8"
>
<DialogHeader className="gap-2 p-0">
<DialogTitle className="text-lg font-semibold">
{modalTitle}
</DialogTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<Info className="size-4 shrink-0" />
<span>For assistance connecting a Cloud Provider visit</span>
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<a href={docsLink} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5 shrink-0" />
<span>Prowler Docs</span>
</a>
</Button>
</div>
</DialogHeader>
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8 lg:flex-row">
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
{isProviderFlow ? (
<WizardStepper currentStep={currentStep} />
) : (
<WizardStepper
currentStep={orgCurrentStep}
stepOffset={getOrganizationsStepperOffset(
orgCurrentStep,
orgSetupPhase,
)}
/>
)}
</div>
<div aria-hidden className="hidden w-[100px] min-w-0 shrink lg:block" />
<div className="relative flex-1 overflow-hidden">
<div
ref={containerRef}
className="minimal-scrollbar h-full w-full overflow-y-scroll [scrollbar-gutter:stable] lg:ml-auto lg:max-w-[620px] xl:max-w-[700px]"
onScroll={handleScroll}
>
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
<ConnectStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)}
onSelectOrganizations={openOrganizationsFlow}
onFooterChange={setFooterConfig}
onProviderTypeChange={setProviderTypeHint}
/>
)}
{isProviderFlow &&
currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && (
<CredentialsStep
onNext={() => setCurrentStep(PROVIDER_WIZARD_STEP.TEST)}
onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && (
<TestConnectionStep
onSuccess={handleTestSuccess}
onResetCredentials={() =>
setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)
}
onFooterChange={setFooterConfig}
/>
)}
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH && (
<LaunchStep />
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm
onBack={backToProviderFlow}
onNext={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
onPhaseChange={setOrgSetupPhase}
initialPhase={orgSetupPhase}
/>
)}
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
<OrgAccountSelection
onBack={() => {
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 && (
<OrgLaunchScan
onClose={handleClose}
onBack={() => {
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
}}
onFooterChange={setFooterConfig}
/>
)}
</div>
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-0 z-10">
<div className="from-bg-neutral-secondary h-12 bg-gradient-to-t to-transparent" />
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<span className="bg-bg-neutral-secondary/85 text-text-neutral-tertiary rounded-full px-3 py-1 text-xs backdrop-blur-sm">
Scroll to see more
</span>
</div>
</div>
)}
</div>
</div>
{(resolvedFooterConfig.showBack ||
resolvedFooterConfig.showSecondaryAction ||
resolvedFooterConfig.showAction) && (
<div className="mt-8 pt-6">
<div className="flex items-center justify-between">
<div>
{resolvedFooterConfig.showBack && (
<Button
type="button"
variant="outline"
size="xl"
disabled={resolvedFooterConfig.backDisabled}
onClick={resolvedFooterConfig.onBack}
>
{resolvedFooterConfig.backLabel}
</Button>
)}
</div>
<div className="flex items-center gap-6">
{resolvedFooterConfig.showSecondaryAction && (
<Button
size={
resolvedFooterConfig.secondaryActionVariant === "link"
? "link-sm"
: "xl"
}
className={
resolvedFooterConfig.secondaryActionVariant === "link"
? "h-auto p-0"
: undefined
}
variant={resolvedFooterConfig.secondaryActionVariant}
type={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.secondaryActionFormId}
disabled={resolvedFooterConfig.secondaryActionDisabled}
onClick={
resolvedFooterConfig.secondaryActionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onSecondaryAction
: undefined
}
>
{resolvedFooterConfig.secondaryActionLabel}
</Button>
)}
{resolvedFooterConfig.showAction && (
<Button
size="xl"
type={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.SUBMIT
? "submit"
: "button"
}
form={resolvedFooterConfig.actionFormId}
disabled={resolvedFooterConfig.actionDisabled}
onClick={
resolvedFooterConfig.actionType ===
WIZARD_FOOTER_ACTION_TYPE.BUTTON
? resolvedFooterConfig.onAction
: undefined
}
>
{resolvedFooterConfig.actionLabel}
</Button>
)}
</div>
</div>
</div>
)}
</Modal>
);
}

View File

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

View File

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

View File

@@ -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 (
<ConnectAccountForm
formId={formId}
hideNavigation
onSuccess={handleSuccess}
onSelectOrganizations={onSelectOrganizations}
onProviderTypeChange={onProviderTypeChange}
onUiStateChange={setUiState}
onBackHandlerChange={(handler) => {
backHandlerRef.current = handler;
}}
/>
);
}

View File

@@ -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 (
<div className="flex h-full items-center justify-center py-8">
<p className="text-muted-foreground text-sm">
Provider details are missing. Go back and select a provider.
</p>
</div>
);
}
if (formType === "selector") {
if (providerType === "aws") {
return (
<SelectViaAWS
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "gcp") {
return (
<SelectViaGCP
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "github") {
return (
<SelectViaGitHub
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "m365") {
return (
<SelectViaM365
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "alibabacloud") {
return (
<SelectViaAlibabaCloud
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "cloudflare") {
return (
<SelectViaCloudflare
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
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 (
<UpdateViaCredentialsForm
searchParams={{
type: providerType,
id: providerId,
secretId: secretId || undefined,
}}
{...commonFormProps}
/>
);
}
return (
<AddViaCredentialsForm
searchParams={{ type: providerType, id: providerId }}
{...commonFormProps}
/>
);
}
if (formType === "role") {
if (shouldUseUpdateForms) {
return (
<UpdateViaRoleForm
searchParams={{
type: providerType,
id: providerId,
secretId: secretId || undefined,
}}
{...commonFormProps}
/>
);
}
return (
<AddViaRoleForm
searchParams={{ type: providerType, id: providerId }}
{...commonFormProps}
/>
);
}
if (formType === "service-account") {
if (shouldUseUpdateForms) {
return (
<UpdateViaServiceAccountForm
searchParams={{
type: providerType,
id: providerId,
secretId: secretId || undefined,
}}
{...commonFormProps}
/>
);
}
return (
<AddViaServiceAccountForm
searchParams={{ type: providerType as ProviderType, id: providerId }}
{...commonFormProps}
/>
);
}
return (
<div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground text-sm">
Select a credential type to continue.
</p>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
"use client";
import { CheckCircle2 } from "lucide-react";
export function LaunchStep() {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
<CheckCircle2 className="text-success size-12" />
<h3 className="text-xl font-semibold">Provider connected successfully</h3>
<p className="text-muted-foreground text-sm">
Continue with the action button to go to scans.
</p>
</div>
);
}

View File

@@ -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<TestConnectionProviderData | null>(null);
const [isLoadingProvider, setIsLoadingProvider] = useState(true);
const [isFormLoading, setIsFormLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="flex min-h-[320px] items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
);
}
if (errorMessage || !providerData || !providerId || !providerType) {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 text-center">
<p className="text-muted-foreground text-sm">
{errorMessage || "Unable to load provider details."}
</p>
</div>
);
}
return (
<TestConnectionForm
formId={formId}
hideActions
onLoadingChange={setIsFormLoading}
searchParams={{
type: providerType,
id: providerId,
updated: mode === PROVIDER_WIZARD_MODE.UPDATE ? "true" : "false",
}}
providerData={providerData}
onSuccess={onSuccess}
onResetCredentials={onResetCredentials}
/>
);
}

View File

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

View File

@@ -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 (
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
{STEPS.map((step, index) => {
const isComplete = index < activeVisualStep;
const isActive = index === activeVisualStep;
const isInactive = index > activeVisualStep;
return (
<div key={step.label} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{index < STEPS.length - 1 && (
<StepConnector isComplete={isComplete} />
)}
</div>
<div className="flex flex-col gap-1 pt-[10px]">
<span
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-text-neutral-primary",
isComplete && "text-text-neutral-primary",
isInactive && "text-text-neutral-tertiary",
)}
>
{step.label}
</span>
<p className="text-text-neutral-secondary text-xs leading-5">
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
}
interface StepCircleProps {
isComplete: boolean;
isActive: boolean;
icon: IconComponent;
}
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
if (isComplete) {
return (
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
<CircleCheckBig className="text-bg-neutral-primary size-6" />
</div>
);
}
if (isActive) {
return (
<div className="border-border-input-primary-pressed bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
</div>
);
}
function StepConnector({ isComplete }: { isComplete: boolean }) {
if (isComplete) {
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
}
return (
<div
className="h-14 w-px"
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
}}
/>
);
}
function StepIcon({
icon: Icon,
className,
}: {
icon: IconComponent;
className: string;
}) {
if (isCustomSvgIcon(Icon)) {
return <Icon size={24} className={className} />;
}
return <Icon className={cn("size-6", className)} />;
}
function isCustomSvgIcon(
icon: IconComponent,
): icon is (props: IconSvgProps) => ReactElement {
return !("displayName" in icon && typeof icon.displayName === "string");
}

View File

@@ -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 (
<BaseCredentialsForm
@@ -28,6 +42,13 @@ export const AddViaCredentialsForm = ({
providerUid={providerUid}
onSubmit={handleAddCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -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 (
<BaseCredentialsForm
@@ -28,6 +42,13 @@ export const AddViaRoleForm = ({
providerUid={providerUid}
onSubmit={handleAddCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -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<ApiResponse>;
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 (
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(handleSubmit)}
className="flex flex-col gap-4"
>
@@ -120,7 +151,7 @@ export const BaseCredentialsForm = ({
<Divider />
{providerType === "aws" && searchParamsObj.get("via") === "role" && (
{providerType === "aws" && effectiveVia === "role" && (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={
@@ -130,7 +161,7 @@ export const BaseCredentialsForm = ({
templateLinks={templateLinks}
/>
)}
{providerType === "aws" && searchParamsObj.get("via") !== "role" && (
{providerType === "aws" && effectiveVia !== "role" && (
<AWSStaticCredentialsForm
control={form.control as unknown as Control<AWSCredentials>}
/>
@@ -140,36 +171,30 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<AzureCredentials>}
/>
)}
{providerType === "m365" &&
searchParamsObj.get("via") === "app_client_secret" && (
<M365ClientSecretCredentialsForm
control={
form.control as unknown as Control<M365ClientSecretCredentials>
}
/>
)}
{providerType === "m365" &&
searchParamsObj.get("via") === "app_certificate" && (
<M365CertificateCredentialsForm
control={
form.control as unknown as Control<M365CertificateCredentials>
}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") === "service-account" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") !== "service-account" && (
<GCPDefaultCredentialsForm
control={
form.control as unknown as Control<GCPDefaultCredentials>
}
/>
)}
{providerType === "m365" && effectiveVia === "app_client_secret" && (
<M365ClientSecretCredentialsForm
control={
form.control as unknown as Control<M365ClientSecretCredentials>
}
/>
)}
{providerType === "m365" && effectiveVia === "app_certificate" && (
<M365CertificateCredentialsForm
control={
form.control as unknown as Control<M365CertificateCredentials>
}
/>
)}
{providerType === "gcp" && effectiveVia === "service-account" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
/>
)}
{providerType === "gcp" && effectiveVia !== "service-account" && (
<GCPDefaultCredentialsForm
control={form.control as unknown as Control<GCPDefaultCredentials>}
/>
)}
{providerType === "kubernetes" && (
<KubernetesCredentialsForm
control={form.control as unknown as Control<KubernetesCredentials>}
@@ -178,7 +203,7 @@ export const BaseCredentialsForm = ({
{providerType === "github" && (
<GitHubCredentialsForm
control={form.control}
credentialsType={searchParamsObj.get("via") || undefined}
credentialsType={effectiveVia || undefined}
/>
)}
{providerType === "iac" && (
@@ -198,71 +223,69 @@ export const BaseCredentialsForm = ({
}
/>
)}
{providerType === "alibabacloud" &&
searchParamsObj.get("via") === "role" && (
<AlibabaCloudRoleCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentialsRole>
}
/>
)}
{providerType === "alibabacloud" &&
searchParamsObj.get("via") !== "role" && (
<AlibabaCloudStaticCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentials>
}
/>
)}
{providerType === "cloudflare" &&
searchParamsObj.get("via") === "api_token" && (
<CloudflareApiTokenCredentialsForm
control={
form.control as unknown as Control<CloudflareTokenCredentials>
}
/>
)}
{providerType === "cloudflare" &&
searchParamsObj.get("via") === "api_key" && (
<CloudflareApiKeyCredentialsForm
control={
form.control as unknown as Control<CloudflareApiKeyCredentials>
}
/>
)}
{providerType === "alibabacloud" && effectiveVia === "role" && (
<AlibabaCloudRoleCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentialsRole>
}
/>
)}
{providerType === "alibabacloud" && effectiveVia !== "role" && (
<AlibabaCloudStaticCredentialsForm
control={
form.control as unknown as Control<AlibabaCloudCredentials>
}
/>
)}
{providerType === "cloudflare" && effectiveVia === "api_token" && (
<CloudflareApiTokenCredentialsForm
control={
form.control as unknown as Control<CloudflareTokenCredentials>
}
/>
)}
{providerType === "cloudflare" && effectiveVia === "api_key" && (
<CloudflareApiKeyCredentialsForm
control={
form.control as unknown as Control<CloudflareApiKeyCredentials>
}
/>
)}
{providerType === "openstack" && (
<OpenStackCredentialsForm
control={form.control as unknown as Control<OpenStackCredentials>}
/>
)}
<div className="flex w-full justify-end gap-4">
{showBackButton && requiresBackButton(searchParamsObj.get("via")) && (
{!hideActions && (
<div className="flex w-full justify-end gap-4">
{showBackButton && requiresBackButton(effectiveVia) && (
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
</Button>
)}
<Button
type="button"
variant="ghost"
type="submit"
variant="default"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : submitButtonText}
</Button>
)}
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : submitButtonText}
</Button>
</div>
</div>
)}
</form>
</Form>
);

View File

@@ -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<typeof addProviderFormSchema>;
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<UseFormReturn<FormValues>, "setValue">;
setPrevStep: Dispatch<SetStateAction<number>>;
setAwsMethod: Dispatch<SetStateAction<"single" | null>>;
}) {
// 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 (
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
@@ -210,64 +329,77 @@ export const ConnectAccountForm = () => {
errorMessage={form.formState.errors.providerType?.message}
/>
)}
{/* Step 2: UID, alias, and credentials (if AWS) */}
{prevStep === 2 && (
{/* Step 2: AWS method selector (only for AWS, before choosing method) */}
{prevStep === 2 && providerType === "aws" && awsMethod === null && (
<>
<ProviderTitleDocs providerType={providerType} />
<CustomInput
control={form.control}
name="providerUid"
type="text"
label={providerFieldDetails.label}
labelPlacement="inside"
placeholder={providerFieldDetails.placeholder}
variant="bordered"
isRequired
/>
<CustomInput
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
<AwsMethodSelector
onSelectSingle={() => setAwsMethod("single")}
onSelectOrganizations={() => {
onSelectOrganizations?.();
}}
/>
</>
)}
{/* Navigation buttons */}
<div className="flex w-full justify-end gap-4">
{/* Show "Back" button only in Step 2 */}
{prevStep === 2 && (
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
</Button>
{/* Step 2: UID, alias form (non-AWS or AWS single account) */}
{prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") && (
<>
<ProviderTitleDocs providerType={providerType} />
<CustomInput
control={form.control}
name="providerUid"
type="text"
label={providerFieldDetails.label}
labelPlacement="inside"
placeholder={providerFieldDetails.placeholder}
variant="bordered"
isRequired
/>
<CustomInput
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
/>
</>
)}
{/* Show "Next" button in Step 2 */}
{prevStep === 2 && (
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
{!hideNavigation && (
<div className="flex w-full justify-end gap-4">
{prevStep === 2 && (
<Button
type="button"
variant="ghost"
size="lg"
onClick={handleBackStep}
disabled={isLoading}
>
{!isLoading && <ChevronLeftIcon size={24} />}
Back
</Button>
)}
{prevStep === 2 &&
(providerType !== "aws" || awsMethod === "single") && (
<Button
type="submit"
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ChevronRightIcon size={24} />
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
</div>
</div>
)}
</form>
</Form>
);

View File

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

View File

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

View File

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

View File

@@ -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 (
<BaseCredentialsForm
@@ -28,6 +42,13 @@ export const AddViaServiceAccountForm = ({
providerUid={providerUid}
onSubmit={handleAddCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

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

View File

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

View File

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

View File

@@ -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<typeof testConnectionFormSchema>;
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<string, unknown>;
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<string, unknown>;
};
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<string | null>(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 (
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
@@ -299,52 +331,58 @@ export const TestConnectionForm = ({
<input type="hidden" name="providerId" value={providerId} />
<div className="flex w-full justify-end sm:gap-6">
{apiErrorMessage ? (
<Button variant="outline" size="lg" asChild>
<Link href="/providers">Back to providers</Link>
</Button>
) : connectionStatus?.error ? (
<Button
onClick={isUpdated ? () => router.back() : onResetCredentials}
type="button"
variant="secondary"
size="lg"
disabled={isResettingCredentials}
>
{isResettingCredentials ? (
<Loader2 className="animate-spin" />
) : (
<CheckIcon size={24} />
)}
{isResettingCredentials
? "Loading"
: isUpdated
? "Update credentials"
: "Reset credentials"}
</Button>
) : (
<Button
type={
isUpdated && connectionStatus?.connected ? "button" : "submit"
}
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
!isUpdated && <RocketIcon size={24} />
)}
{isLoading
? "Loading"
: isUpdated
? "Check connection"
: "Launch scan"}
</Button>
)}
</div>
{!hideActions && (
<div className="flex w-full justify-end sm:gap-6">
{apiErrorMessage ? (
<Button variant="outline" size="lg" asChild>
<Link href="/providers">Back to providers</Link>
</Button>
) : connectionStatus?.error ? (
<Button
onClick={
isUpdated
? onResetCredentialsCallback || (() => router.back())
: handleResetCredentials
}
type="button"
variant="secondary"
size="lg"
disabled={isResettingCredentials}
>
{isResettingCredentials ? (
<Loader2 className="animate-spin" />
) : (
<CheckIcon size={24} />
)}
{isResettingCredentials
? "Loading"
: isUpdated
? "Update credentials"
: "Reset credentials"}
</Button>
) : (
<Button
type={
isUpdated && connectionStatus?.connected ? "button" : "submit"
}
variant="default"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
!isUpdated && <RocketIcon size={24} />
)}
{isLoading
? "Loading"
: isUpdated
? "Check connection"
: "Launch scan"}
</Button>
)}
</div>
)}
</form>
</Form>
);

View File

@@ -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 (
<BaseCredentialsForm
@@ -29,6 +43,13 @@ export const UpdateViaCredentialsForm = ({
providerUid={providerUid}
onSubmit={handleUpdateCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -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 (
<BaseCredentialsForm
@@ -29,6 +43,13 @@ export const UpdateViaRoleForm = ({
providerUid={providerUid}
onSubmit={handleUpdateCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

@@ -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 (
<BaseCredentialsForm
@@ -29,6 +43,13 @@ export const UpdateViaServiceAccountForm = ({
providerUid={providerUid}
onSubmit={handleUpdateCredentials}
successNavigationUrl={successNavigationUrl}
via={via}
onSuccess={onSuccess}
onBack={onBack}
formId={formId}
hideActions={hideActions}
onLoadingChange={onLoadingChange}
onValidityChange={onValidityChange}
submitButtonText="Next"
/>
);

View File

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

View File

@@ -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 (
<div className="flex flex-col gap-y-2">
<div className="flex gap-4">
{providerType && getProviderLogo(providerType as ProviderType)}
<span className="text-lg font-semibold">
{providerType
? getProviderName(providerType as ProviderType)
: "Unknown Provider"}
</span>
</div>
<div className="flex items-end gap-x-2">
<p className="text-default-500 text-sm">
{getProviderHelpText(providerType as string).text}
</p>
<CustomLink
href={getProviderHelpText(providerType as string).link}
size="sm"
className="text-nowrap"
>
Read the docs
</CustomLink>
</div>
<div className="flex gap-4">
{providerType && getProviderLogo(providerType as ProviderType)}
<span className="text-lg font-semibold">
{providerType
? getProviderName(providerType as ProviderType)
: "Unknown Provider"}
</span>
</div>
);
};

View File

@@ -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<HTMLButtonElement> {
/**
* 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 (
<svg
{...props}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<m.path
animate={{ pathLength: 1 }}
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
strokeLinecap="round"
strokeLinejoin="round"
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
/>
</svg>
);
}
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 (
<nav aria-label="Progress" className="max-w-fit">
<ol className={cn("flex flex-col gap-y-3", colors, className)}>
{steps?.map((step, stepIdx) => {
const status =
currentStep === stepIdx
? "active"
: currentStep < stepIdx
? "inactive"
: "complete";
return (
<li key={stepIdx} className="relative">
<div className="flex w-full max-w-full items-center">
<button
key={stepIdx}
ref={ref}
aria-current={status === "active" ? "step" : undefined}
className={cn(
"group rounded-large flex w-full cursor-pointer items-center justify-center gap-4 px-3 py-2.5",
stepClassName,
)}
onClick={() => setCurrentStep(stepIdx)}
{...props}
>
<div className="flex h-full items-center">
<LazyMotion features={domAnimation}>
<div className="relative">
<m.div
animate={status}
className={cn(
"border-medium text-large text-default-foreground relative flex h-[34px] w-[34px] items-center justify-center rounded-full font-semibold",
{
"shadow-lg": status === "complete",
},
)}
data-status={status}
initial={false}
transition={{ duration: 0.25 }}
variants={{
inactive: {
backgroundColor: "transparent",
borderColor: "var(--inactive-border-color)",
color: "var(--inactive-color)",
},
active: {
backgroundColor: "transparent",
borderColor: "var(--bg-button-primary)",
color: "var(--bg-button-primary)",
},
complete: {
backgroundColor: "var(--bg-button-primary)",
borderColor: "var(--bg-button-primary)",
},
}}
>
<div className="flex items-center justify-center">
{status === "complete" ? (
<CheckIcon className="h-6 w-6 text-(--active-fg-color)" />
) : (
<span>{stepIdx + 1}</span>
)}
</div>
</m.div>
</div>
</LazyMotion>
</div>
<div className="flex-1 text-left">
<div>
<div
className={cn(
"text-medium text-default-foreground font-medium transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.title}
</div>
<div
className={cn(
"text-tiny text-default-600 lg:text-small transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
},
)}
>
{step.description}
</div>
</div>
</div>
</button>
</div>
{stepIdx < steps.length - 1 && !hideProgressBars && (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-[calc(64px*var(--idx)+1)] left-3 flex h-1/2 -translate-y-1/3 items-center px-4",
)}
style={{
// @ts-expect-error
"--idx": stepIdx,
}}
>
<div
className={cn(
"relative h-full w-0.5 bg-(--inactive-bar-color) transition-colors duration-300",
"after:absolute after:block after:h-0 after:w-full after:bg-(--active-border-color) after:transition-[height] after:duration-300 after:content-['']",
{
"after:h-full": stepIdx < currentStep,
},
)}
/>
</div>
)}
</li>
);
})}
</ol>
</nav>
);
},
);
VerticalSteps.displayName = "VerticalSteps";

View File

@@ -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 (
<section className="max-w-sm">
<h1 className="mb-2 text-xl font-medium" id="getting-started">
Add a Cloud Provider
</h1>
<p className="text-small text-default-500 mb-5">
Complete these steps to configure your cloud provider and initiate your
first scan.
</p>
<Progress
classNames={{
base: "px-0.5 mb-5",
label: "text-small",
value: "text-small text-button-primary",
indicator: "bg-button-primary",
}}
label="Steps"
maxValue={steps.length - 1}
minValue={0}
showValueLabel={true}
size="md"
value={currentStep}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-border-neutral-primary aria-[current]:border-button-primary aria-[current]:text-text-neutral-primary cursor-default"
steps={updatedSteps}
/>
<Spacer y={4} />
</section>
);
};

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Cloud Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No cloud providers have been configured. Start by setting up a
cloud provider.
</p>
</div>
const [open, setOpen] = useState(false);
<Button
asChild
aria-label="Go to Add Cloud Provider page"
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href="/providers/connect-account">Get Started</Link>
</Button>
</CardContent>
</Card>
</div>
return (
<>
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Cloud Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No cloud providers have been configured. Start by setting up a
cloud provider.
</p>
</div>
<Button
aria-label="Open Add Cloud Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={() => setOpen(true)}
>
Get Started
</Button>
</CardContent>
</Card>
</div>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
</>
);
};

View File

@@ -21,6 +21,10 @@ type UseCredentialsFormProps = {
providerUid?: string;
onSubmit: (formData: FormData) => Promise<ApiResponse>;
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 || "",
};
};