diff --git a/ui/components/providers/organizations/aws-method-selector.tsx b/ui/components/providers/organizations/aws-method-selector.tsx index 787977f8e1..2e7d40566f 100644 --- a/ui/components/providers/organizations/aws-method-selector.tsx +++ b/ui/components/providers/organizations/aws-method-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { Ban, Box } from "lucide-react"; +import { Ban, Box, Boxes } from "lucide-react"; import { RadioCard } from "@/components/providers/radio-card"; @@ -28,7 +28,7 @@ export function AwsMethodSelector({ /> value, { + message: "You must confirm the StackSet deployment before continuing.", + }), }); type OrgSetupFormData = z.infer; @@ -43,22 +68,43 @@ interface OrgSetupFormProps { onBack: () => void; onNext: () => void; onFooterChange: (config: WizardFooterConfig) => void; + onPhaseChange: (phase: OrgSetupPhase) => void; } export function OrgSetupForm({ onBack, onNext, onFooterChange, + onPhaseChange, }: OrgSetupFormProps) { + const debugLog = (message: string, payload?: unknown) => { + if (process.env.NODE_ENV === "production") return; + + if (payload === undefined) { + console.error(`${DEBUG_SCOPE} ${message}`); + return; + } + + console.error(`${DEBUG_SCOPE} ${message}`, payload); + }; + const [apiError, setApiError] = useState(null); - const { setOrganization, setDiscovery } = useOrgSetupStore(); + const [isExternalIdCopied, setIsExternalIdCopied] = useState(false); + const [stackSetExternalId] = useState(() => generateStackSetExternalId()); + const [setupPhase, setSetupPhase] = useState( + ORG_SETUP_PHASE.DETAILS, + ); + const { setOrganization, setDiscovery, setSelectedAccountIds } = + useOrgSetupStore(); const formId = "org-wizard-setup-form"; const { + control, register, handleSubmit, formState: { errors, isSubmitting, isValid }, setError, + watch, } = useForm({ resolver: zodResolver(orgSetupSchema), mode: "onChange", @@ -67,68 +113,233 @@ export function OrgSetupForm({ organizationName: "", awsOrgId: "", roleArn: "", - externalId: "", + stackSetDeployed: false, }, }); + const awsOrgId = watch("awsOrgId") || ""; + const isOrgIdValid = /^o-[a-z0-9]{10,32}$/.test(awsOrgId.trim()); + const stackSetQuickLink = + getAWSCredentialsTemplateLinks(stackSetExternalId).cloudformationQuickLink; + useEffect(() => { + onPhaseChange(setupPhase); + }, [onPhaseChange, setupPhase]); + + useEffect(() => { + if (setupPhase === ORG_SETUP_PHASE.DETAILS) { + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: true, + actionLabel: "Next", + actionDisabled: !isOrgIdValid, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + return; + } + onFooterChange({ showBack: true, backLabel: "Back", backDisabled: isSubmitting, - onBack, + onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS), showAction: true, - actionLabel: "Next", + actionLabel: "Authenticate", actionDisabled: isSubmitting || !isValid, actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, actionFormId: formId, }); - }, [formId, isSubmitting, isValid, onBack, onFooterChange]); + }, [ + formId, + isOrgIdValid, + isSubmitting, + isValid, + onBack, + onFooterChange, + setupPhase, + ]); - const onSubmit = async (data: OrgSetupFormData) => { + const handleContinueToAccess = () => { 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"); + if (!isOrgIdValid) { + setError("awsOrgId", { + message: awsOrgId.trim() + ? "Must be a valid AWS Organization ID (e.g., o-abc123def4)" + : "Organization ID is required", + }); return; } - const orgId = orgResult.data.id; + setSetupPhase(ORG_SETUP_PHASE.ACCESS); + }; - // 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"); + const handleFormSubmit = (event: FormEvent) => { + if (setupPhase === ORG_SETUP_PHASE.DETAILS) { + event.preventDefault(); + handleContinueToAccess(); return; } - // Step 3: Trigger Discovery - const discoveryResult = await triggerDiscovery(orgId); + void handleSubmit(onSubmit)(event); + }; - if (discoveryResult?.error) { - setApiError(discoveryResult.error); - return; + const onSubmit = async (data: OrgSetupFormData) => { + try { + setApiError(null); + debugLog("Authenticate submit started", { + awsOrgId: data.awsOrgId, + hasRoleArn: Boolean(data.roleArn), + stackSetDeployed: data.stackSetDeployed, + }); + const resolvedOrganizationName = + data.organizationName?.trim() || data.awsOrgId; + + // Step 1: Create Organization + const orgFormData = new FormData(); + orgFormData.set("name", resolvedOrganizationName); + orgFormData.set("externalId", data.awsOrgId); + + const orgResult = await createOrganization(orgFormData); + debugLog("createOrganization response received", { + hasError: Boolean(orgResult?.error), + hasErrorsArray: Boolean(orgResult?.errors?.length), + }); + + if (orgResult?.error) { + handleServerError(orgResult, "Organization"); + return; + } + + const orgId = orgResult.data.id; + setOrganization(orgId, resolvedOrganizationName, data.awsOrgId); + + // Step 2: Create Organization Secret + const secretFormData = new FormData(); + secretFormData.set("organizationId", orgId); + secretFormData.set("roleArn", data.roleArn); + secretFormData.set("externalId", stackSetExternalId); + + const secretResult = await createOrganizationSecret(secretFormData); + debugLog("createOrganizationSecret response received", { + hasError: Boolean(secretResult?.error), + hasErrorsArray: Boolean(secretResult?.errors?.length), + }); + + if (secretResult?.error) { + handleServerError(secretResult, "Secret"); + return; + } + + // Step 3: Trigger Discovery + const discoveryResult = await triggerDiscovery(orgId); + debugLog("triggerDiscovery response received", { + hasError: Boolean(discoveryResult?.error), + discoveryId: discoveryResult?.data?.id ?? null, + }); + + if (discoveryResult?.error) { + setApiError(discoveryResult.error); + return; + } + + const discoveryId = discoveryResult.data.id; + + const resolvedDiscoveryResult = await pollDiscoveryResult( + orgId, + discoveryId, + ); + + if (!resolvedDiscoveryResult) { + debugLog("pollDiscoveryResult returned null (authentication failed)"); + return; + } + + const selectableAccountIds = getSelectableAccountIds( + resolvedDiscoveryResult, + ); + buildOrgTreeData(resolvedDiscoveryResult); + setDiscovery(discoveryId, resolvedDiscoveryResult); + setSelectedAccountIds(selectableAccountIds); + + // Discovery succeeded; advance to next wizard step. + debugLog("Authenticate flow succeeded, advancing to next step", { + discoveryId, + }); + onNext(); + } catch (error) { + console.error(`${DEBUG_SCOPE} Unexpected authenticate error`, error); + setApiError( + "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.", + ); + } + }; + + const pollDiscoveryResult = async ( + organizationId: string, + discoveryId: string, + ): Promise => { + for (let attempt = 0; attempt < DISCOVERY_MAX_RETRIES; attempt += 1) { + debugLog("Polling discovery status", { + attempt: attempt + 1, + maxAttempts: DISCOVERY_MAX_RETRIES, + organizationId, + discoveryId, + }); + const result = await getDiscovery(organizationId, discoveryId); + + if (result?.error) { + console.error(`${DEBUG_SCOPE} getDiscovery returned error`, { + attempt: attempt + 1, + error: result.error, + }); + setApiError( + `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${result.error}`, + ); + return null; + } + + const status = result.data.attributes.status; + debugLog("Discovery status response", { + attempt: attempt + 1, + status, + }); + + if (status === DISCOVERY_STATUS.SUCCEEDED) { + debugLog("Discovery succeeded"); + return result.data.attributes.result as DiscoveryResult; + } + + if (status === DISCOVERY_STATUS.FAILED) { + const backendError = result.data.attributes.error; + console.error(`${DEBUG_SCOPE} Discovery failed`, { + attempt: attempt + 1, + backendError, + }); + setApiError( + backendError + ? `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${backendError}` + : "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.", + ); + return null; + } + + await new Promise((resolve) => + setTimeout(resolve, DISCOVERY_POLL_INTERVAL_MS), + ); } - const discoveryId = discoveryResult.data.id; - - // Store in Zustand and advance - setOrganization(orgId, data.organizationName, data.awsOrgId); - setDiscovery(discoveryId, null as never); - onNext(); + setApiError( + "Authentication timed out. Please verify the credentials and try again.", + ); + console.error(`${DEBUG_SCOPE} Discovery polling timed out`, { + maxAttempts: DISCOVERY_MAX_RETRIES, + pollIntervalMs: DISCOVERY_POLL_INTERVAL_MS, + }); + return null; }; const handleServerError = ( @@ -138,12 +349,18 @@ export function OrgSetupForm({ }, context: string, ) => { + console.error(`${DEBUG_SCOPE} handleServerError`, { + context, + error: result.error ?? null, + errors: result.errors ?? [], + }); if (result.errors?.length) { for (const err of result.errors) { const pointer = err.source?.pointer ?? ""; - if (pointer.includes("external_id")) { + if (pointer.includes("external_id") && context === "Organization") { setError("awsOrgId", { message: err.detail }); + setApiError(err.detail); } else if (pointer.includes("name")) { setError("organizationName", { message: err.detail }); } else { @@ -155,92 +372,227 @@ export function OrgSetupForm({ } }; + useEffect(() => { + if (!apiError) return; + document + .getElementById(formId) + ?.scrollIntoView({ block: "start", behavior: "smooth" }); + }, [apiError, formId]); + return (
-
-

Organization Details

-

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

-
+ {setupPhase === ORG_SETUP_PHASE.DETAILS && ( +
+
+ +

+ Amazon Web Services (AWS) / Organization Details +

+
- {apiError && ( -
- {apiError} +

+ Enter the Organization ID for the accounts you want to add to + Prowler. +

)} -
-
- - - {errors.organizationName && ( - - {errors.organizationName.message} + {setupPhase === ORG_SETUP_PHASE.ACCESS && !isSubmitting && ( +
+
+ +

+ Amazon Web Services (AWS) / Authentication Details +

+
+
+ )} + + {setupPhase === ORG_SETUP_PHASE.ACCESS && isSubmitting && ( +
+
+ +

Gathering AWS Accounts...

+
+
+ )} + + {apiError && ( + + + {apiError} + + + )} + + {setupPhase === ORG_SETUP_PHASE.DETAILS && ( +
+
+ + + {errors.awsOrgId && ( + + {errors.awsOrgId.message} + + )} +
+ +
+ + + {errors.organizationName && ( + + {errors.organizationName.message} + + )} +
+ +

+ If left blank, Prowler will use the Organization name stored in AWS. +

+
+ )} + + {setupPhase === ORG_SETUP_PHASE.ACCESS && !isSubmitting && ( +
+
+

+ 1) Launch the Prowler CloudFormation StackSet in your AWS Console. +

+ +
+ +
+

+ 2) Use the following Prowler External ID parameter in the + StackSet. +

+
+ + External ID: + +
+ + {stackSetExternalId} + + +
+
+
+ +
+

+ 3) Copy the Prowler IAM Role ARN from AWS and confirm the StackSet + is successfully deployed by clicking the checkbox below. +

+
+ +
+ + + {errors.roleArn && ( + + {errors.roleArn.message} + + )} +
+ +

+ * It may take up to 60 seconds for AWS to generate the IAM Role ARN +

+ +
+ ( + <> + + field.onChange(Boolean(checked)) + } + /> + + + )} + /> +
+ {errors.stackSetDeployed && ( + + {errors.stackSetDeployed.message} )}
+ )} -
- - - {errors.awsOrgId && ( - - {errors.awsOrgId.message} - - )} -
- -
- - - {errors.roleArn && ( - - {errors.roleArn.message} - - )} -
- -
- - - {errors.externalId && ( - - {errors.externalId.message} - - )} -
-
- - {isSubmitting && ( + {setupPhase === ORG_SETUP_PHASE.DETAILS && isSubmitting && (
Setting up organization... @@ -249,3 +601,16 @@ export function OrgSetupForm({ ); } + +function generateStackSetExternalId() { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const parts = [3, 4, 4, 3, 6, 6]; + + return parts + .map((length) => + Array.from({ length }, () => + characters.charAt(Math.floor(Math.random() * characters.length)), + ).join(""), + ) + .join("-"); +} diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 635adda8e0..12da1b5eb8 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -12,10 +12,16 @@ import { OrgSetupForm } from "@/components/providers/organizations/org-setup-for 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 { getProviderHelpText } from "@/lib"; import { useOrgSetupStore } from "@/store/organizations/store"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { ORG_WIZARD_STEP, OrgWizardStep } from "@/types/organizations"; +import { + ORG_SETUP_PHASE, + ORG_WIZARD_STEP, + OrgSetupPhase, + OrgWizardStep, +} from "@/types/organizations"; import { PROVIDER_WIZARD_MODE, PROVIDER_WIZARD_STEP, @@ -24,6 +30,7 @@ import { } from "@/types/provider-wizard"; import { ProviderType } from "@/types/providers"; +import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils"; import { ConnectStep } from "./steps/connect-step"; import { CredentialsStep } from "./steps/credentials-step"; import { @@ -96,6 +103,14 @@ export function ProviderWizardModal({ const [providerTypeHint, setProviderTypeHint] = useState( null, ); + const [orgSetupPhase, setOrgSetupPhase] = useState( + ORG_SETUP_PHASE.DETAILS, + ); + const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}-${validatePhase}`; + const { containerRef, showScrollHint, handleScroll } = useScrollHint({ + enabled: open, + refreshToken: scrollHintRefreshToken, + }); const { reset: resetProviderWizard, @@ -138,6 +153,7 @@ export function ProviderWizardModal({ setValidatePhase(VALIDATE_PHASE.DISCOVERY); setFooterConfig(EMPTY_FOOTER_CONFIG); setProviderTypeHint(initialData.providerType); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); return; } @@ -149,6 +165,7 @@ export function ProviderWizardModal({ setValidatePhase(VALIDATE_PHASE.DISCOVERY); setFooterConfig(EMPTY_FOOTER_CONFIG); setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }, [ initialData, open, @@ -169,6 +186,7 @@ export function ProviderWizardModal({ setValidatePhase(VALIDATE_PHASE.DISCOVERY); setFooterConfig(EMPTY_FOOTER_CONFIG); setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); onOpenChange(false); }; @@ -196,6 +214,7 @@ export function ProviderWizardModal({ setValidatePhase(VALIDATE_PHASE.DISCOVERY); setFooterConfig(EMPTY_FOOTER_CONFIG); setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }; const backToProviderFlow = () => { @@ -204,6 +223,7 @@ export function ProviderWizardModal({ setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); setFooterConfig(EMPTY_FOOTER_CONFIG); setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }; const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER; @@ -240,6 +260,7 @@ export function ProviderWizardModal({ backLabel: "Back", onBack: () => { setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }, showAction: false, actionLabel: "Next", @@ -278,15 +299,22 @@ export function ProviderWizardModal({ ) : ( )}
-
-
+
+
{isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS)} @@ -324,9 +352,10 @@ export function ProviderWizardModal({ onBack={backToProviderFlow} onNext={() => { setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); - setValidatePhase(VALIDATE_PHASE.DISCOVERY); + setValidatePhase(VALIDATE_PHASE.SELECTION); }} onFooterChange={setFooterConfig} + onPhaseChange={setOrgSetupPhase} /> )} @@ -346,6 +375,7 @@ export function ProviderWizardModal({ { setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }} onNext={() => { setValidatePhase(VALIDATE_PHASE.TESTING); @@ -382,6 +412,17 @@ export function ProviderWizardModal({ /> )}
+ + {showScrollHint && ( +
+
+
+ + Scroll to see more + +
+
+ )}
diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts new file mode 100644 index 0000000000..810da48446 --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; + +import { getOrganizationsStepperOffset } 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); + }); +}); diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.ts new file mode 100644 index 0000000000..a907bb66a7 --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.ts @@ -0,0 +1,17 @@ +import { + ORG_SETUP_PHASE, + ORG_WIZARD_STEP, + OrgSetupPhase, + OrgWizardStep, +} from "@/types/organizations"; + +export function getOrganizationsStepperOffset( + currentStep: OrgWizardStep, + setupPhase: OrgSetupPhase, +) { + if (currentStep === ORG_WIZARD_STEP.SETUP) { + return setupPhase === ORG_SETUP_PHASE.ACCESS ? 1 : 0; + } + + return 1; +} diff --git a/ui/types/organizations.ts b/ui/types/organizations.ts index c680f47b01..7d9d73e7f0 100644 --- a/ui/types/organizations.ts +++ b/ui/types/organizations.ts @@ -52,6 +52,14 @@ export const ORG_WIZARD_STEP = { export type OrgWizardStep = (typeof ORG_WIZARD_STEP)[keyof typeof ORG_WIZARD_STEP]; +export const ORG_SETUP_PHASE = { + DETAILS: "details", + ACCESS: "access", +} as const; + +export type OrgSetupPhase = + (typeof ORG_SETUP_PHASE)[keyof typeof ORG_SETUP_PHASE]; + // ─── Discovery Result Interfaces ────────────────────────────────────────────── export interface AccountRegistration {