feat(ui): add Organizations wizard modal with stepper

Add multi-step modal wizard for AWS Organizations onboarding. Includes
OrgWizardStepper with visual step circles and connectors, OrgSetupForm
with Zod validation for org details, and OrgWizardModal orchestrating
the Setup → Validate → Launch flow.
This commit is contained in:
alejandrobailo
2026-02-17 09:46:22 +01:00
parent d3b0c3c393
commit 689d044d92
3 changed files with 560 additions and 0 deletions
@@ -0,0 +1,230 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
createOrganization,
createOrganizationSecret,
triggerDiscovery,
} from "@/actions/organizations/organizations";
import { Button } from "@/components/shadcn";
import { Input } from "@/components/shadcn/input/input";
import { useOrgSetupStore } from "@/store/organizations/store";
const orgSetupSchema = z.object({
organizationName: z
.string()
.min(3, "Organization name must be at least 3 characters"),
awsOrgId: z
.string()
.regex(
/^o-[a-z0-9]{10,32}$/,
"Must be a valid AWS Organization ID (e.g., o-abc123def4)",
),
roleArn: z
.string()
.regex(
/^arn:aws:iam::\d{12}:role\//,
"Must be a valid IAM Role ARN (e.g., arn:aws:iam::123456789012:role/ProwlerOrgRole)",
),
externalId: z.string().min(1, "External ID is required"),
});
type OrgSetupFormData = z.infer<typeof orgSetupSchema>;
interface OrgSetupFormProps {
onBack: () => void;
onNext: () => void;
}
export function OrgSetupForm({ onBack, onNext }: OrgSetupFormProps) {
const [apiError, setApiError] = useState<string | null>(null);
const { setOrganization, setDiscovery } = useOrgSetupStore();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<OrgSetupFormData>({
resolver: zodResolver(orgSetupSchema),
defaultValues: {
organizationName: "",
awsOrgId: "",
roleArn: "",
externalId: "",
},
});
const onSubmit = async (data: OrgSetupFormData) => {
setApiError(null);
// Step 1: Create Organization
const orgFormData = new FormData();
orgFormData.set("name", data.organizationName);
orgFormData.set("externalId", data.awsOrgId);
const orgResult = await createOrganization(orgFormData);
if (orgResult?.error) {
handleServerError(orgResult, "Organization");
return;
}
const orgId = orgResult.data.id;
// Step 2: Create Organization Secret
const secretFormData = new FormData();
secretFormData.set("organizationId", orgId);
secretFormData.set("roleArn", data.roleArn);
secretFormData.set("externalId", data.externalId);
const secretResult = await createOrganizationSecret(secretFormData);
if (secretResult?.error) {
handleServerError(secretResult, "Secret");
return;
}
// Step 3: Trigger Discovery
const discoveryResult = await triggerDiscovery(orgId);
if (discoveryResult?.error) {
setApiError(discoveryResult.error);
return;
}
const discoveryId = discoveryResult.data.id;
// Store in Zustand and advance
setOrganization(orgId, data.organizationName, data.awsOrgId);
setDiscovery(discoveryId, null as never);
onNext();
};
const handleServerError = (
result: {
error?: string;
errors?: Array<{ detail: string; source?: { pointer: string } }>;
},
context: string,
) => {
if (result.errors?.length) {
for (const err of result.errors) {
const pointer = err.source?.pointer ?? "";
if (pointer.includes("external_id")) {
setError("awsOrgId", { message: err.detail });
} else if (pointer.includes("name")) {
setError("organizationName", { message: err.detail });
} else {
setApiError(err.detail);
}
}
} else {
setApiError(result.error ?? `Failed to create ${context}`);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold">Organization Details</h3>
<p className="text-muted-foreground text-sm">
Enter your AWS Organization details to discover all accounts.
</p>
</div>
{apiError && (
<div className="border-destructive/50 bg-destructive/10 text-destructive rounded-md border px-4 py-3 text-sm">
{apiError}
</div>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="organizationName" className="text-sm font-medium">
Organization Name
</label>
<Input
id="organizationName"
placeholder="e.g. My AWS Organization"
{...register("organizationName")}
/>
{errors.organizationName && (
<span className="text-destructive text-xs">
{errors.organizationName.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="awsOrgId" className="text-sm font-medium">
AWS Organization ID
</label>
<Input
id="awsOrgId"
placeholder="e.g. o-abc123def4"
{...register("awsOrgId")}
/>
{errors.awsOrgId && (
<span className="text-destructive text-xs">
{errors.awsOrgId.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="roleArn" className="text-sm font-medium">
Role ARN
</label>
<Input
id="roleArn"
placeholder="e.g. arn:aws:iam::123456789012:role/ProwlerOrgRole"
{...register("roleArn")}
/>
{errors.roleArn && (
<span className="text-destructive text-xs">
{errors.roleArn.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="externalId" className="text-sm font-medium">
External ID
</label>
<Input
id="externalId"
placeholder="Enter the external ID for role assumption"
{...register("externalId")}
/>
{errors.externalId && (
<span className="text-destructive text-xs">
{errors.externalId.message}
</span>
)}
</div>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
>
Back
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 size-4 animate-spin" />}
{isSubmitting ? "Setting up..." : "Next"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,157 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/shadcn/dialog";
import { useOrgSetupStore } from "@/store/organizations/store";
import { ORG_WIZARD_STEP, OrgWizardStep } from "@/types/organizations";
import { OrgAccountSelection } from "./org-account-selection";
import { OrgConnectionTest } from "./org-connection-test";
import { OrgDiscoveryLoader } from "./org-discovery-loader";
import { OrgLaunchScan } from "./org-launch-scan";
import { OrgSetupForm } from "./org-setup-form";
import { OrgWizardStepper } from "./org-wizard-stepper";
const VALIDATE_PHASE = {
DISCOVERY: "discovery",
SELECTION: "selection",
TESTING: "testing",
} as const;
type ValidatePhase = (typeof VALIDATE_PHASE)[keyof typeof VALIDATE_PHASE];
interface OrgWizardModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function OrgWizardModal({ open, onOpenChange }: OrgWizardModalProps) {
const [currentStep, setCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [validatePhase, setValidatePhase] = useState<ValidatePhase>(
VALIDATE_PHASE.DISCOVERY,
);
const { connectionResults, reset } = useOrgSetupStore();
const hasConnectionErrors = Object.values(connectionResults).some(
(s) => s === "error",
);
const handleClose = () => {
reset();
setCurrentStep(ORG_WIZARD_STEP.SETUP);
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
onOpenChange(false);
};
const handleSetupBack = () => {
handleClose();
};
const handleSetupNext = () => {
setCurrentStep(ORG_WIZARD_STEP.VALIDATE);
setValidatePhase(VALIDATE_PHASE.DISCOVERY);
};
const handleDiscoveryComplete = () => {
setValidatePhase(VALIDATE_PHASE.SELECTION);
};
const handleSelectionBack = () => {
// Go back to setup form
setCurrentStep(ORG_WIZARD_STEP.SETUP);
};
const handleSelectionNext = () => {
setValidatePhase(VALIDATE_PHASE.TESTING);
};
const handleTestingBack = () => {
setValidatePhase(VALIDATE_PHASE.SELECTION);
};
const handleTestingNext = () => {
setCurrentStep(ORG_WIZARD_STEP.LAUNCH);
};
const handleSkipValidation = () => {
setCurrentStep(ORG_WIZARD_STEP.LAUNCH);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0">
<DialogHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">
Adding A Cloud Provider
</DialogTitle>
<a
href="https://docs.prowler.com"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
>
Learn more
<ExternalLink className="size-3" />
</a>
</div>
</DialogHeader>
<div className="flex min-h-[500px]">
{/* Left: Stepper */}
<div className="w-72 shrink-0 border-r p-5">
<OrgWizardStepper
currentStep={currentStep}
hasConnectionErrors={hasConnectionErrors}
/>
</div>
{/* Right: Step content */}
<div className="flex-1 overflow-y-auto p-6">
{currentStep === ORG_WIZARD_STEP.SETUP && (
<OrgSetupForm onBack={handleSetupBack} onNext={handleSetupNext} />
)}
{currentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.DISCOVERY && (
<OrgDiscoveryLoader
onDiscoveryComplete={handleDiscoveryComplete}
/>
)}
{currentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.SELECTION && (
<OrgAccountSelection
onBack={handleSelectionBack}
onNext={handleSelectionNext}
/>
)}
{currentStep === ORG_WIZARD_STEP.VALIDATE &&
validatePhase === VALIDATE_PHASE.TESTING && (
<OrgConnectionTest
onBack={handleTestingBack}
onNext={handleTestingNext}
onSkip={handleSkipValidation}
/>
)}
{currentStep === ORG_WIZARD_STEP.LAUNCH && (
<OrgLaunchScan onClose={handleClose} />
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,173 @@
"use client";
import { AlertCircle, FolderGit2, KeyRound, Shield } from "lucide-react";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
import { IconComponent, IconSvgProps } from "@/types/components";
import { OrgWizardStep } from "@/types/organizations";
interface OrgWizardStepperProps {
currentStep: OrgWizardStep;
hasConnectionErrors: boolean;
}
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: Shield,
},
{
label: "Launch Scan",
description: "Scan newly connected resources.",
icon: ProwlerShort,
},
];
/**
* Maps modal internal step (0=Setup, 1=Validate, 2=Launch) to the
* visual stepper index (0-3). Step 0 in the stepper is always complete.
*/
function getVisualStepIndex(modalStep: OrgWizardStep): number {
return modalStep + 1;
}
export function OrgWizardStepper({
currentStep,
hasConnectionErrors,
}: OrgWizardStepperProps) {
const activeVisualStep = getVisualStepIndex(currentStep);
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;
const isErrorStep = index === 2 && hasConnectionErrors && isActive;
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}
isError={isErrorStep}
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",
isErrorStep && "text-destructive",
)}
>
{step.label}
</span>
<p className="text-xs leading-5 text-[#d4d4d8]">
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
}
interface StepCircleProps {
isComplete: boolean;
isActive: boolean;
isError: boolean;
icon: IconComponent;
}
function StepCircle({
isComplete,
isActive,
isError,
icon: Icon,
}: StepCircleProps) {
if (isError) {
return (
<div className="border-destructive flex size-[44px] shrink-0 items-center justify-center rounded-full border bg-[#121110]">
<AlertCircle className="text-destructive size-6" />
</div>
);
}
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");
}