diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx new file mode 100644 index 0000000000..32efdd6ad7 --- /dev/null +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -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; + +interface OrgSetupFormProps { + onBack: () => void; + onNext: () => void; +} + +export function OrgSetupForm({ onBack, onNext }: OrgSetupFormProps) { + const [apiError, setApiError] = useState(null); + const { setOrganization, setDiscovery } = useOrgSetupStore(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + } = useForm({ + 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 ( +
+
+

Organization Details

+

+ Enter your AWS Organization details to discover all accounts. +

+
+ + {apiError && ( +
+ {apiError} +
+ )} + +
+
+ + + {errors.organizationName && ( + + {errors.organizationName.message} + + )} +
+ +
+ + + {errors.awsOrgId && ( + + {errors.awsOrgId.message} + + )} +
+ +
+ + + {errors.roleArn && ( + + {errors.roleArn.message} + + )} +
+ +
+ + + {errors.externalId && ( + + {errors.externalId.message} + + )} +
+
+ +
+ + +
+
+ ); +} diff --git a/ui/components/providers/organizations/org-wizard-modal.tsx b/ui/components/providers/organizations/org-wizard-modal.tsx new file mode 100644 index 0000000000..cfb0544b0c --- /dev/null +++ b/ui/components/providers/organizations/org-wizard-modal.tsx @@ -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( + ORG_WIZARD_STEP.SETUP, + ); + const [validatePhase, setValidatePhase] = useState( + 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 ( + + + +
+ + Adding A Cloud Provider + + + Learn more + + +
+
+ +
+ {/* Left: Stepper */} +
+ +
+ + {/* Right: Step content */} +
+ {currentStep === ORG_WIZARD_STEP.SETUP && ( + + )} + + {currentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.DISCOVERY && ( + + )} + + {currentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.SELECTION && ( + + )} + + {currentStep === ORG_WIZARD_STEP.VALIDATE && + validatePhase === VALIDATE_PHASE.TESTING && ( + + )} + + {currentStep === ORG_WIZARD_STEP.LAUNCH && ( + + )} +
+
+
+
+ ); +} diff --git a/ui/components/providers/organizations/org-wizard-stepper.tsx b/ui/components/providers/organizations/org-wizard-stepper.tsx new file mode 100644 index 0000000000..aaba81eefa --- /dev/null +++ b/ui/components/providers/organizations/org-wizard-stepper.tsx @@ -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 ( + + ); +} + +interface StepCircleProps { + isComplete: boolean; + isActive: boolean; + isError: boolean; + icon: IconComponent; +} + +function StepCircle({ + isComplete, + isActive, + isError, + icon: Icon, +}: StepCircleProps) { + if (isError) { + return ( +
+ +
+ ); + } + + const borderColor = + isComplete || isActive ? "border-primary" : "border-[#202020]"; + const iconColor = isComplete || isActive ? "text-primary" : "text-[#525252]"; + + return ( +
+ +
+ ); +} + +/** + * 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 ; + } + return ; +} + +/** + * Type guard: Lucide icons have a `displayName` property set by lucide-react. + * Custom SVG icons (React.FC) accept a `size` number prop. + */ +function isCustomSvgIcon(icon: IconComponent): icon is React.FC { + return !("displayName" in icon && typeof icon.displayName === "string"); +}