mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user