feat(ui): integrate Organizations flow into provider add workflow

Add AWS method selector step between provider type and UID form in
ConnectAccountForm. Replace HeroUI VerticalSteps/Progress with custom
icon-based stepper in WorkflowAddProvider. Remove HeroUI Spacer import
from layout in favor of Tailwind utility class.
This commit is contained in:
alejandrobailo committed 2026-02-17 09:46:37 +01:00
1 parent 83bceab584
commit ea5ab2429c
4 files changed
+193 -114

No files matched your search

@@ -1,6 +1,5 @@
import "@/styles/globals.css";
import { Spacer } from "@heroui/spacer";
import React from "react";
import { WorkflowAddProvider } from "@/components/providers/workflow";
@@ -18,7 +17,7 @@ export default function ProviderLayout({ children }: ProviderLayoutProps) {
icon="icon-park-outline:close-small"
href="/providers"
/>
<Spacer y={8} />
<div className="mt-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 />
@@ -8,6 +8,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { addProvider } from "@/actions/providers/providers";
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
import { OrgWizardModal } from "@/components/providers/organizations/org-wizard-modal";
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
@@ -83,6 +85,8 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
export const ConnectAccountForm = () => {
const { toast } = useToast();
const [prevStep, setPrevStep] = useState(1);
const [awsMethod, setAwsMethod] = useState<"single" | null>(null);
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
const router = useRouter();
const formSchema = addProviderFormSchema;
@@ -170,10 +174,19 @@ export const ConnectAccountForm = () => {
};
const handleBackStep = () => {
// 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
// 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", "");
@@ -200,35 +213,50 @@ 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
<AwsMethodSelector
onSelectSingle={() => setAwsMethod("single")}
onSelectOrganizations={() => setIsOrgModalOpen(true)}
/>
<CustomInput
control={form.control}
name="providerAlias"
type="text"
label="Provider alias (optional)"
labelPlacement="inside"
placeholder="Enter the provider alias"
variant="bordered"
isRequired={false}
<OrgWizardModal
open={isOrgModalOpen}
onOpenChange={setIsOrgModalOpen}
/>
</>
)}
{/* 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}
/>
</>
)}
{/* Navigation buttons */}
<div className="flex w-full justify-end gap-4">
{/* Show "Back" button only in Step 2 */}
{/* Show "Back" button in Step 2 (skip when on AWS method selector) */}
{prevStep === 2 && (
<Button
type="button"
@@ -241,22 +269,23 @@ export const ConnectAccountForm = () => {
Back
</Button>
)}
{/* 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} />
)}
{isLoading ? "Loading" : "Next"}
</Button>
)}
{/* Show "Next" button in Step 2 (only when UID form is visible) */}
{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>
)}
</div>
</form>
</Form>
@@ -1,5 +1,4 @@
export * from "./credentials-role-helper";
export * from "./provider-title-docs";
export * from "./skeleton-provider-workflow";
export * from "./vertical-steps";
export * from "./workflow-add-provider";
@@ -1,97 +1,149 @@
"use client";
import { Progress } from "@heroui/progress";
import { Spacer } from "@heroui/spacer";
import { FolderGit2, KeyRound, Shield } from "lucide-react";
import { usePathname } from "next/navigation";
import React from "react";
import { VerticalSteps } from "./vertical-steps";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
import { IconComponent, IconSvgProps } from "@/types/components";
const steps = [
interface StepConfig {
label: string;
description: string;
icon: IconComponent;
}
const STEPS: StepConfig[] = [
{
title: "Choose your Cloud Provider",
label: "Link a 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",
"Enter the provider details you would like to add in Prowler.",
icon: FolderGit2,
},
{
title: "Enter Authentication Details",
label: "Authenticate Credentials",
description:
"Provide the necessary credentials to establish a secure connection to your selected cloud provider.",
href: "/providers/add-credentials",
"Authorize a secure connection between Prowler and your provider.",
icon: KeyRound,
},
{
title: "Verify Connection & Start Scan",
label: "Validate Connection",
description:
"Ensure your credentials are correct and start scanning your cloud environment.",
href: "/providers/test-connection",
"Review provider resources and test the connection to Prowler.",
icon: Shield,
},
{
label: "Launch Scan",
description: "Scan newly connected resources.",
icon: ProwlerShort,
},
];
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",
},
},
const ROUTE_TO_STEP: Record<string, number> = {
"/providers/connect-account": 0,
"/providers/add-credentials": 1,
"/providers/test-connection": 2,
"/providers/update-credentials": 1,
};
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;
});
const currentStep = ROUTE_TO_STEP[pathname] ?? 0;
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>
<nav aria-label="Progress" className="flex max-w-sm flex-col gap-0">
{STEPS.map((step, index) => {
const isComplete = index < currentStep;
const isActive = index === currentStep;
const isInactive = index > currentStep;
return (
<div key={step.label} className="flex items-start gap-3">
{/* Step indicator + connector line */}
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{/* Connector line */}
{index < STEPS.length - 1 && (
<div
className={cn(
"h-14 w-0 border-l border-dashed",
isComplete ? "border-primary/40" : "border-[#202020]",
)}
/>
)}
</div>
{/* Label + description */}
<div className="flex flex-col gap-1 pt-[10px]">
<span
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-[#f4f4f5]",
isComplete && "text-[#f4f4f5]",
isInactive && "text-[#f4f4f5]/60",
)}
>
{step.label}
</span>
<p className="text-xs leading-5 text-[#d4d4d8]">
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
};
interface StepCircleProps {
isComplete: boolean;
isActive: boolean;
icon: IconComponent;
}
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
const borderColor =
isComplete || isActive ? "border-primary" : "border-[#202020]";
const iconColor = isComplete || isActive ? "text-primary" : "text-[#525252]";
return (
<div
className={cn(
"flex size-[44px] shrink-0 items-center justify-center rounded-full border bg-[#121110]",
borderColor,
)}
>
<StepIcon icon={Icon} className={iconColor} />
</div>
);
}
/**
* Renders either a Lucide icon (className-based) or a custom SVG icon
* (size-prop-based like ProwlerShort) with consistent sizing.
*/
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)} />;
}
/**
* Type guard: Lucide icons have a `displayName` property set by lucide-react.
* Custom SVG icons (React.FC<IconSvgProps>) accept a `size` number prop.
*/
function isCustomSvgIcon(icon: IconComponent): icon is React.FC<IconSvgProps> {
return !("displayName" in icon && typeof icon.displayName === "string");
}