{resolvedFooterConfig.showBack && (
diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts
index 6e2b090fc9..5d19ae9d16 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts
+++ b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts
@@ -12,8 +12,81 @@ import {
getOrganizationsStepperOffset,
getProviderWizardDocsDestination,
getProviderWizardModalTitle,
+ getProviderWizardStepper,
} from "./provider-wizard-modal.utils";
+describe("getProviderWizardStepper", () => {
+ const labels = (steps: { label: string }[]) => steps.map((s) => s.label);
+
+ it("lists the four generic steps until a provider is picked", () => {
+ const stepper = getProviderWizardStepper({
+ mode: PROVIDER_WIZARD_MODE.ADD,
+ providerType: null,
+ currentStep: PROVIDER_WIZARD_STEP.CONNECT,
+ });
+
+ expect(labels(stepper.steps)).toEqual([
+ "Link a Provider",
+ "Authenticate Credentials",
+ "Validate Connection",
+ "Launch Scan",
+ ]);
+ expect(stepper.stepOffset).toBe(0);
+ });
+
+ it("folds the credentials step into the first one when adding an AWS account", () => {
+ const stepper = getProviderWizardStepper({
+ mode: PROVIDER_WIZARD_MODE.ADD,
+ providerType: "aws",
+ currentStep: PROVIDER_WIZARD_STEP.CONNECT,
+ });
+
+ expect(labels(stepper.steps)).toEqual([
+ "Link a Provider",
+ "Validate Connection",
+ "Launch Scan",
+ ]);
+ expect(stepper.stepOffset).toBe(0);
+ });
+
+ it("keeps the AWS stepper in sync once the wizard skips to the connection test", () => {
+ const stepper = getProviderWizardStepper({
+ mode: PROVIDER_WIZARD_MODE.ADD,
+ providerType: "aws",
+ currentStep: PROVIDER_WIZARD_STEP.TEST,
+ });
+
+ // TEST is index 2 in the wizard but the second row of the AWS stepper.
+ expect(stepper.stepOffset).toBe(-1);
+ });
+
+ it("keeps the first AWS row active if the wizard ever lands on the credentials step", () => {
+ const stepper = getProviderWizardStepper({
+ mode: PROVIDER_WIZARD_MODE.ADD,
+ providerType: "aws",
+ currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS,
+ });
+
+ // CREDENTIALS has no row of its own for AWS: it folds into "Link a Provider".
+ expect(stepper.stepOffset).toBe(-1);
+ });
+
+ it("still shows the credentials step when updating AWS credentials", () => {
+ const stepper = getProviderWizardStepper({
+ mode: PROVIDER_WIZARD_MODE.UPDATE,
+ providerType: "aws",
+ currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS,
+ });
+
+ expect(labels(stepper.steps)).toEqual([
+ "Link a Provider",
+ "Authenticate Credentials",
+ "Validate Connection",
+ ]);
+ expect(stepper.stepOffset).toBe(0);
+ });
+});
+
describe("getOrganizationsStepperOffset", () => {
it("keeps step 1 active during organization details", () => {
const offset = getOrganizationsStepperOffset(
diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.ts
index 1e8f6f9248..a320521c7e 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.utils.ts
+++ b/ui/components/providers/wizard/provider-wizard-modal.utils.ts
@@ -6,8 +6,61 @@ import {
} from "@/types/organizations";
import {
PROVIDER_WIZARD_MODE,
+ PROVIDER_WIZARD_STEP,
ProviderWizardMode,
+ ProviderWizardStep,
} from "@/types/provider-wizard";
+import type { ProviderType } from "@/types/providers";
+
+import {
+ AWS_PROVIDER_WIZARD_STEPS,
+ PROVIDER_WIZARD_STEPS,
+} from "./wizard-stepper";
+
+const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice(
+ 0,
+ PROVIDER_WIZARD_STEP.LAUNCH,
+);
+
+interface ProviderWizardStepperInput {
+ mode: ProviderWizardMode;
+ providerType: ProviderType | null;
+ currentStep: ProviderWizardStep;
+}
+
+/** Rows for the provider-flow stepper plus the offset that maps `currentStep` onto them. */
+export function getProviderWizardStepper({
+ mode,
+ providerType,
+ currentStep,
+}: ProviderWizardStepperInput) {
+ if (mode === PROVIDER_WIZARD_MODE.UPDATE) {
+ return { steps: UPDATE_MODE_WIZARD_STEPS, stepOffset: 0 };
+ }
+ if (providerType === "aws") {
+ // CONNECT stays on the first row; every later step shifts up one, so
+ // CREDENTIALS (no row of its own) folds into the first one too.
+ const stepOffset = currentStep === PROVIDER_WIZARD_STEP.CONNECT ? 0 : -1;
+ return { steps: AWS_PROVIDER_WIZARD_STEPS, stepOffset };
+ }
+ return { steps: PROVIDER_WIZARD_STEPS, stepOffset: 0 };
+}
+
+interface CredentialsRetryStepInput {
+ mode: ProviderWizardMode;
+ providerType: ProviderType | null;
+}
+
+/** Where "Back" from the connection test lands: AWS re-enters its one-step form. */
+export function getCredentialsRetryStep({
+ mode,
+ providerType,
+}: CredentialsRetryStepInput): ProviderWizardStep {
+ if (mode === PROVIDER_WIZARD_MODE.ADD && providerType === "aws") {
+ return PROVIDER_WIZARD_STEP.CONNECT;
+ }
+ return PROVIDER_WIZARD_STEP.CREDENTIALS;
+}
export function getOrganizationsStepperOffset(
currentStep: OrgWizardStep,
diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx
new file mode 100644
index 0000000000..47475b471e
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx
@@ -0,0 +1,472 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ PROVIDER_FUNNEL_EVENT,
+ type ProviderFunnelDetail,
+} from "@/lib/provider-funnel/provider-funnel-events";
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+
+import { AwsConnectStep } from "./aws-connect-step";
+import type { AwsConnectUiState } from "./types";
+
+const { addProvider, addCredentialsProvider, openCloudUpgradeMock } =
+ vi.hoisted(() => ({
+ addProvider: vi.fn(),
+ addCredentialsProvider: vi.fn(),
+ openCloudUpgradeMock: vi.fn(),
+ }));
+
+vi.mock("next-auth/react", () => ({
+ useSession: () => ({
+ data: { tenantId: "tenant-abc" },
+ status: "authenticated",
+ }),
+}));
+vi.mock("@/actions/providers/providers", () => ({
+ addProvider,
+ addCredentialsProvider,
+}));
+vi.mock("@/store", () => ({
+ useCloudUpgradeStore: (
+ selector: (state: {
+ openCloudUpgrade: typeof openCloudUpgradeMock;
+ }) => unknown,
+ ) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
+}));
+
+const FORM_ID = "aws-connect-test-form";
+const ROLE_ARN = "arn:aws:iam::123456789012:role/ProwlerScan";
+
+// Stands in for the wizard footer: the step only publishes its UI state.
+function Harness({
+ onConnected,
+ onSelectOrganizations,
+}: {
+ onConnected: () => void;
+ onSelectOrganizations: () => void;
+}) {
+ const [uiState, setUiState] = useState
(null);
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function renderStep() {
+ const onConnected = vi.fn();
+ const onSelectOrganizations = vi.fn();
+ render(
+ ,
+ );
+ return { onConnected, onSelectOrganizations, user: userEvent.setup() };
+}
+
+const connectButton = () =>
+ screen.getByRole("button", { name: "Connect account" });
+
+describe("AwsConnectStep", () => {
+ const funnelSignals: ProviderFunnelDetail[] = [];
+ const recordFunnelSignal: EventListener = (event) => {
+ funnelSignals.push((event as CustomEvent).detail);
+ };
+
+ beforeEach(() => {
+ funnelSignals.length = 0;
+ window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ useProviderWizardStore.getState().reset();
+ addProvider.mockResolvedValue({ data: { id: "provider-1" } });
+ addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } });
+ });
+
+ afterEach(() => {
+ window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ vi.unstubAllEnvs();
+ });
+
+ describe("in Prowler Cloud", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ });
+
+ it("creates the role from the shared stack and connects with just its ARN", async () => {
+ // Given
+ const { onConnected, user } = renderStep();
+
+ // Then: the button opens the shared template with the External ID filled in;
+ // the AccountId parameter defaults to Prowler Cloud's account there.
+ const quickCreate = screen.getByRole("link", {
+ name: /Create the IAM role in AWS/i,
+ });
+ expect(quickCreate).toHaveAttribute(
+ "href",
+ expect.stringContaining("prowler-scan-role.yml"),
+ );
+ expect(quickCreate).toHaveAttribute(
+ "href",
+ expect.stringContaining("param_ExternalId=tenant-abc"),
+ );
+ expect(connectButton()).toBeDisabled();
+
+ // When
+ await user.type(
+ screen.getByRole("textbox", { name: /Role ARN/ }),
+ ROLE_ARN,
+ );
+
+ // Then
+ expect(
+ await screen.findByText(/Account 123456789012 will be added/),
+ ).toBeVisible();
+ await waitFor(() => expect(connectButton()).toBeEnabled());
+
+ // When
+ await user.click(connectButton());
+
+ // Then
+ await waitFor(() => expect(onConnected).toHaveBeenCalledOnce());
+ const secret = Object.fromEntries(
+ (addCredentialsProvider.mock.calls[0][0] as FormData).entries(),
+ );
+ expect(secret).toMatchObject({
+ providerId: "provider-1",
+ role_arn: ROLE_ARN,
+ external_id: "tenant-abc",
+ credentials_type: "aws-sdk-default",
+ });
+ expect(funnelSignals).toContainEqual({
+ step: "account_submitted",
+ providerType: "aws",
+ via: "role",
+ outcome: "success",
+ });
+ });
+
+ it("shows an account the API already knows on the ARN field and stays on the step", async () => {
+ // Given
+ addProvider.mockResolvedValueOnce({
+ errors: [
+ {
+ detail: "Provider with this uid already exists.",
+ source: { pointer: "/data/attributes/uid" },
+ },
+ ],
+ });
+ const { onConnected, user } = renderStep();
+ await user.type(
+ screen.getByRole("textbox", { name: /Role ARN/ }),
+ ROLE_ARN,
+ );
+ await waitFor(() => expect(connectButton()).toBeEnabled());
+
+ // When
+ await user.click(connectButton());
+
+ // Then
+ expect(
+ await screen.findByText("Provider with this uid already exists."),
+ ).toBeVisible();
+ expect(onConnected).not.toHaveBeenCalled();
+ expect(funnelSignals).toContainEqual({
+ step: "account_submitted",
+ providerType: "aws",
+ via: "role",
+ outcome: "error",
+ });
+ });
+
+ it("hands the whole-organization choice to the organizations flow", async () => {
+ // Given
+ const { onSelectOrganizations, user } = renderStep();
+
+ // When
+ await user.click(
+ screen.getByRole("tab", { name: /Full AWS Organization/ }),
+ );
+
+ // Then
+ expect(onSelectOrganizations).toHaveBeenCalledOnce();
+ });
+
+ it("connects with access keys and the typed account id", async () => {
+ // Given
+ const { onConnected, user } = renderStep();
+
+ // When
+ await user.click(
+ screen.getByRole("radio", { name: /Static access keys/ }),
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: /Account ID/ }),
+ "210987654321",
+ );
+ await user.type(
+ screen.getByPlaceholderText("Enter the AWS Access Key ID"),
+ "AKIAEXAMPLE",
+ );
+ await user.type(
+ screen.getByPlaceholderText("Enter the AWS Secret Access Key"),
+ "secret-value",
+ );
+ await waitFor(() => expect(connectButton()).toBeEnabled());
+ await user.click(connectButton());
+
+ // Then
+ await waitFor(() => expect(onConnected).toHaveBeenCalledOnce());
+ const provider = Object.fromEntries(
+ (addProvider.mock.calls[0][0] as FormData).entries(),
+ );
+ expect(provider).toEqual({
+ providerType: "aws",
+ providerUid: "210987654321",
+ });
+ });
+ });
+
+ describe("with access keys, when the API refuses the account", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ });
+
+ it("shows the refusal on the Account ID field and stays on the step", async () => {
+ // Given
+ addProvider.mockResolvedValueOnce({
+ errors: [
+ {
+ detail: "Provider with this uid already exists.",
+ source: { pointer: "/data/attributes/uid" },
+ },
+ ],
+ });
+ const { onConnected, user } = renderStep();
+ await user.click(
+ screen.getByRole("radio", { name: /Static access keys/ }),
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: /Account ID/ }),
+ "210987654321",
+ );
+ await user.type(
+ screen.getByPlaceholderText("Enter the AWS Access Key ID"),
+ "AKIAEXAMPLE",
+ );
+ await user.type(
+ screen.getByPlaceholderText("Enter the AWS Secret Access Key"),
+ "secret-value",
+ );
+ await waitFor(() => expect(connectButton()).toBeEnabled());
+
+ // When
+ await user.click(connectButton());
+
+ // Then
+ expect(
+ await screen.findByText("Provider with this uid already exists."),
+ ).toBeVisible();
+ // The field wrapper carries the invalid state for the Account ID input.
+ expect(
+ screen
+ .getByRole("textbox", { name: /Account ID/ })
+ .closest("[aria-invalid]"),
+ ).toHaveAttribute("aria-invalid", "true");
+ expect(onConnected).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when the step is left and reopened within the same wizard", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ });
+
+ it("keeps what was typed, including the chosen access method", async () => {
+ // Given
+ const onConnected = vi.fn();
+ const onSelectOrganizations = vi.fn();
+ const user = userEvent.setup();
+ const { unmount } = render(
+ ,
+ );
+ await user.click(
+ screen.getByRole("radio", { name: /Static access keys/ }),
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: /Account ID/ }),
+ "210987654321",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: /Provider alias/ }),
+ "Staging",
+ );
+
+ // When: the organizations tab or the connection test unmounts the step.
+ unmount();
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("radio", { name: /Static access keys/ }),
+ ).toHaveAttribute("aria-checked", "true");
+ expect(screen.getByRole("textbox", { name: /Account ID/ })).toHaveValue(
+ "210987654321",
+ );
+ expect(
+ screen.getByRole("textbox", { name: /Provider alias/ }),
+ ).toHaveValue("Staging");
+ });
+
+ it("starts blank again once the wizard is reset", async () => {
+ // Given
+ const user = userEvent.setup();
+ const { unmount } = render(
+ ,
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: /Role ARN/ }),
+ ROLE_ARN,
+ );
+ unmount();
+
+ // When
+ useProviderWizardStore.getState().reset();
+ render();
+
+ // Then
+ expect(screen.getByRole("textbox", { name: /Role ARN/ })).toHaveValue("");
+ });
+ });
+
+ describe("in Prowler Cloud, role creation", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ });
+
+ it("leads with the one-click stack and keeps the other templates behind a toggle", async () => {
+ // Given
+ const { user } = renderStep();
+
+ // Then
+ expect(
+ screen.queryByRole("link", { name: /CloudFormation Template/i }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("link", { name: /Terraform Code/i }),
+ ).not.toBeInTheDocument();
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: /Other ways to create the role/i }),
+ );
+
+ // Then
+ expect(
+ screen.getByRole("link", { name: /CloudFormation Template/i }),
+ ).toHaveAttribute("href", expect.stringContaining("prowler-scan-role"));
+ expect(
+ screen.getByRole("link", { name: /Terraform Code/i }),
+ ).toBeVisible();
+ });
+
+ it("never asks which credentials assume the role: Prowler Cloud does", async () => {
+ // Given
+ const { user } = renderStep();
+ await user.click(
+ screen.getByRole("button", { name: /Advanced options/i }),
+ );
+
+ // Then
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(
+ screen.queryByPlaceholderText("Enter the AWS Access Key ID"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("Enter the role session name"),
+ ).toBeVisible();
+ });
+ });
+
+ describe("in a self-hosted deployment", () => {
+ beforeEach(() => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+ });
+
+ it("offers the same one-click role setup, on the shared template", async () => {
+ // Given
+ const { user } = renderStep();
+
+ // Then: the template keeps the AccountId parameter self-hosted users must edit.
+ expect(
+ screen.getByRole("link", { name: /Create the IAM role in AWS/i }),
+ ).toHaveAttribute(
+ "href",
+ expect.stringContaining("prowler-scan-role.yml"),
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: /Advanced options/i }),
+ );
+
+ // Then: keys belong to the "Static access keys" method, never to the role one.
+ expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
+ expect(
+ screen.queryByPlaceholderText("Enter the AWS Access Key ID"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("Enter the role session name"),
+ ).toBeVisible();
+ });
+
+ it("assumes the role with the credentials of the host running Prowler", async () => {
+ // Given
+ const { onConnected, user } = renderStep();
+
+ // When
+ await user.type(
+ screen.getByRole("textbox", { name: /Role ARN/ }),
+ ROLE_ARN,
+ );
+ await screen.findByText(/Account 123456789012 will be added/);
+ await waitFor(() => expect(connectButton()).toBeEnabled());
+ await user.click(connectButton());
+
+ // Then
+ await waitFor(() => expect(onConnected).toHaveBeenCalledOnce());
+ const secret = Object.fromEntries(
+ (addCredentialsProvider.mock.calls[0][0] as FormData).entries(),
+ );
+ expect(secret).toMatchObject({
+ role_arn: ROLE_ARN,
+ credentials_type: "aws-sdk-default",
+ });
+ expect(secret).not.toHaveProperty("aws_access_key_id");
+ });
+ });
+});
diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx
new file mode 100644
index 0000000000..1b0d1ed47b
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx
@@ -0,0 +1,432 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ChevronDownIcon, KeyRound, ShieldCheck } from "lucide-react";
+import { useSession } from "next-auth/react";
+import { useEffect, useState } from "react";
+import {
+ Control,
+ FieldValues,
+ Resolver,
+ UseFormReturn,
+ useForm,
+ useFormState,
+ useWatch,
+} from "react-hook-form";
+
+import { RadioCard } from "@/components/providers/radio-card";
+import { CredentialsRoleHelper } from "@/components/providers/workflow/credentials-role-helper";
+import { WizardInputField } from "@/components/providers/workflow/forms/fields";
+import { AwsRoleOptionalFields } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-optional-fields";
+import { AWSStaticCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-static-credentials-form";
+import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/shadcn/collapsible";
+import { Form } from "@/components/shadcn/form";
+import { useFormServerErrors } from "@/hooks/use-form-server-errors";
+import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
+import { getAWSCredentialsTemplateLinks } from "@/lib/external-urls";
+import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+import {
+ ACCOUNT_SUBMIT_OUTCOME,
+ dispatchProviderFunnel,
+ PROVIDER_FUNNEL_STEP,
+} from "@/lib/provider-funnel/provider-funnel-events";
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+import type { AWSCredentials, AWSCredentialsRole } from "@/types";
+import type { AwsConnectDraft } from "@/types/provider-wizard";
+
+import {
+ awsKeysConnectSchema,
+ type AwsKeysConnectValues,
+ awsRoleConnectSchema,
+ type AwsRoleConnectValues,
+} from "./aws-connect.schema";
+import {
+ AWS_ONBOARDING_METHOD,
+ AwsOnboardingMethodTabs,
+} from "./aws-onboarding-method-tabs";
+import { parseAwsAccountIdFromRoleArn } from "./aws-role-arn";
+import {
+ AWS_UID_ERROR_POINTER,
+ connectAwsAccount,
+} from "./connect-aws-account";
+import {
+ AWS_ACCESS_METHOD,
+ type AwsAccessMethod,
+ type AwsConnectUiState,
+} from "./types";
+
+const ALIAS_ERROR_POINTER = "/data/attributes/alias";
+const UNIQUE_TOGETHER_ERROR_POINTER = "/data/attributes/__all__";
+
+// What the user typed survives the step unmounting (organizations tab, a step
+// back from the connection test) until the wizard closes.
+const readDraft = () => useProviderWizardStore.getState().awsConnectDraft;
+
+const initialMethod = (): AwsAccessMethod =>
+ readDraft()?.method === AWS_ACCESS_METHOD.CREDENTIALS
+ ? AWS_ACCESS_METHOD.CREDENTIALS
+ : AWS_ACCESS_METHOD.ROLE;
+
+function useDraftValues(
+ form: UseFormReturn,
+ key: keyof Pick,
+) {
+ const values = useWatch({ control: form.control });
+ useEffect(() => {
+ useProviderWizardStore
+ .getState()
+ .setAwsConnectDraft({ [key]: values as AwsConnectDraft[typeof key] });
+ }, [key, values]);
+}
+
+interface AwsConnectStepProps {
+ formId: string;
+ onConnected: () => void;
+ onSelectOrganizations: () => void;
+ onUiStateChange: (state: AwsConnectUiState) => void;
+}
+
+/** One form to register an AWS account and store its credentials. */
+export function AwsConnectStep({
+ formId,
+ onConnected,
+ onSelectOrganizations,
+ onUiStateChange,
+}: AwsConnectStepProps) {
+ // Local state needed: the access method only matters until the account is connected.
+ const [method, setMethod] = useState(initialMethod);
+ // Local state needed: the active form reports it so the method cannot change mid-submit.
+ const [isBusy, setIsBusy] = useState(false);
+
+ const isRole = method === AWS_ACCESS_METHOD.ROLE;
+
+ const chooseMethod = (next: AwsAccessMethod) => {
+ setMethod(next);
+ useProviderWizardStore.getState().setAwsConnectDraft({ method: next });
+ };
+
+ return (
+
+
+
+
+
+
+
+ Choose how Prowler should access your account.
+
+
chooseMethod(AWS_ACCESS_METHOD.ROLE)}
+ >
+
+ Recommended
+
+
+
chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)}
+ />
+
+
+ {isRole ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+interface ConnectFormProps
+ extends Pick<
+ AwsConnectStepProps,
+ "formId" | "onConnected" | "onUiStateChange"
+ > {
+ onBusyChange: (isBusy: boolean) => void;
+}
+
+interface UseAwsConnectSubmitOptions {
+ form: UseFormReturn;
+ method: AwsAccessMethod;
+ // The field an account-level API error belongs to for this method.
+ accountField: string;
+ // Beyond form validity: the role form also needs an account read from the ARN.
+ accountResolved?: boolean;
+ extraValues?: Record;
+ onConnected: () => void;
+ onBusyChange: (isBusy: boolean) => void;
+ onUiStateChange: (state: AwsConnectUiState) => void;
+}
+
+function useAwsConnectSubmit({
+ form,
+ method,
+ accountField,
+ accountResolved = true,
+ extraValues,
+ onConnected,
+ onBusyChange,
+ onUiStateChange,
+}: UseAwsConnectSubmitOptions) {
+ const { handleServerResponse } = useFormServerErrors(form, {
+ ...PROVIDER_CREDENTIALS_ERROR_MAPPING,
+ [AWS_UID_ERROR_POINTER]: accountField,
+ [UNIQUE_TOGETHER_ERROR_POINTER]: accountField,
+ [ALIAS_ERROR_POINTER]: ProviderCredentialFields.PROVIDER_ALIAS,
+ });
+ // A hook, not `form.formState.isValid` read inline: the React Compiler keys
+ // its memo on the stable `form` object and would freeze a proxy read at false.
+ const { isSubmitting, isValid } = useFormState({ control: form.control });
+ const canSubmit = isValid && accountResolved;
+
+ // Same contract ConnectAccountForm uses: the wizard footer lives outside the step.
+ // Both callbacks must be stable setters, or this effect would loop.
+ useEffect(() => {
+ onBusyChange(isSubmitting);
+ onUiStateChange({
+ showBack: true,
+ showAction: true,
+ actionLabel: isSubmitting ? "Connecting account..." : "Connect account",
+ actionDisabled: !canSubmit || isSubmitting,
+ isLoading: isSubmitting,
+ });
+ }, [canSubmit, isSubmitting, onBusyChange, onUiStateChange]);
+
+ return form.handleSubmit(async (values) => {
+ const result = await connectAwsAccount({
+ method,
+ values: { ...values, ...extraValues },
+ });
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.ACCOUNT_SUBMITTED,
+ providerType: "aws",
+ via: method,
+ outcome: result.ok
+ ? ACCOUNT_SUBMIT_OUTCOME.SUCCESS
+ : ACCOUNT_SUBMIT_OUTCOME.ERROR,
+ });
+ if (!result.ok) {
+ // Maps API pointers onto the form's fields; anything unmapped becomes a toast.
+ handleServerResponse({ errors: result.errors });
+ return;
+ }
+ onConnected();
+ });
+}
+
+function AwsRoleConnectForm({
+ formId,
+ onConnected,
+ onBusyChange,
+ onUiStateChange,
+}: ConnectFormProps) {
+ const { data: session } = useSession();
+ const externalId = session?.tenantId ?? "";
+
+ const form = useForm({
+ resolver: zodResolver(
+ awsRoleConnectSchema,
+ ) as unknown as Resolver,
+ mode: "onChange",
+ defaultValues: {
+ [ProviderCredentialFields.PROVIDER_ID]: "",
+ [ProviderCredentialFields.PROVIDER_TYPE]: "aws",
+ [ProviderCredentialFields.PROVIDER_ALIAS]: "",
+ // The role is assumed with Prowler's own credentials (Cloud's identity or the
+ // host's AWS SDK chain); static keys are a method of their own, never mixed in.
+ [ProviderCredentialFields.CREDENTIALS_TYPE]:
+ ProviderCredentialFields.CREDENTIALS_TYPE_AWS,
+ [ProviderCredentialFields.ROLE_ARN]: "",
+ [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "",
+ [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "",
+ [ProviderCredentialFields.AWS_SESSION_TOKEN]: "",
+ [ProviderCredentialFields.ROLE_SESSION_NAME]: "",
+ [ProviderCredentialFields.SESSION_DURATION]: "3600",
+ ...readDraft()?.roleValues,
+ },
+ });
+ useDraftValues(form, "roleValues");
+
+ const roleArn = useWatch({
+ control: form.control,
+ name: ProviderCredentialFields.ROLE_ARN,
+ });
+ const detectedAccountId = parseAwsAccountIdFromRoleArn(roleArn ?? "");
+
+ const onSubmit = useAwsConnectSubmit({
+ form,
+ method: AWS_ACCESS_METHOD.ROLE,
+ accountField: ProviderCredentialFields.ROLE_ARN,
+ accountResolved: detectedAccountId !== null,
+ // The external id is the tenant's, never user input, so it joins at submit time.
+ extraValues: { [ProviderCredentialFields.EXTERNAL_ID]: externalId },
+ onConnected,
+ onBusyChange,
+ onUiStateChange,
+ });
+
+ // One template for every build: self-hosted users set the account that assumes
+ // the role, so the AccountId parameter must stay editable in the console.
+ const templateLinks = getAWSCredentialsTemplateLinks(externalId);
+ const roleControl = form.control as unknown as Control;
+
+ return (
+
+
+ );
+}
+
+function AwsKeysConnectForm({
+ formId,
+ onConnected,
+ onBusyChange,
+ onUiStateChange,
+}: ConnectFormProps) {
+ const form = useForm({
+ resolver: zodResolver(
+ awsKeysConnectSchema,
+ ) as unknown as Resolver,
+ mode: "onChange",
+ defaultValues: {
+ [ProviderCredentialFields.PROVIDER_ID]: "",
+ [ProviderCredentialFields.PROVIDER_TYPE]: "aws",
+ [ProviderCredentialFields.PROVIDER_UID]: "",
+ [ProviderCredentialFields.PROVIDER_ALIAS]: "",
+ [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "",
+ [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "",
+ [ProviderCredentialFields.AWS_SESSION_TOKEN]: "",
+ ...readDraft()?.keysValues,
+ },
+ });
+ useDraftValues(form, "keysValues");
+
+ const onSubmit = useAwsConnectSubmit({
+ form,
+ method: AWS_ACCESS_METHOD.CREDENTIALS,
+ accountField: ProviderCredentialFields.PROVIDER_UID,
+ onConnected,
+ onBusyChange,
+ onUiStateChange,
+ });
+
+ return (
+
+
+ );
+}
+
+function AliasField({ control }: { control: Control }) {
+ return (
+
+ );
+}
diff --git a/ui/components/providers/wizard/steps/aws/aws-connect.schema.ts b/ui/components/providers/wizard/steps/aws/aws-connect.schema.ts
new file mode 100644
index 0000000000..2d17d23858
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-connect.schema.ts
@@ -0,0 +1,52 @@
+import { z } from "zod";
+
+import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+import {
+ addCredentialsFormSchema,
+ addCredentialsRoleFormSchema,
+} from "@/types/formSchemas";
+
+const aliasField = {
+ [ProviderCredentialFields.PROVIDER_ALIAS]: z.string().trim().optional(),
+};
+
+// The shared credential schemas stay the source of truth; the step only adds the
+// account fields it collects in the same form.
+export const awsRoleConnectSchema = addCredentialsRoleFormSchema("aws").and(
+ z.object(aliasField),
+);
+
+export const awsKeysConnectSchema = addCredentialsFormSchema("aws").and(
+ z.object({
+ ...aliasField,
+ [ProviderCredentialFields.PROVIDER_UID]: z
+ .string()
+ .trim()
+ .regex(/^\d{12}$/, "AWS Account ID must be exactly 12 digits"),
+ }),
+);
+
+// The shared schema factories take a plain string, so their inferred type is the
+// union of every provider; the step's forms declare the AWS shape explicitly.
+interface AwsConnectAccountValues {
+ [ProviderCredentialFields.PROVIDER_ID]: string;
+ [ProviderCredentialFields.PROVIDER_TYPE]: string;
+ [ProviderCredentialFields.PROVIDER_ALIAS]?: string;
+}
+
+export interface AwsRoleConnectValues extends AwsConnectAccountValues {
+ [ProviderCredentialFields.ROLE_ARN]: string;
+ [ProviderCredentialFields.CREDENTIALS_TYPE]?: string;
+ [ProviderCredentialFields.AWS_ACCESS_KEY_ID]?: string;
+ [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]?: string;
+ [ProviderCredentialFields.AWS_SESSION_TOKEN]?: string;
+ [ProviderCredentialFields.ROLE_SESSION_NAME]?: string;
+ [ProviderCredentialFields.SESSION_DURATION]?: string;
+}
+
+export interface AwsKeysConnectValues extends AwsConnectAccountValues {
+ [ProviderCredentialFields.PROVIDER_UID]: string;
+ [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: string;
+ [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: string;
+ [ProviderCredentialFields.AWS_SESSION_TOKEN]?: string;
+}
diff --git a/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx b/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx
new file mode 100644
index 0000000000..6a4b7a279f
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx
@@ -0,0 +1,77 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { useCloudUpgradeStore } from "@/store";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import {
+ AWS_ONBOARDING_METHOD,
+ AwsOnboardingMethodTabs,
+} from "./aws-onboarding-method-tabs";
+
+describe("AwsOnboardingMethodTabs", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ useCloudUpgradeStore.getState().closeCloudUpgrade();
+ });
+
+ it("switches to the organization flow in Cloud", async () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ const user = userEvent.setup();
+ const onSelectOrganizations = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("tab", { name: /Full AWS Organization/ }),
+ );
+
+ expect(onSelectOrganizations).toHaveBeenCalledOnce();
+ expect(screen.queryByText("Cloud")).not.toBeInTheDocument();
+ });
+
+ it("opens the AWS Organizations upgrade in Local Server", async () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+ const user = userEvent.setup();
+ const onSelectOrganizations = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("tab", { name: /Full AWS Organization/ }),
+ );
+
+ expect(onSelectOrganizations).not.toHaveBeenCalled();
+ expect(screen.getByText("Cloud")).toBeVisible();
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS,
+ );
+ });
+
+ it("returns to the single account flow from the organization tab", async () => {
+ const user = userEvent.setup();
+ const onSelectSingle = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("tab", { name: "Single AWS Account" }));
+
+ expect(onSelectSingle).toHaveBeenCalledOnce();
+ });
+});
diff --git a/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx b/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx
new file mode 100644
index 0000000000..2f24f177d4
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Tabs, TabsList, TabsTrigger } from "@/components/shadcn/tabs/tabs";
+import { isCloud } from "@/lib/shared/env";
+import { useCloudUpgradeStore } from "@/store";
+import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+export const AWS_ONBOARDING_METHOD = {
+ SINGLE: "single",
+ ORGANIZATION: "organization",
+} as const;
+
+export type AwsOnboardingMethod =
+ (typeof AWS_ONBOARDING_METHOD)[keyof typeof AWS_ONBOARDING_METHOD];
+
+interface AwsOnboardingMethodTabsProps {
+ value: AwsOnboardingMethod;
+ onSelectSingle?: () => void;
+ onSelectOrganizations?: () => void;
+}
+
+/** Single account vs. whole organization switch at the top of the AWS connect step. */
+export function AwsOnboardingMethodTabs({
+ value,
+ onSelectSingle,
+ onSelectOrganizations,
+}: AwsOnboardingMethodTabsProps) {
+ const isCloudEnv = isCloud();
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
+
+ const handleValueChange = (next: string) => {
+ if (next === value) return;
+ if (next === AWS_ONBOARDING_METHOD.SINGLE) {
+ onSelectSingle?.();
+ return;
+ }
+ if (isCloudEnv) {
+ onSelectOrganizations?.();
+ return;
+ }
+ openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS);
+ };
+
+ return (
+
+
+
+ Single AWS Account
+
+
+ Cloud
+
+ )
+ }
+ >
+ Full AWS Organization
+
+
+
+ );
+}
diff --git a/ui/components/providers/wizard/steps/aws/aws-role-arn.test.ts b/ui/components/providers/wizard/steps/aws/aws-role-arn.test.ts
new file mode 100644
index 0000000000..11929a20b7
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-role-arn.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { parseAwsAccountIdFromRoleArn } from "./aws-role-arn";
+
+describe("parseAwsAccountIdFromRoleArn", () => {
+ it.each([
+ ["arn:aws:iam::123456789012:role/ProwlerScan", "123456789012"],
+ [" arn:aws:iam::123456789012:role/path/ProwlerScan ", "123456789012"],
+ ["arn:aws-cn:iam::123456789012:role/ProwlerScan", "123456789012"],
+ ["arn:aws-us-gov:iam::123456789012:role/Prowler@Scan", "123456789012"],
+ ])("extracts the account id from %s", (arn, expected) => {
+ expect(parseAwsAccountIdFromRoleArn(arn)).toBe(expected);
+ });
+
+ it.each([
+ "",
+ "123456789012",
+ "arn:aws:iam::12345678901:role/ProwlerScan",
+ "arn:aws:iam::123456789012:user/prowler",
+ "arn:aws:s3:::bucket",
+ "arn:aws:iam::123456789012:role/",
+ ])("returns null for %s", (arn) => {
+ expect(parseAwsAccountIdFromRoleArn(arn)).toBeNull();
+ });
+});
diff --git a/ui/components/providers/wizard/steps/aws/aws-role-arn.ts b/ui/components/providers/wizard/steps/aws/aws-role-arn.ts
new file mode 100644
index 0000000000..47b7b79153
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/aws-role-arn.ts
@@ -0,0 +1,9 @@
+const AWS_ROLE_ARN_PATTERN =
+ /^arn:aws(?:-[a-z]+)*:iam::(\d{12}):role\/[\w+=,.@/-]+$/;
+
+export const AWS_ROLE_ARN_MESSAGE =
+ "Must be a valid IAM Role ARN (e.g. arn:aws:iam::123456789012:role/ProwlerScan)";
+
+/** The 12-digit account id embedded in an IAM role ARN, or null when malformed. */
+export const parseAwsAccountIdFromRoleArn = (roleArn: string) =>
+ AWS_ROLE_ARN_PATTERN.exec(roleArn.trim())?.[1] ?? null;
diff --git a/ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts b/ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts
new file mode 100644
index 0000000000..725e1fcc15
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts
@@ -0,0 +1,340 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+import { useUIStore } from "@/store/ui/store";
+
+import { connectAwsAccount } from "./connect-aws-account";
+import { AWS_ACCESS_METHOD } from "./types";
+
+const {
+ addProvider,
+ addCredentialsProvider,
+ updateProvider,
+ updateCredentialsProvider,
+} = vi.hoisted(() => ({
+ addProvider: vi.fn(),
+ addCredentialsProvider: vi.fn(),
+ updateProvider: vi.fn(),
+ updateCredentialsProvider: vi.fn(),
+}));
+
+vi.mock("@/actions/providers/providers", () => ({
+ addProvider,
+ addCredentialsProvider,
+ updateProvider,
+ updateCredentialsProvider,
+}));
+
+const ROLE_ARN = "arn:aws:iam::123456789012:role/ProwlerScan";
+
+const roleValues = {
+ providerId: "",
+ providerType: "aws",
+ providerAlias: "Production",
+ role_arn: ROLE_ARN,
+ external_id: "tenant-1",
+ credentials_type: "aws-sdk-default",
+ aws_access_key_id: "",
+ aws_secret_access_key: "",
+ aws_session_token: "",
+ role_session_name: "",
+ session_duration: "3600",
+};
+
+const formEntries = (call: number, mock: typeof addProvider) =>
+ Object.fromEntries((mock.mock.calls[call][0] as FormData).entries());
+
+describe("connectAwsAccount", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ useProviderWizardStore.getState().reset();
+ useUIStore.setState({ hasProviders: false, hasProvidersResolved: true });
+ addProvider.mockResolvedValue({ data: { id: "provider-1" } });
+ addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } });
+ updateProvider.mockResolvedValue({ data: { id: "provider-1" } });
+ updateCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } });
+ });
+
+ describe("when connecting through an IAM role", () => {
+ it("registers the account read from the ARN and stores its credentials in one go", async () => {
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(result).toEqual({ ok: true });
+ expect(formEntries(0, addProvider)).toEqual({
+ providerType: "aws",
+ providerUid: "123456789012",
+ providerAlias: "Production",
+ });
+ expect(formEntries(0, addCredentialsProvider)).toEqual({
+ providerId: "provider-1",
+ providerType: "aws",
+ role_arn: ROLE_ARN,
+ external_id: "tenant-1",
+ credentials_type: "aws-sdk-default",
+ session_duration: "3600",
+ });
+ });
+
+ it("leaves the wizard ready for the connection test", async () => {
+ // When
+ await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(useProviderWizardStore.getState()).toMatchObject({
+ providerId: "provider-1",
+ providerType: "aws",
+ providerUid: "123456789012",
+ providerAlias: "Production",
+ via: "role",
+ secretId: "secret-1",
+ mode: "add",
+ });
+ expect(useUIStore.getState().hasProviders).toBe(true);
+ });
+
+ it("rejects a malformed ARN on its field without calling the API", async () => {
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: { ...roleValues, role_arn: "arn:aws:s3:::bucket" },
+ });
+
+ // Then
+ expect(result).toEqual({
+ ok: false,
+ errors: [
+ expect.objectContaining({
+ source: { pointer: "/data/attributes/uid" },
+ }),
+ ],
+ });
+ expect(addProvider).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when connecting with access keys", () => {
+ it("registers the typed account id and sends only the keys as the secret", async () => {
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.CREDENTIALS,
+ values: {
+ providerId: "",
+ providerType: "aws",
+ providerUid: "210987654321",
+ providerAlias: "",
+ aws_access_key_id: "AKIAEXAMPLE",
+ aws_secret_access_key: "secret",
+ aws_session_token: "",
+ },
+ });
+
+ // Then
+ expect(result).toEqual({ ok: true });
+ expect(formEntries(0, addProvider)).toEqual({
+ providerType: "aws",
+ providerUid: "210987654321",
+ });
+ expect(formEntries(0, addCredentialsProvider)).toEqual({
+ providerId: "provider-1",
+ providerType: "aws",
+ aws_access_key_id: "AKIAEXAMPLE",
+ aws_secret_access_key: "secret",
+ });
+ expect(useProviderWizardStore.getState().via).toBe("credentials");
+ });
+ });
+
+ describe("when the API refuses the account", () => {
+ it("returns the provider errors and stores nothing", async () => {
+ // Given
+ const errors = [
+ {
+ detail: "Provider with this uid already exists.",
+ source: { pointer: "/data/attributes/uid" },
+ },
+ ];
+ addProvider.mockResolvedValueOnce({ errors });
+
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(result).toEqual({ ok: false, errors });
+ expect(addCredentialsProvider).not.toHaveBeenCalled();
+ expect(useProviderWizardStore.getState().providerId).toBeNull();
+ });
+ });
+
+ describe("when the API fails without field errors", () => {
+ it("reports the account failure instead of throwing", async () => {
+ // Given
+ addProvider.mockResolvedValueOnce({ error: "Server is unavailable." });
+
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(result).toEqual({
+ ok: false,
+ errors: [{ detail: "Server is unavailable." }],
+ });
+ expect(addCredentialsProvider).not.toHaveBeenCalled();
+ expect(useProviderWizardStore.getState().providerId).toBeNull();
+ });
+
+ it("reports an account response without an id instead of stalling", async () => {
+ // Given
+ addProvider.mockResolvedValueOnce({ data: {} });
+
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(result).toEqual({
+ ok: false,
+ errors: [{ detail: expect.stringMatching(/try again/i) }],
+ });
+ expect(addCredentialsProvider).not.toHaveBeenCalled();
+ });
+
+ it("reports the credentials failure and keeps the account for a retry", async () => {
+ // Given
+ addCredentialsProvider.mockResolvedValueOnce({
+ error: "Server is unavailable.",
+ });
+
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(result).toEqual({
+ ok: false,
+ errors: [{ detail: "Server is unavailable." }],
+ });
+ expect(useProviderWizardStore.getState()).toMatchObject({
+ providerId: "provider-1",
+ secretId: null,
+ });
+ });
+ });
+
+ describe("when the credentials are refused after the account was registered", () => {
+ it("reuses the registered account on the next attempt instead of creating it twice", async () => {
+ // Given
+ const errors = [
+ {
+ detail: "Invalid role ARN.",
+ source: { pointer: "/data/attributes/secret/role_arn" },
+ },
+ ];
+ addCredentialsProvider.mockResolvedValueOnce({ errors });
+
+ // When
+ const first = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+ const second = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // Then
+ expect(first).toEqual({ ok: false, errors });
+ expect(second).toEqual({ ok: true });
+ expect(addProvider).toHaveBeenCalledOnce();
+ expect(addCredentialsProvider).toHaveBeenCalledTimes(2);
+ expect(updateProvider).not.toHaveBeenCalled();
+ });
+
+ it("renames the registered account when the alias changed before the retry", async () => {
+ // Given
+ addCredentialsProvider.mockResolvedValueOnce({
+ errors: [{ detail: "Invalid role ARN." }],
+ });
+ await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+
+ // When
+ const second = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: { ...roleValues, providerAlias: "Production EU" },
+ });
+
+ // Then
+ expect(second).toEqual({ ok: true });
+ expect(addProvider).toHaveBeenCalledOnce();
+ expect(formEntries(0, updateProvider)).toEqual({
+ providerId: "provider-1",
+ providerAlias: "Production EU",
+ });
+ expect(useProviderWizardStore.getState().providerAlias).toBe(
+ "Production EU",
+ );
+ });
+ });
+
+ describe("when the account was already connected in this wizard session", () => {
+ it("updates the stored credentials instead of creating a second secret", async () => {
+ // Given
+ await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: roleValues,
+ });
+ updateCredentialsProvider.mockResolvedValueOnce({
+ data: { id: "secret-1" },
+ });
+
+ // When
+ const result = await connectAwsAccount({
+ method: AWS_ACCESS_METHOD.ROLE,
+ values: {
+ ...roleValues,
+ role_arn: "arn:aws:iam::123456789012:role/ProwlerScanV2",
+ },
+ });
+
+ // Then
+ expect(result).toEqual({ ok: true });
+ expect(addProvider).toHaveBeenCalledOnce();
+ expect(addCredentialsProvider).toHaveBeenCalledOnce();
+ expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith(
+ "secret-1",
+ expect.any(FormData),
+ );
+ expect(
+ Object.fromEntries(
+ (updateCredentialsProvider.mock.calls[0][1] as FormData).entries(),
+ ),
+ ).toMatchObject({
+ providerId: "provider-1",
+ providerType: "aws",
+ role_arn: "arn:aws:iam::123456789012:role/ProwlerScanV2",
+ });
+ expect(useProviderWizardStore.getState().secretId).toBe("secret-1");
+ });
+ });
+});
diff --git a/ui/components/providers/wizard/steps/aws/connect-aws-account.ts b/ui/components/providers/wizard/steps/aws/connect-aws-account.ts
new file mode 100644
index 0000000000..cf1115f4c5
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/connect-aws-account.ts
@@ -0,0 +1,191 @@
+import {
+ addCredentialsProvider,
+ addProvider,
+ updateCredentialsProvider,
+ updateProvider,
+} from "@/actions/providers/providers";
+import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+import { useProviderWizardStore } from "@/store/provider-wizard/store";
+import { useUIStore } from "@/store/ui/store";
+import type { ApiError } from "@/types";
+import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
+
+import {
+ AWS_ROLE_ARN_MESSAGE,
+ parseAwsAccountIdFromRoleArn,
+} from "./aws-role-arn";
+import { AWS_ACCESS_METHOD, type AwsAccessMethod } from "./types";
+
+export const AWS_UID_ERROR_POINTER = "/data/attributes/uid";
+
+// Account fields travel with the provider, never with its secret.
+const ACCOUNT_FIELDS: readonly string[] = [
+ ProviderCredentialFields.PROVIDER_UID,
+ ProviderCredentialFields.PROVIDER_ALIAS,
+];
+
+export interface AwsConnectInput {
+ method: AwsAccessMethod;
+ values: Record;
+}
+
+interface AwsConnectSuccess {
+ ok: true;
+}
+
+interface AwsConnectFailure {
+ ok: false;
+ errors: ApiError[];
+}
+
+export type AwsConnectResult = AwsConnectSuccess | AwsConnectFailure;
+
+const asText = (value: unknown) => (typeof value === "string" ? value : "");
+
+// Blank fields are left out, so optional credentials never reach the API as "".
+const toFormData = (values: Record) => {
+ const formData = new FormData();
+ Object.entries(values).forEach(([key, value]) => {
+ const text = asText(value).trim();
+ if (text) formData.append(key, text);
+ });
+ return formData;
+};
+
+interface CreatedResource {
+ id?: unknown;
+}
+
+interface CreateActionResponse {
+ data?: CreatedResource;
+ error?: string;
+ errors?: ApiError[];
+}
+
+const UNCONFIRMED_RESPONSE_MESSAGE =
+ "The API did not confirm the request. Please try again.";
+
+// Actions resolve { errors } on a refusal and { error } on a crash, never throwing.
+// A body with no id is reported too, or the step would stall without feedback.
+const readCreatedId = (response: unknown) => {
+ const body = response as CreateActionResponse | undefined;
+ if (body?.errors?.length) return { id: null, errors: body.errors };
+ if (body?.error) return { id: null, errors: [{ detail: body.error }] };
+ const id = body?.data?.id;
+ if (typeof id !== "string" || !id) {
+ return { id: null, errors: [{ detail: UNCONFIRMED_RESPONSE_MESSAGE }] };
+ }
+ return { id, errors: null };
+};
+
+const resolveAccountId = ({ method, values }: AwsConnectInput) =>
+ method === AWS_ACCESS_METHOD.ROLE
+ ? parseAwsAccountIdFromRoleArn(
+ asText(values[ProviderCredentialFields.ROLE_ARN]),
+ )
+ : asText(values[ProviderCredentialFields.PROVIDER_UID]).trim() || null;
+
+// A retry may carry a new alias; the account registered earlier has to follow it.
+const renameProvider = async (providerId: string, alias: string) => {
+ const store = useProviderWizardStore.getState();
+ if ((store.providerAlias ?? "") === alias)
+ return { providerId, errors: null };
+
+ const updated = readCreatedId(
+ await updateProvider(
+ toFormData({
+ [ProviderCredentialFields.PROVIDER_ID]: providerId,
+ [ProviderCredentialFields.PROVIDER_ALIAS]: alias,
+ }),
+ ),
+ );
+ if (!updated.id) return { providerId: null, errors: updated.errors };
+
+ store.setProvider({
+ id: providerId,
+ type: "aws",
+ uid: store.providerUid ?? "",
+ alias: alias || null,
+ });
+ return { providerId, errors: null };
+};
+
+// A retry after a refused secret must not register the same account twice.
+const ensureProvider = async (uid: string, alias: string) => {
+ const store = useProviderWizardStore.getState();
+ if (store.providerId && store.providerUid === uid) {
+ return renameProvider(store.providerId, alias);
+ }
+
+ const created = readCreatedId(
+ await addProvider(
+ toFormData({
+ [ProviderCredentialFields.PROVIDER_TYPE]: "aws",
+ [ProviderCredentialFields.PROVIDER_UID]: uid,
+ [ProviderCredentialFields.PROVIDER_ALIAS]: alias,
+ }),
+ ),
+ );
+ if (!created.id) return { providerId: null, errors: created.errors };
+
+ const providerId = created.id;
+ store.setProvider({
+ id: providerId,
+ type: "aws",
+ uid,
+ alias: alias || null,
+ });
+ store.setSecretId(null);
+ store.setMode(PROVIDER_WIZARD_MODE.ADD);
+ // The layout only re-counts providers on a server render; flip the shared flag now.
+ useUIStore.getState().setHasProviders(true);
+ return { providerId, errors: null };
+};
+
+/** Registers the AWS account and stores its credentials in a single submit. */
+export async function connectAwsAccount(
+ input: AwsConnectInput,
+): Promise {
+ const uid = resolveAccountId(input);
+ if (!uid) {
+ return {
+ ok: false,
+ errors: [
+ {
+ detail: AWS_ROLE_ARN_MESSAGE,
+ source: { pointer: AWS_UID_ERROR_POINTER },
+ } as ApiError,
+ ],
+ };
+ }
+
+ const alias = asText(
+ input.values[ProviderCredentialFields.PROVIDER_ALIAS],
+ ).trim();
+ const provider = await ensureProvider(uid, alias);
+ if (!provider.providerId) return { ok: false, errors: provider.errors ?? [] };
+
+ const secretValues = Object.fromEntries(
+ Object.entries(input.values).filter(
+ ([key]) => !ACCOUNT_FIELDS.includes(key),
+ ),
+ );
+ const secretFormData = toFormData({
+ ...secretValues,
+ [ProviderCredentialFields.PROVIDER_ID]: provider.providerId,
+ [ProviderCredentialFields.PROVIDER_TYPE]: "aws",
+ });
+ // A provider holds one secret: resubmitting a connected account edits it in place.
+ const storedSecretId = useProviderWizardStore.getState().secretId;
+ const secret = readCreatedId(
+ storedSecretId
+ ? await updateCredentialsProvider(storedSecretId, secretFormData)
+ : await addCredentialsProvider(secretFormData),
+ );
+ if (!secret.id) return { ok: false, errors: secret.errors ?? [] };
+
+ const store = useProviderWizardStore.getState();
+ store.setSecretId(secret.id);
+ store.setVia(input.method);
+ return { ok: true };
+}
diff --git a/ui/components/providers/wizard/steps/aws/types.ts b/ui/components/providers/wizard/steps/aws/types.ts
new file mode 100644
index 0000000000..6273d05e6d
--- /dev/null
+++ b/ui/components/providers/wizard/steps/aws/types.ts
@@ -0,0 +1,16 @@
+export const AWS_ACCESS_METHOD = {
+ ROLE: "role",
+ CREDENTIALS: "credentials",
+} as const;
+
+export type AwsAccessMethod =
+ (typeof AWS_ACCESS_METHOD)[keyof typeof AWS_ACCESS_METHOD];
+
+/** What the step publishes so the wizard can draw its footer. */
+export interface AwsConnectUiState {
+ showBack: boolean;
+ showAction: boolean;
+ actionLabel: string;
+ actionDisabled: boolean;
+ isLoading: boolean;
+}
diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx
index 649f0d1562..f6e004511b 100644
--- a/ui/components/providers/wizard/steps/connect-step.tsx
+++ b/ui/components/providers/wizard/steps/connect-step.tsx
@@ -6,11 +6,14 @@ import {
ConnectAccountForm,
ConnectAccountSuccessData,
} from "@/components/providers/workflow/forms";
+import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
-import { OrgFlowType } from "@/types/organizations";
+import { useUIStore } from "@/store/ui/store";
+import { ORGANIZATION_TYPE, OrgFlowType } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
+import { AwsConnectStep } from "./aws/aws-connect-step";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
@@ -18,20 +21,28 @@ import {
interface ConnectStepProps {
onNext: () => void;
+ /** AWS registers the account and its credentials in this step, so it skips ahead. */
+ onCredentialsSaved: () => void;
onSelectOrganizations: (orgType: OrgFlowType) => void;
onFooterChange: (config: WizardFooterConfig) => void;
onProviderTypeChange: (providerType: ProviderType | null) => void;
+ /** Provider the user was already working with, e.g. when returning from the AWS organization flow. */
+ initialProviderType?: ProviderType | null;
}
export function ConnectStep({
onNext,
+ onCredentialsSaved,
onSelectOrganizations,
onFooterChange,
onProviderTypeChange,
+ initialProviderType = null,
}: ConnectStepProps) {
const { setProvider, setVia, setSecretId, setMode } =
useProviderWizardStore();
const backHandlerRef = useRef<(() => void) | null>(null);
+ // Local state needed: AWS swaps the generic account form for its one-step form.
+ const [isAwsFlow, setIsAwsFlow] = useState(initialProviderType === "aws");
const [uiState, setUiState] = useState({
showBack: false,
showAction: false,
@@ -52,15 +63,25 @@ export function ConnectStep({
setVia(null);
setSecretId(null);
setMode(PROVIDER_WIZARD_MODE.ADD);
+ // The layout only re-counts providers on a server render; flip the shared flag now.
+ useUIStore.getState().setHasProviders(true);
onNext();
};
useEffect(() => {
+ // The footer sits outside the tour's spotlight, so once the user can continue
+ // the tour has done its job and gets out of the way. No-op off-onboarding.
+ if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) {
+ endActiveTour();
+ }
onFooterChange({
showBack: uiState.showBack,
backLabel: "Back",
backDisabled: uiState.isLoading,
- onBack: () => backHandlerRef.current?.(),
+ // Leaving AWS remounts the generic form on a fresh provider list.
+ onBack: isAwsFlow
+ ? () => setIsAwsFlow(false)
+ : () => backHandlerRef.current?.(),
showAction: uiState.showAction,
actionLabel: uiState.actionLabel,
actionLoading: uiState.isLoading,
@@ -68,7 +89,25 @@ export function ConnectStep({
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
});
- }, [onFooterChange, uiState]);
+ }, [isAwsFlow, onFooterChange, uiState]);
+
+ const handleProviderTypeChange = (providerType: ProviderType | null) => {
+ onProviderTypeChange(providerType);
+ if (providerType === "aws") setIsAwsFlow(true);
+ };
+
+ if (isAwsFlow) {
+ return (
+
+ onSelectOrganizations(ORGANIZATION_TYPE.AWS)
+ }
+ onUiStateChange={setUiState}
+ />
+ );
+ }
return (
{
backHandlerRef.current = handler;
diff --git a/ui/components/providers/wizard/wizard-stepper.tsx b/ui/components/providers/wizard/wizard-stepper.tsx
index 4d86680977..6c00e5ca4b 100644
--- a/ui/components/providers/wizard/wizard-stepper.tsx
+++ b/ui/components/providers/wizard/wizard-stepper.tsx
@@ -46,6 +46,19 @@ const STEPS: StepConfig[] = [
export const PROVIDER_WIZARD_STEPS = STEPS;
+// AWS registers the account and its credentials in one step, so the wizard
+// skips straight from CONNECT to TEST; the stepper mirrors that.
+export const AWS_PROVIDER_WIZARD_STEPS: StepConfig[] = [
+ {
+ label: "Link a Provider",
+ description:
+ "Enter the account details and the credentials Prowler will use.",
+ icon: FolderGit2,
+ },
+ STEPS[2],
+ STEPS[3],
+];
+
export function WizardStepper({
currentStep,
stepOffset = 0,
diff --git a/ui/components/providers/workflow/credentials-role-helper.test.tsx b/ui/components/providers/workflow/credentials-role-helper.test.tsx
new file mode 100644
index 0000000000..e1908a4f34
--- /dev/null
+++ b/ui/components/providers/workflow/credentials-role-helper.test.tsx
@@ -0,0 +1,89 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ PROVIDER_FUNNEL_EVENT,
+ type ProviderFunnelDetail,
+} from "@/lib/provider-funnel/provider-funnel-events";
+
+import { CredentialsRoleHelper } from "./credentials-role-helper";
+
+const templateLinks = {
+ cloudformation: "https://example.com/template.yml",
+ cloudformationQuickLink: "https://example.com/quick-create",
+ terraform: "https://example.com/terraform",
+};
+
+describe("CredentialsRoleHelper", () => {
+ const funnelSignals: ProviderFunnelDetail[] = [];
+ const recordFunnelSignal: EventListener = (event) => {
+ funnelSignals.push((event as CustomEvent).detail);
+ };
+
+ beforeEach(() => {
+ funnelSignals.length = 0;
+ window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ });
+
+ afterEach(() => {
+ window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
+ });
+
+ describe("when connecting a provider", () => {
+ it("signals which role template the user opened", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("link", { name: /Create the IAM role in AWS/i }),
+ );
+ await user.click(
+ screen.getByRole("button", { name: /Other ways to create the role/i }),
+ );
+ await user.click(
+ screen.getByRole("link", { name: "CloudFormation Template" }),
+ );
+ await user.click(screen.getByRole("link", { name: "Terraform Code" }));
+
+ // Then
+ expect(funnelSignals).toEqual([
+ {
+ step: "role_template_opened",
+ template: "cloudformation_quick_create",
+ },
+ { step: "role_template_opened", template: "cloudformation_template" },
+ { step: "role_template_opened", template: "terraform" },
+ ]);
+ });
+ });
+
+ describe("when configuring an integration", () => {
+ it("stays out of the provider funnel", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("link", { name: /Create the IAM role in AWS/i }),
+ );
+
+ // Then
+ expect(funnelSignals).toEqual([]);
+ });
+ });
+});
diff --git a/ui/components/providers/workflow/credentials-role-helper.tsx b/ui/components/providers/workflow/credentials-role-helper.tsx
index 12e0bd3502..76bee3ec44 100644
--- a/ui/components/providers/workflow/credentials-role-helper.tsx
+++ b/ui/components/providers/workflow/credentials-role-helper.tsx
@@ -1,103 +1,132 @@
"use client";
+import { ChevronDownIcon, ExternalLink } from "lucide-react";
+
import { IdIcon } from "@/components/icons";
-import { Button } from "@/components/shadcn";
+import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/shadcn/collapsible";
+import {
+ dispatchProviderFunnel,
+ PROVIDER_FUNNEL_STEP,
+ ROLE_TEMPLATE_KIND,
+ type RoleTemplateKind,
+} from "@/lib/provider-funnel/provider-funnel-events";
+import { isCloud } from "@/lib/shared/env";
import { IntegrationType } from "@/types/integrations";
+interface CredentialsRoleTemplateLinks {
+ cloudformation: string;
+ cloudformationQuickLink: string;
+ terraform: string;
+}
+
interface CredentialsRoleHelperProps {
externalId: string;
- templateLinks: {
- cloudformation: string;
- cloudformationQuickLink: string;
- terraform: string;
- };
+ templateLinks: CredentialsRoleTemplateLinks;
integrationType?: IntegrationType;
}
+const describeRole = (integrationType?: IntegrationType) => {
+ if (integrationType === "amazon_s3") {
+ return "A read-only IAM role must be manually created or updated. Open the AWS console to do it from the stack; the External ID comes filled in.";
+ }
+ if (integrationType) {
+ return "A read-only IAM role must be manually created. Open the AWS console to create it from the stack; the External ID comes filled in.";
+ }
+ return isCloud()
+ ? "Open the AWS console to create a read-only IAM role that Prowler Cloud can assume. The stack comes with your External ID filled in."
+ : "Open the AWS console to create a read-only IAM role that Prowler can assume. Fill in the AWS account Prowler runs from; the External ID comes filled in.";
+};
+
+/** One button creates the IAM role; the raw templates stay tucked away. */
export const CredentialsRoleHelper = ({
externalId,
templateLinks,
integrationType,
}: CredentialsRoleHelperProps) => {
- const isAmazonS3 = integrationType === "amazon_s3";
+ // Integrations reuse this helper; only the add-provider journey is signalled.
+ const signalTemplateOpened = (template: RoleTemplateKind) => {
+ if (integrationType) return;
+ dispatchProviderFunnel({
+ step: PROVIDER_FUNNEL_STEP.ROLE_TEMPLATE_OPENED,
+ template,
+ });
+ };
return (
-
-
-
- A read-only IAM role must be manually created
- {isAmazonS3 ? " or updated" : ""}
-
+
+
+
);
};
diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx
index ea83267cc0..f0199a0c80 100644
--- a/ui/components/providers/workflow/forms/connect-account-form.tsx
+++ b/ui/components/providers/workflow/forms/connect-account-form.tsx
@@ -9,7 +9,6 @@ import { useForm, UseFormReturn } from "react-hook-form";
import { addProvider, updateProvider } from "@/actions/providers/providers";
import { addRegistryProvider } from "@/actions/providers/registry-provider";
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
-import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector";
import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
@@ -50,12 +49,13 @@ export interface ConnectAccountSuccessData {
/**
* Provider types that offer an organization-onboarding method choice: exactly the
- * ones with an onboarding flow, so a new flow type cannot miss the fork.
+ * ones with an onboarding flow, so a new flow type cannot miss the fork. AWS is the
+ * exception: the wizard's own AWS step hosts its single-account/organization switch.
*/
function providerHasOrgMethod(
providerType: ProviderType | undefined,
): providerType is OrgFlowType {
- return toOrgFlowType(providerType) !== undefined;
+ return providerType !== "aws" && toOrgFlowType(providerType) !== undefined;
}
interface ConnectAccountFormProps {
@@ -507,18 +507,6 @@ export const ConnectAccountForm = ({
/>
)}
- {/* Step 2: AWS method selector (before choosing a method) */}
- {prevStep === 2 && providerType === "aws" && method === null && (
- <>
-
- setMethod("single")}
- onSelectOrganizations={() =>
- onSelectOrganizations?.(ORGANIZATION_TYPE.AWS)
- }
- />
- >
- )}
{/* Step 2: Azure method selector (before choosing a method) */}
{prevStep === 2 && providerType === "azure" && method === null && (
<>
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx
index e344b7e92b..082cd1443b 100644
--- a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx
+++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form.tsx
@@ -3,21 +3,16 @@ import { Control, UseFormSetValue, useWatch } from "react-hook-form";
import { CredentialsRoleHelper } from "@/components/providers/workflow";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
-import { Badge } from "@/components/shadcn/badge/badge";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/shadcn/select/select";
import { Separator } from "@/components/shadcn/separator/separator";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { isCloud } from "@/lib/shared/env";
import { AWSCredentialsRole } from "@/types";
import { IntegrationType } from "@/types/integrations";
+import { AwsRoleCredentialsSource } from "./aws-role-credentials-source";
+import { AwsRoleOptionalFields } from "./aws-role-optional-fields";
+
export const AWSRoleCredentialsForm = ({
control,
setValue,
@@ -80,81 +75,12 @@ export const AWSRoleCredentialsForm = ({
)}
{type === "providers" ? (
@@ -210,31 +136,7 @@ export const AWSRoleCredentialsForm = ({
isRequired
/>
-
>
)}
>
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-source.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-source.tsx
new file mode 100644
index 0000000000..491f24df81
--- /dev/null
+++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-source.tsx
@@ -0,0 +1,105 @@
+import { Control, UseFormSetValue } from "react-hook-form";
+
+import { WizardInputField } from "@/components/providers/workflow/forms/fields";
+import { Badge } from "@/components/shadcn/badge/badge";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/shadcn/select/select";
+import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+import { AWSCredentialsRole } from "@/types";
+
+interface AwsRoleCredentialsSourceProps {
+ control: Control