From ea36f12a0100f92e357ad4e150208a123ddfd508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 22 Sep 2026 18:21:55 +0200 Subject: [PATCH] feat(ui): connect AWS accounts in a single wizard step (#12852) Co-authored-by: alejandrobailo --- ui/__tests__/msw/handlers/organizations.ts | 32 ++ ui/app/(prowler)/layout.tsx | 29 +- .../providers/providers-page.harness.tsx | 78 ++- .../providers-page.integration.test.tsx | 75 +++ .../aws-one-step-connect.changed.md | 1 + .../first-run-add-provider.changed.md | 1 + .../sidebar-add-provider-action.added.md | 1 + .../app-sidebar/app-sidebar-content.test.tsx | 72 ++- .../layout/app-sidebar/launch-scan-action.tsx | 60 ++- .../__tests__/onboarding-gate.test.tsx | 307 ++++++------ .../__tests__/onboarding-trigger.test.tsx | 18 + .../onboarding-welcome-modal.test.tsx | 83 --- ui/components/onboarding/onboarding-gate.tsx | 117 +++-- .../onboarding/onboarding-trigger.tsx | 8 +- .../onboarding/onboarding-welcome-modal.tsx | 42 -- .../aws-method-selector.test.tsx | 43 -- .../organizations/aws-method-selector.tsx | 50 -- .../organizations/org-setup-form.tsx | 14 + .../providers-accounts-view.test.tsx | 165 +++++- .../providers/providers-accounts-view.tsx | 50 +- .../use-provider-wizard-controller.test.tsx | 83 ++- .../hooks/use-provider-wizard-controller.ts | 38 +- .../wizard/provider-wizard-modal.test.tsx | 189 ++++++- .../wizard/provider-wizard-modal.tsx | 72 ++- .../provider-wizard-modal.utils.test.ts | 73 +++ .../wizard/provider-wizard-modal.utils.ts | 53 ++ .../steps/aws/aws-connect-step.test.tsx | 472 ++++++++++++++++++ .../wizard/steps/aws/aws-connect-step.tsx | 432 ++++++++++++++++ .../wizard/steps/aws/aws-connect.schema.ts | 52 ++ .../aws/aws-onboarding-method-tabs.test.tsx | 77 +++ .../steps/aws/aws-onboarding-method-tabs.tsx | 72 +++ .../wizard/steps/aws/aws-role-arn.test.ts | 25 + .../wizard/steps/aws/aws-role-arn.ts | 9 + .../steps/aws/connect-aws-account.test.ts | 340 +++++++++++++ .../wizard/steps/aws/connect-aws-account.ts | 191 +++++++ .../providers/wizard/steps/aws/types.ts | 16 + .../providers/wizard/steps/connect-step.tsx | 47 +- .../providers/wizard/wizard-stepper.tsx | 13 + .../workflow/credentials-role-helper.test.tsx | 89 ++++ .../workflow/credentials-role-helper.tsx | 147 +++--- .../workflow/forms/connect-account-form.tsx | 18 +- .../aws-role-credentials-form.tsx | 118 +---- .../aws-role-credentials-source.tsx | 105 ++++ .../aws-role-optional-fields.tsx | 37 ++ .../discovery-callout.test.tsx | 16 + .../discovery-callout/discovery-callout.tsx | 8 +- .../side-panel/side-panel-trigger.test.tsx | 15 + ui/lib/onboarding/README.md | 24 +- .../__tests__/gate-decision.test.ts | 17 + ui/lib/onboarding/first-run-marker.ts | 44 ++ ui/lib/onboarding/gate-decision.ts | 8 +- .../provider-funnel-events.test.ts | 56 +++ .../provider-funnel/provider-funnel-events.ts | 125 +++++ ui/lib/providers-navigation.ts | 22 + .../use-driver-tour.lifecycle.test.tsx | 198 ++++++++ ui/lib/tours/add-provider.tour.ts | 16 +- ui/lib/tours/use-driver-tour.ts | 54 +- ui/store/provider-wizard/store.ts | 20 + ui/store/ui/store-initializer.test.tsx | 25 +- ui/store/ui/store.ts | 12 +- ui/styles/tours.css | 8 + ui/tests/providers/providers-page.ts | 104 +--- ui/tests/providers/providers.md | 75 +-- ui/tests/providers/providers.spec.ts | 96 +--- ui/tests/scans/scans-page.ts | 6 +- ui/tests/setups/manage-registry.auth.setup.ts | 2 + ui/tests/sign-in-base/sign-in-base-page.ts | 18 +- ui/tests/sign-up/sign-up.md | 5 +- ui/tests/sign-up/sign-up.spec.ts | 10 +- ui/types/provider-wizard.ts | 9 + 70 files changed, 4076 insertions(+), 931 deletions(-) create mode 100644 ui/changelog.d/aws-one-step-connect.changed.md create mode 100644 ui/changelog.d/first-run-add-provider.changed.md create mode 100644 ui/changelog.d/sidebar-add-provider-action.added.md delete mode 100644 ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx delete mode 100644 ui/components/onboarding/onboarding-welcome-modal.tsx delete mode 100644 ui/components/providers/organizations/aws-method-selector.test.tsx delete mode 100644 ui/components/providers/organizations/aws-method-selector.tsx create mode 100644 ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx create mode 100644 ui/components/providers/wizard/steps/aws/aws-connect-step.tsx create mode 100644 ui/components/providers/wizard/steps/aws/aws-connect.schema.ts create mode 100644 ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx create mode 100644 ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx create mode 100644 ui/components/providers/wizard/steps/aws/aws-role-arn.test.ts create mode 100644 ui/components/providers/wizard/steps/aws/aws-role-arn.ts create mode 100644 ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts create mode 100644 ui/components/providers/wizard/steps/aws/connect-aws-account.ts create mode 100644 ui/components/providers/wizard/steps/aws/types.ts create mode 100644 ui/components/providers/workflow/credentials-role-helper.test.tsx create mode 100644 ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-source.tsx create mode 100644 ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-optional-fields.tsx create mode 100644 ui/lib/onboarding/first-run-marker.ts create mode 100644 ui/lib/provider-funnel/provider-funnel-events.test.ts create mode 100644 ui/lib/provider-funnel/provider-funnel-events.ts diff --git a/ui/__tests__/msw/handlers/organizations.ts b/ui/__tests__/msw/handlers/organizations.ts index 2755e9281c..d1750a9fd5 100644 --- a/ui/__tests__/msw/handlers/organizations.ts +++ b/ui/__tests__/msw/handlers/organizations.ts @@ -302,6 +302,7 @@ export const handlersForOrganizations = ( organizations.map((o) => o.secretId).filter((id): id is string => !!id), ); let orgSeq = 0; + let providerSeq = 0; let secretSeq = 0; /** Reads per connection task, so `executingPolls` can hold one task running. */ const connectionTaskReads = new Map(); @@ -565,6 +566,37 @@ export const handlersForOrganizations = ( HttpResponse.json({ data: [], meta: collectionMeta(0) }), ), + // --- single-account connect (AWS one-step form) ----------------------- + http.post(`${API}/providers`, async ({ request }) => { + const body = (await request.json()) as { + data: { attributes: { provider: string; uid: string; alias?: string } }; + }; + providerSeq += 1; + return HttpResponse.json( + { + data: { + id: `provider-created-${providerSeq}`, + type: "providers", + attributes: { + ...body.data.attributes, + connection: { connected: false, last_checked_at: null }, + }, + }, + }, + { status: 201 }, + ); + }), + + http.post(`${API}/providers/secrets`, () => { + secretSeq += 1; + return HttpResponse.json( + { + data: { id: `secret-created-${secretSeq}`, type: "provider-secrets" }, + }, + { status: 201 }, + ); + }), + // --- providers (uid resolution) + connection testing ----------------- http.get<{ id: string }>(`${API}/providers/:id`, ({ params }) => { const provider = fx.providers.find((p) => p.id === params.id); diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 4a6244ffec..ebb8a9c53c 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -59,6 +59,10 @@ export default async function RootLayout({ // Skip Cloud-only onboarding fetches and orchestrators in OSS. const cloudEnabled = isCloud(); + // Every deployment needs the provider count: it drives the first-run redirect + // and the sidebar's Add Provider action. + const providersPromise = getProviders({ page: 1, pageSize: 1 }); + // One-time server-side Registry gate per request: only an ELIGIBLE answer // shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started // here so it resolves in parallel with the Cloud onboarding fetches. @@ -69,29 +73,27 @@ export default async function RootLayout({ // Fail-open: unknown scan state is treated as "has data" so the banner never blocks // progression on a fetch error. let hasCompletedScan = true; - // Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open). - let hasProviders: boolean | undefined = false; // Scopes the onboarding steps' local markers, so resolving them for one // tenant does not silence them for another. let tenantId: string | null = null; if (cloudEnabled) { - const [providersData, scansByState] = await Promise.all([ - getProviders({ page: 1, pageSize: 1 }), - getScansByState(), - ]); + const scansByState = await getScansByState(); hasCompletedScan = Array.isArray(scansByState?.data) ? scansByState.data.some( (scan: { attributes?: { state?: string } }) => scan.attributes?.state === SCAN_STATES.COMPLETED, ) : true; - hasProviders = Array.isArray(providersData?.data) - ? providersData.data.length > 0 - : undefined; tenantId = (await auth())?.tenantId ?? null; } + const providersData = await providersPromise; + // Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open). + const hasProviders: boolean | undefined = Array.isArray(providersData?.data) + ? providersData.data.length > 0 + : undefined; + const registryEligible = (await registryAccessPromise).status === REGISTRY_ACCESS.ELIGIBLE; @@ -114,13 +116,12 @@ export default async function RootLayout({ - {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */} - + {/* Tri-state for both: an unknown count leaves the store unresolved and the gate closed. */} + + {/* Every deployment: an empty tenant lands on the add-provider wizard once. */} + {cloudEnabled && ( <> - {/* Single mount point so the watcher survives post-connect navigation. */} {/* Persistent banner shown only while a guided sequence is active. */} diff --git a/ui/app/(prowler)/providers/providers-page.harness.tsx b/ui/app/(prowler)/providers/providers-page.harness.tsx index 4c10b5b07b..74d249d719 100644 --- a/ui/app/(prowler)/providers/providers-page.harness.tsx +++ b/ui/app/(prowler)/providers/providers-page.harness.tsx @@ -54,6 +54,17 @@ export class ProvidersPageHarness extends BrowserHarness { return this.countRequests("POST", "/apply"); } + /** `POST /providers` alone; the substring match would also count secrets. */ + get providerCreateCallCount(): number { + return ( + this.countRequests("POST", "/providers") - this.secretCreateCallCount + ); + } + + get secretCreateCallCount(): number { + return this.countRequests("POST", "/providers/secrets"); + } + /** * Whether any apply asked the endpoint to include related resources, which it * rejects outright — a tripwire, not a preference. @@ -198,10 +209,75 @@ export class ProvidersPageHarness extends BrowserHarness { /** Enter the AWS Organizations onboarding flow from a fresh wizard. */ async chooseAwsOrganizations(): Promise { await this.selectProviderType(/Amazon Web Services/); - await this.chooseMethod(/Add Multiple Accounts With AWS Organizations/); + // AWS hosts its single-account/organization switch as tabs on its connect step. + const tab = await this.waitFor(() => + this.byRoleName("tab", /Full AWS Organization/), + ); + await this.user.click(tab); await this.waitForText(/Organization Details/); } + /** Labels of the wizard's progress stepper, top to bottom. */ + stepperLabels(): string[] { + return Array.from( + document.querySelectorAll('nav[aria-label="Wizard progress"] span'), + (node) => node.textContent?.trim() ?? "", + ); + } + + /** Wait until the AWS single-account connect step (with its method tabs) is showing. */ + async waitForAwsConnectStep(): Promise { + await this.waitFor( + () => this.byRoleName("tab", /Single AWS Account/), + undefined, + "AWS connect step", + ); + } + + /** Type the account id and static keys on the AWS one-step connect form. */ + async fillAwsAccountKeys({ + accountId, + accessKeyId, + secretAccessKey, + }: { + accountId: string; + accessKeyId: string; + secretAccessKey: string; + }): Promise { + const accountInput = await this.waitFor(() => + this.inputByName("providerUid"), + ); + await this.user.fill(accountInput, accountId); + const keyInput = await this.waitFor(() => + this.inputByName("aws_access_key_id"), + ); + await this.user.fill(keyInput, accessKeyId); + const secretInput = await this.waitFor(() => + this.inputByName("aws_secret_access_key"), + ); + await this.user.fill(secretInput, secretAccessKey); + } + + /** Submit the AWS one-step form; waits for its action to become enabled. */ + async connectAccount(): Promise { + await this.clickPrimary(/Connect account/); + } + + /** Wait until the connection test step is showing with its action ready. */ + async waitForConnectionTestStep(): Promise { + await this.waitForButton(/Check connection/, 10000); + } + + /** Switch back to a single account from the organization flow's tabs. */ + async switchToAwsSingleAccount(): Promise { + const tab = await this.waitFor( + () => this.byRoleName("tab", /Single AWS Account/), + undefined, + "Single AWS Account tab", + ); + await this.user.click(tab); + } + /** Select GCP and open the GCP Organization method card (no advance wait). */ async chooseGcpOrganizationsMethod(): Promise { await this.selectProviderType(/Google Cloud Platform/); diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx index 33b6cb91d0..3da8bc94f6 100644 --- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx @@ -212,6 +212,81 @@ describe("Organization onboarding wizard", () => { }, 60000); }); + describe("Wizard progress", () => { + it("drops the credentials row once AWS is picked, since one step covers both", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await harness.mount(); + expect(harness.stepperLabels()).toEqual([ + "Link a Provider", + "Authenticate Credentials", + "Validate Connection", + "Launch Scan", + ]); + + await harness.selectProviderType(/Amazon Web Services/); + await harness.waitForAwsConnectStep(); + + expect(harness.stepperLabels()).toEqual([ + "Link a Provider", + "Validate Connection", + "Launch Scan", + ]); + }, 40000); + }); + + describe("Single account with access keys", () => { + // Runs compiled by the React Compiler, unlike the unit suite: it guards the + // form's validity being read as a reactive value, not frozen in a memo. + it("enables Connect account once the form is filled and jumps to the connection test", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await harness.mount(); + await harness.selectProviderType(/Amazon Web Services/); + await harness.waitForAwsConnectStep(); + await harness.chooseMethod(/Static access keys/); + await harness.fillAwsAccountKeys({ + accountId: "210987654321", + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "secret-value", + }); + + await harness.connectAccount(); + + await harness.waitForConnectionTestStep(); + expect(harness.providerCreateCallCount).toBe(1); + expect(harness.secretCreateCallCount).toBe(1); + const secret = await harness.lastRequestBody<{ + data: { relationships: { provider: { data: { id: string } } } }; + }>("POST", "/providers/secrets"); + expect(secret?.data.relationships.provider.data.id).toBe( + "provider-created-1", + ); + }, 40000); + }); + + describe("Leaving the organization flow", () => { + it("keeps the method tabs on Organization Details and switches back to a single account", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await harness.mount(); + await harness.chooseAwsOrganizations(); + + await harness.switchToAwsSingleAccount(); + + await harness.waitForAwsConnectStep(); + expect(harness.hasOrganizationSetupStep()).toBe(false); + }, 40000); + + it("returns to the AWS single-account step when going back from Organization Details", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await harness.mount(); + await harness.chooseAwsOrganizations(); + + await harness.goBack(); + + await harness.waitForAwsConnectStep(); + expect(harness.hasOrganizationSetupStep()).toBe(false); + }, 40000); + }); + describe("Account selection", () => { it("disables blocked accounts and excludes them from the selectable count", async () => { const harness = new ProvidersPageHarness(awsOnboardingFixture()); diff --git a/ui/changelog.d/aws-one-step-connect.changed.md b/ui/changelog.d/aws-one-step-connect.changed.md new file mode 100644 index 0000000000..8a20db1c83 --- /dev/null +++ b/ui/changelog.d/aws-one-step-connect.changed.md @@ -0,0 +1 @@ +AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored with the account before the connection test diff --git a/ui/changelog.d/first-run-add-provider.changed.md b/ui/changelog.d/first-run-add-provider.changed.md new file mode 100644 index 0000000000..a40ae414e3 --- /dev/null +++ b/ui/changelog.d/first-run-add-provider.changed.md @@ -0,0 +1 @@ +New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal diff --git a/ui/changelog.d/sidebar-add-provider-action.added.md b/ui/changelog.d/sidebar-add-provider-action.added.md new file mode 100644 index 0000000000..29cc8aec45 --- /dev/null +++ b/ui/changelog.d/sidebar-add-provider-action.added.md @@ -0,0 +1 @@ +Sidebar action reads Add Provider while the tenant has no providers diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx index 8e33a31b86..055d173ae3 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -13,11 +13,13 @@ const { openCloudUpgradeMock, openLaunchScanModalMock, pathnameValue, + permissionsValue, pushMock, } = vi.hoisted(() => ({ openCloudUpgradeMock: vi.fn(), openLaunchScanModalMock: vi.fn(), pathnameValue: { current: "/findings" }, + permissionsValue: { current: {} as Record }, pushMock: vi.fn(), })); @@ -27,7 +29,7 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/hooks", () => ({ - useAuth: () => ({ permissions: {} }), + useAuth: () => ({ permissions: permissionsValue.current }), })); vi.mock("@/hooks/use-runtime-config", () => ({ @@ -54,11 +56,16 @@ vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({ describe("AppSidebarContent", () => { beforeEach(() => { pathnameValue.current = "/findings"; + permissionsValue.current = { manage_providers: true }; pushMock.mockClear(); openCloudUpgradeMock.mockClear(); openLaunchScanModalMock.mockClear(); useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); - useUIStore.setState({ registryEligible: false }); + useUIStore.setState({ + registryEligible: false, + hasProviders: false, + hasProvidersResolved: false, + }); }); afterEach(() => { @@ -163,6 +170,67 @@ describe("AppSidebarContent", () => { expect(pushMock).toHaveBeenCalledWith("/lighthouse"); }); + it("offers Add Provider instead of Launch Scan once the tenant is known to have no providers", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + useUIStore.setState({ hasProviders: false, hasProvidersResolved: true }); + + // When + render(); + + // Then + expect(screen.getByRole("link", { name: "Add Provider" })).toHaveAttribute( + "href", + "/providers?addProvider=true&addProviderSource=sidebar_cta", + ); + expect( + screen.queryByRole("link", { name: "Launch Scan" }), + ).not.toBeInTheDocument(); + }); + + it("keeps Launch Scan for a user who cannot add providers", () => { + // Given: an empty list may only mean limited visibility. + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + permissionsValue.current = { manage_providers: false }; + useUIStore.setState({ hasProviders: false, hasProvidersResolved: true }); + + // When + render(); + + // Then + expect(screen.getByRole("link", { name: "Launch Scan" })).toBeVisible(); + expect( + screen.queryByRole("link", { name: "Add Provider" }), + ).not.toBeInTheDocument(); + }); + + it("keeps Launch Scan while the provider count is still unresolved", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + useUIStore.setState({ hasProviders: false, hasProvidersResolved: false }); + + // When + render(); + + // Then + expect(screen.getByRole("link", { name: "Launch Scan" })).toBeVisible(); + expect( + screen.queryByRole("link", { name: "Add Provider" }), + ).not.toBeInTheDocument(); + }); + + it("keeps Launch Scan for a tenant that already has providers", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + useUIStore.setState({ hasProviders: true, hasProvidersResolved: true }); + + // When + render(); + + // Then + expect(screen.getByRole("link", { name: "Launch Scan" })).toBeVisible(); + }); + it("opens the current scan modal instead of navigating from the scans route", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/components/layout/app-sidebar/launch-scan-action.tsx b/ui/components/layout/app-sidebar/launch-scan-action.tsx index 1da429811f..26f0e55002 100644 --- a/ui/components/layout/app-sidebar/launch-scan-action.tsx +++ b/ui/components/layout/app-sidebar/launch-scan-action.tsx @@ -1,15 +1,28 @@ "use client"; -import { ScanLine } from "lucide-react"; +import { CloudCog, ScanLine } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Button } from "@/components/shadcn/button/button"; +import { useAuth } from "@/hooks"; +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_STEP, + SIDEBAR_CTA_VARIANT, + WIZARD_OPEN_SOURCE, +} from "@/lib/provider-funnel/provider-funnel-events"; +import { buildAddProviderHref } from "@/lib/providers-navigation"; import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation"; import { useScansStore } from "@/store"; +import { useUIStore } from "@/store/ui/store"; import type { AppSidebarSelectionHandler } from "./types"; +const ADD_PROVIDER_FROM_SIDEBAR_HREF = buildAddProviderHref( + WIZARD_OPEN_SOURCE.SIDEBAR_CTA, +); + interface LaunchScanActionProps { onSelect?: AppSidebarSelectionHandler; } @@ -28,8 +41,43 @@ export function LaunchScanAction({ onSelect }: LaunchScanActionProps) { const openLaunchScanModal = useScansStore( (state) => state.openLaunchScanModal, ); + const { permissions } = useAuth(); + // Only a confirmed empty tenant swaps the action; an unresolved count keeps Launch Scan. + const hasNoProviders = useUIStore( + (state) => state.hasProvidersResolved && !state.hasProviders, + ); + // Without the permission an empty list may just be limited visibility. + const needsFirstProvider = + hasNoProviders && permissions.manage_providers === true; const isScansPage = pathname.startsWith("/scans"); + if (needsFirstProvider) { + return ( + + ); + } + + const trackLaunchScan = () => + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED, + variant: SIDEBAR_CTA_VARIANT.LAUNCH_SCAN, + }); + if (isScansPage) { return ( diff --git a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx index 9a5f7cf88e..f83c8a0385 100644 --- a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx @@ -1,18 +1,23 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { isFirstRunHandled } from "@/lib/onboarding/first-run-marker"; import { addProviderTour } from "@/lib/tours/add-provider.tour"; import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter"; import { OnboardingGate } from "../onboarding-gate"; -const pushMock = vi.fn(); +const replaceMock = vi.fn(); const armMock = vi.fn(); const pathnameMock = vi.fn(); +const permissionsMock = vi.fn(); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ permissions: permissionsMock() }), +})); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: pushMock, replace: vi.fn() }), + useRouter: () => ({ push: vi.fn(), replace: replaceMock }), usePathname: () => pathnameMock(), })); @@ -27,20 +32,31 @@ const addProviderTourId = { version: addProviderTour.version, }; +const TENANT_A = "11111111-1111-4111-8111-111111111111"; +const TENANT_B = "22222222-2222-4222-8222-222222222222"; + +const CLOUD_FIRST_RUN_HREF = + "/providers?addProvider=true&addProviderSource=first_run&onboarding=add-provider"; +const OSS_FIRST_RUN_HREF = + "/providers?addProvider=true&addProviderSource=first_run"; + describe("OnboardingGate", () => { beforeEach(() => { window.localStorage.clear(); - pushMock.mockClear(); + replaceMock.mockClear(); armMock.mockClear(); pathnameMock.mockReturnValue("/"); + permissionsMock.mockReturnValue({ manage_providers: true }); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it.each(["/billing", "/billing/", "/billing/checkout"])( - "defers onboarding on %s without resolving it", + "defers the first run on %s without resolving it", (pathname) => { // Given pathnameMock.mockReturnValue(pathname); @@ -49,16 +65,13 @@ describe("OnboardingGate", () => { render(); // Then - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); + expect(replaceMock).not.toHaveBeenCalled(); expect(armMock).not.toHaveBeenCalled(); - expect(pushMock).not.toHaveBeenCalled(); + expect(isFirstRunHandled()).toBe(false); }, ); - it("offers onboarding after leaving billing without remounting the gate", async () => { + it("sends the user to add a provider after leaving billing, without remounting the gate", async () => { // Given pathnameMock.mockReturnValue("/billing"); const { rerender } = render(); @@ -68,170 +81,158 @@ describe("OnboardingGate", () => { rerender(); // Then - expect( - await screen.findByRole("button", { name: /get started/i }), - ).toBeInTheDocument(); - expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); - expect(armMock).not.toHaveBeenCalled(); + await waitFor(() => + expect(replaceMock).toHaveBeenCalledExactlyOnceWith(CLOUD_FIRST_RUN_HREF), + ); }); - it("does not suppress onboarding on a route that only shares the billing prefix", async () => { + it("does not defer on a route that only shares the billing prefix", async () => { // Given - pathnameMock.mockReturnValue("/billing-settings"); + pathnameMock.mockReturnValue("/billing-history"); // When render(); // Then - expect( - await screen.findByRole("button", { name: /get started/i }), - ).toBeInTheDocument(); + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); }); - describe("when the user has no providers and no completion record", () => { - it("shows the Welcome modal", async () => { + describe("when a Cloud tenant has no providers and never went through the first run", () => { + it("opens the add-provider wizard with its tour and arms the checkpoint", async () => { + // Given / When render(); - expect( - await screen.findByRole("button", { name: /get started/i }), - ).toBeInTheDocument(); - }); - }); - - describe("when the user already has providers", () => { - it("does not show the Welcome modal", async () => { - render(); - - await waitFor(() => { - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - }); - }); - }); - - describe("when a completion record already exists in this browser", () => { - it("does not show the Welcome modal", async () => { - localStorageAdapter.set(addProviderTourId, { - tourId: addProviderTour.id, - version: addProviderTour.version, - state: "dismissed", - completedAt: new Date().toISOString(), - }); - - render(); - - await waitFor(() => { - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - }); - }); - }); - - describe("when the gate flow is dismissed but later sequence flows are incomplete", () => { - it("does not show the Welcome modal for a later flow", async () => { - // Later flows are only reachable via the checkpoint/sequence, never the gate. - localStorageAdapter.set(addProviderTourId, { - tourId: addProviderTour.id, - version: addProviderTour.version, - state: "dismissed", - completedAt: new Date().toISOString(), - }); - - render(); - - await waitFor(() => { - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - }); - }); - }); - - describe("when hasProviders is undefined (fail-open)", () => { - it("does not show the Welcome modal", async () => { - // `undefined` mirrors the tri-state layout forwards on a failed provider fetch. - render(); - - await waitFor(() => { - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - }); - }); - - it("can be mounted with the prop omitted entirely (fail-open)", async () => { - render(); - - await waitFor(() => { - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - }); - }); - }); - - describe("when the user accepts the Welcome modal", () => { - it("navigates to the flow route with the onboarding query param and writes no record", async () => { - const user = userEvent.setup(); - render(); - const getStarted = await screen.findByRole("button", { - name: /get started/i, - }); - - await user.click(getStarted); - - expect(pushMock).toHaveBeenCalledWith( - "/providers?onboarding=add-provider", + // Then + await waitFor(() => + expect(replaceMock).toHaveBeenCalledExactlyOnceWith( + CLOUD_FIRST_RUN_HREF, + ), ); - expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); + expect(armMock).toHaveBeenCalledOnce(); }); - it("arms the onboarding checkpoint", async () => { - const user = userEvent.setup(); - render(); - const getStarted = await screen.findByRole("button", { - name: /get started/i, - }); + it("happens only once per tenant on this browser", async () => { + // Given + const { unmount } = render( + , + ); + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + unmount(); + replaceMock.mockClear(); - await user.click(getStarted); + // When + render(); - expect(armMock).toHaveBeenCalledTimes(1); + // Then + expect(isFirstRunHandled(TENANT_A)).toBe(true); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("honours a browser-wide marker written before markers were tenant-scoped", () => { + // Given: e2e storage state and pre-existing browsers set the bare key. + window.localStorage.setItem("prowler.onboarding.first-run", "true"); + + // When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + expect(isFirstRunHandled(TENANT_A)).toBe(true); + }); + + it("still runs for a different empty tenant on the same browser", async () => { + // Given + const { unmount } = render( + , + ); + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + unmount(); + replaceMock.mockClear(); + + // When + render(); + + // Then + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + expect(isFirstRunHandled(TENANT_B)).toBe(true); }); }); - describe("when the user dismisses the Welcome modal", () => { - it("writes a dismissed record and stops showing the modal", async () => { - const user = userEvent.setup(); + describe("when a self-hosted deployment has no providers", () => { + it("opens the add-provider wizard without the Cloud-only tour or checkpoint", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When render(); - const skip = await screen.findByRole("button", { - name: /skip for now/i, - }); - await user.click(skip); - - await waitFor(() => { - expect( - screen.queryByRole("button", { name: /skip for now/i }), - ).not.toBeInTheDocument(); - }); - const record = localStorageAdapter.get(addProviderTourId); - expect(record).not.toBeNull(); - expect(record?.state).toBe("dismissed"); - }); - - it("does NOT arm the onboarding checkpoint", async () => { - const user = userEvent.setup(); - render(); - const skip = await screen.findByRole("button", { - name: /skip for now/i, - }); - - await user.click(skip); - - // Skipping must never arm the checkpoint (user opted out). + // Then + await waitFor(() => + expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF), + ); expect(armMock).not.toHaveBeenCalled(); }); }); + + describe("when the user cannot add providers", () => { + it("leaves the user where they are, since an empty list may just be limited visibility", () => { + // Given + permissionsMock.mockReturnValue({ manage_providers: false }); + + // When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + expect(isFirstRunHandled()).toBe(false); + }); + }); + + describe("when the tenant already has providers", () => { + it("leaves the user where they are", () => { + // Given / When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + expect(isFirstRunHandled()).toBe(false); + }); + }); + + describe("when the add-provider tour was already resolved in this browser", () => { + it("leaves the user where they are", () => { + // Given + localStorageAdapter.set(addProviderTourId, { + tourId: addProviderTour.id, + version: addProviderTour.version, + state: "dismissed", + completedAt: new Date().toISOString(), + }); + + // When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + expect(armMock).not.toHaveBeenCalled(); + }); + }); + + describe("when the provider count is unknown (fail-open)", () => { + it("leaves the user where they are when the fetch failed", () => { + // Given / When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("can be mounted with the prop omitted entirely", () => { + // Given / When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx index e0f040f832..fdf884d257 100644 --- a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx @@ -118,6 +118,24 @@ describe("OnboardingTrigger", () => { ); }); + it("starts at the step the page asks for, skipping the ones before it", async () => { + // Given + searchParamsValue = new URLSearchParams("onboarding=add-provider"); + + // When + render( + , + ); + + // Then + await waitFor(() => + expect(startMock).toHaveBeenCalledExactlyOnceWith("provider-type"), + ); + }); + it("strips only the onboarding param and preserves other query params", async () => { searchParamsValue = new URLSearchParams( "scanId=scan-1&onboarding=add-provider&tab=completed", diff --git a/ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx b/ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx deleted file mode 100644 index a3665031c2..0000000000 --- a/ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { OnboardingWelcomeModal } from "../onboarding-welcome-modal"; - -describe("OnboardingWelcomeModal", () => { - describe("when open is true", () => { - it("renders the flow title and description", () => { - render( - , - ); - - expect(screen.getByText("Add your first provider")).toBeInTheDocument(); - expect( - screen.getByText( - "Connect a cloud account so Prowler has something to scan.", - ), - ).toBeInTheDocument(); - }); - - it("calls onAccept when the primary action is clicked", async () => { - const user = userEvent.setup(); - const onAccept = vi.fn(); - const onDismiss = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("button", { name: /get started/i })); - - expect(onAccept).toHaveBeenCalledTimes(1); - expect(onDismiss).not.toHaveBeenCalled(); - }); - - it("calls onDismiss when the skip action is clicked", async () => { - const user = userEvent.setup(); - const onAccept = vi.fn(); - const onDismiss = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("button", { name: /skip for now/i })); - - expect(onDismiss).toHaveBeenCalledTimes(1); - expect(onAccept).not.toHaveBeenCalled(); - }); - }); - - describe("when open is false", () => { - it("does not render the modal content", () => { - render( - , - ); - - expect( - screen.queryByRole("button", { name: /get started/i }), - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/ui/components/onboarding/onboarding-gate.tsx b/ui/components/onboarding/onboarding-gate.tsx index f32d411938..6f76318415 100644 --- a/ui/components/onboarding/onboarding-gate.tsx +++ b/ui/components/onboarding/onboarding-gate.tsx @@ -1,26 +1,40 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { useState } from "react"; -import { getOrderedFlows, shouldStartOnboarding } from "@/lib/onboarding"; +import { useAuth } from "@/hooks/use-auth"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { + getOrderedFlows, + type OnboardingFlow, + shouldStartOnboarding, +} from "@/lib/onboarding"; +import { + isFirstRunHandled, + markFirstRunHandled, +} from "@/lib/onboarding/first-run-marker"; +import { WIZARD_OPEN_SOURCE } from "@/lib/provider-funnel/provider-funnel-events"; +import { buildAddProviderHref } from "@/lib/providers-navigation"; +import { isCloud } from "@/lib/shared/env"; import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter"; -import { TOUR_COMPLETION_STATES } from "@/lib/tours/tour-types"; import { useTourCompletion } from "@/lib/tours/use-tour-completion"; import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint"; -import { OnboardingWelcomeModal } from "./onboarding-welcome-modal"; - interface OnboardingGateProps { - // `undefined` = fetch failed/ambiguous; fail-open (never force the modal). + // `undefined` = fetch failed/ambiguous; fail-open (never force the first run). hasProviders?: boolean; + // Scopes the first-run marker so one tenant's first run never silences another's. + tenantId?: string | null; } -// Mandatory new-user gate. Mounted once in the layout; decision derived during render -// via useSyncExternalStore — server renders nothing, no hydration mismatch. -export function OnboardingGate({ hasProviders }: OnboardingGateProps) { - const router = useRouter(); +// New-tenant gate. Mounted once in the layout: an empty tenant is sent straight to +// the add-provider wizard, once per tenant and browser. Renders nothing. +export function OnboardingGate({ + hasProviders, + tenantId = null, +}: OnboardingGateProps) { const pathname = usePathname(); + const { permissions } = useAuth(); // Billing must stay usable before onboarding; leaving it keeps the gate eligible. const isBillingRoute = pathname === "/billing" || pathname?.startsWith("/billing/"); @@ -28,52 +42,53 @@ export function OnboardingGate({ hasProviders }: OnboardingGateProps) { // Gate forces only the first flow (`add-provider`); remaining flows come via checkpoint/replay. const flow = getOrderedFlows()[0] ?? null; - // Returns null on server/first render — gate stays closed until resolved client-side. + // Returns null on server/first render; the redirect re-reads storage before acting. const completionRecord = useTourCompletion(flow?.tour ?? null); - // Session flag prevents the gate re-opening after accept/dismiss within this mount. - const [resolvedThisSession, setResolvedThisSession] = useState(false); - - const activeFlow = - flow && + const shouldRedirect = + flow !== null && !isBillingRoute && - !resolvedThisSession && - shouldStartOnboarding({ hasProviders, completionRecord }) - ? flow - : null; + shouldStartOnboarding({ + hasProviders, + canManageProviders: permissions.manage_providers === true, + completionRecord, + }); - if (!activeFlow) return null; + if (!shouldRedirect) return null; - const handleAccept = () => { - // Arm checkpoint only on explicit accept — skip must never arm it. + return ; +} + +interface FirstRunRedirectProps { + flow: OnboardingFlow; + tenantId: string | null; +} + +function FirstRunRedirect({ flow, tenantId }: FirstRunRedirectProps) { + const router = useRouter(); + + useMountEffect(() => { + // Hydration renders with an empty completion snapshot, so decide from storage here. + const tourId = { id: flow.tour.id, version: flow.tour.version }; + if ( + isFirstRunHandled(tenantId) || + localStorageAdapter.get(tourId) !== null + ) { + return; + } + + markFirstRunHandled(tenantId); + + const addProviderHref = buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN); + if (!isCloud()) { + router.replace(addProviderHref); + return; + } + + // Tours and the post-connect checkpoint are Cloud-only. useOnboardingCheckpointStore.getState().arm(); - setResolvedThisSession(true); - // Routes may already carry a query string, so pick the right separator. - const separator = activeFlow.route.includes("?") ? "&" : "?"; - router.push(`${activeFlow.route}${separator}onboarding=${activeFlow.id}`); - }; - - const handleDismiss = () => { - // Persist dismissal so the gate silently skips on future visits. - localStorageAdapter.set( - { id: activeFlow.tour.id, version: activeFlow.tour.version }, - { - tourId: activeFlow.tour.id, - version: activeFlow.tour.version, - state: TOUR_COMPLETION_STATES.DISMISSED, - completedAt: new Date().toISOString(), - }, - ); - setResolvedThisSession(true); - }; + router.replace(`${addProviderHref}&onboarding=${flow.id}`); + }); - return ( - - ); + return null; } diff --git a/ui/components/onboarding/onboarding-trigger.tsx b/ui/components/onboarding/onboarding-trigger.tsx index 2a1854f3c8..270afbd721 100644 --- a/ui/components/onboarding/onboarding-trigger.tsx +++ b/ui/components/onboarding/onboarding-trigger.tsx @@ -34,6 +34,8 @@ interface OnboardingTriggerProps { flow: OnboardingFlow; // force-started when the sequence names it or `?onboarding=` matches stepHandlers?: { [K in TTarget]?: TourStepHandlers }; configOverrides?: Partial; + // Step to begin from when the page already did what the earlier steps ask for. + startAtTarget?: TTarget; } // Latched per-trigger: `key` mounts a fresh runner on each re-trigger; `mode` drives param-strip logic. @@ -50,6 +52,7 @@ export function OnboardingTrigger({ flow, stepHandlers, configOverrides, + startAtTarget, }: OnboardingTriggerProps) { const searchParams = useSearchParams(); const param = searchParams?.get(ONBOARDING_PARAM) ?? null; // null outside Suspense context @@ -108,6 +111,7 @@ export function OnboardingTrigger({ queryString={request.queryString} stepHandlers={stepHandlers} configOverrides={configOverrides} + startAtTarget={startAtTarget} /> ); } @@ -118,6 +122,7 @@ interface OnboardingTourRunnerProps { queryString: string; stepHandlers?: { [K in TTarget]?: TourStepHandlers }; configOverrides?: Partial; + startAtTarget?: TTarget; } function OnboardingTourRunner({ @@ -126,6 +131,7 @@ function OnboardingTourRunner({ queryString, stepHandlers, configOverrides, + startAtTarget, }: OnboardingTourRunnerProps) { // onClosed is intentionally inert — the banner owns advance/exit for both modes. const { start } = useDriverTour(flow.tour, { @@ -144,7 +150,7 @@ function OnboardingTourRunner({ queueMicrotask(() => { if (cancelled) return; - start(); + start(startAtTarget); if (mode === "replay") { // Only strip when the param actually started this replay; a same-route // in-memory request leaves the URL untouched (no replaceState needed). diff --git a/ui/components/onboarding/onboarding-welcome-modal.tsx b/ui/components/onboarding/onboarding-welcome-modal.tsx deleted file mode 100644 index 566db34cba..0000000000 --- a/ui/components/onboarding/onboarding-welcome-modal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { Button } from "@/components/shadcn"; -import { DialogFooter } from "@/components/shadcn/dialog"; -import { Modal } from "@/components/shadcn/modal/modal"; - -interface OnboardingWelcomeModalProps { - open: boolean; - flowTitle?: string; - flowDescription?: string; - onAccept: () => void; - onDismiss: () => void; -} - -export function OnboardingWelcomeModal({ - open, - flowTitle, - flowDescription, - onAccept, - onDismiss, -}: OnboardingWelcomeModalProps) { - return ( - { - if (!next) onDismiss(); - }} - > - - {/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */} - - - - - ); -} diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx deleted file mode 100644 index 7ea1d9a95e..0000000000 --- a/ui/components/providers/organizations/aws-method-selector.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 { AwsMethodSelector } from "./aws-method-selector"; - -describe("AwsMethodSelector", () => { - afterEach(() => { - vi.unstubAllEnvs(); - useCloudUpgradeStore.getState().closeCloudUpgrade(); - }); - - it("opens the AWS Organizations upgrade in Local Server", async () => { - // Given - vi.stubEnv("UI_CLOUD_ENABLED", "false"); - const user = userEvent.setup(); - const onSelectOrganizations = vi.fn(); - - // When - render( - , - ); - - // Then - await user.click( - screen.getByRole("radio", { - name: /add multiple accounts with aws organizations/i, - }), - ); - - expect(onSelectOrganizations).not.toHaveBeenCalled(); - expect(screen.getByText("Cloud")).toBeVisible(); - expect(useCloudUpgradeStore.getState().activeFeature).toBe( - CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, - ); - }); -}); diff --git a/ui/components/providers/organizations/aws-method-selector.tsx b/ui/components/providers/organizations/aws-method-selector.tsx deleted file mode 100644 index 882adef1b2..0000000000 --- a/ui/components/providers/organizations/aws-method-selector.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Box, Boxes } from "lucide-react"; - -import { RadioCard } from "@/components/providers/radio-card"; -import { Badge } from "@/components/shadcn/badge/badge"; -import { isCloud } from "@/lib/shared/env"; -import { useCloudUpgradeStore } from "@/store"; -import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -interface AwsMethodSelectorProps { - onSelectSingle: () => void; - onSelectOrganizations: () => void; -} - -export function AwsMethodSelector({ - onSelectSingle, - onSelectOrganizations, -}: AwsMethodSelectorProps) { - const isCloudEnv = isCloud(); - const openCloudUpgrade = useCloudUpgradeStore( - (state) => state.openCloudUpgrade, - ); - - return ( -
-

- Select a method to add your accounts to Prowler. -

- - - - - isCloudEnv - ? onSelectOrganizations() - : openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS) - } - > - {!isCloudEnv && Cloud} - -
- ); -} diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index 5d2640dd4b..839cd3a365 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -10,6 +10,10 @@ import { z } from "zod"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { AWSProviderBadge } from "@/components/icons/providers-badge"; +import { + AWS_ONBOARDING_METHOD, + AwsOnboardingMethodTabs, +} from "@/components/providers/wizard/steps/aws/aws-onboarding-method-tabs"; import type { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls"; import { WIZARD_FOOTER_ACTION_TYPE } from "@/components/providers/wizard/steps/footer-controls"; import type { OrgWizardIntent } from "@/components/providers/wizard/types"; @@ -71,6 +75,8 @@ interface OrgSetupFormProps { onBack: () => void; onClose?: () => void; onNext: () => void; + /** Keeps the single/organization tabs on screen; absent when the flow was entered directly. */ + onSelectSingleAccount?: () => void; onFooterChange: (config: WizardFooterConfig) => void; onPhaseChange: (phase: OrgSetupPhase) => void; initialPhase?: OrgSetupPhase; @@ -82,6 +88,7 @@ export function OrgSetupForm({ onBack, onClose, onNext, + onSelectSingleAccount, onFooterChange, onPhaseChange, initialPhase = ORG_SETUP_PHASE.DETAILS, @@ -326,6 +333,13 @@ export function OrgSetupForm({ + {onSelectSingleAccount && ( + + )} +

Enter the Organization ID for the accounts you want to add to Prowler. diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index da2d3c10ef..abd1487e0d 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -1,18 +1,24 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + PROVIDER_FUNNEL_EVENT, + type ProviderFunnelDetail, +} from "@/lib/provider-funnel/provider-funnel-events"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; const { + onboardingTriggerSpy, providersAccountsTableSpy, refreshMock, replaceMock, searchParamsValue, } = vi.hoisted(() => ({ + onboardingTriggerSpy: vi.fn(), providersAccountsTableSpy: vi.fn(), refreshMock: vi.fn(), replaceMock: vi.fn(), @@ -28,6 +34,14 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(searchParamsValue.current), })); +vi.mock("@/components/onboarding", () => ({ + OnboardingTrigger: (props: { startAtTarget?: string }) => { + onboardingTriggerSpy(props); + return null; + }, + PageReady: () => null, +})); + vi.mock("@/components/providers/table", () => ({ SkeletonTableProviders: () =>

, })); @@ -132,9 +146,21 @@ const disconnectedProviders: ProviderProps[] = [ ]; describe("ProvidersAccountsView", () => { + 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); vi.restoreAllMocks(); providersAccountsTableSpy.mockClear(); + onboardingTriggerSpy.mockClear(); searchParamsValue.current = ""; window.history.replaceState({}, "", "/"); }); @@ -273,6 +299,143 @@ describe("ProvidersAccountsView", () => { expect(replaceMock).not.toHaveBeenCalled(); }); + it("signals which control opened the wizard", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Add Provider" })); + + // Then + expect(funnelSignals).toEqual([ + { step: "wizard_opened", source: "page_button" }, + ]); + }); + + it("signals the empty-state CTA as the wizard entry point", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + + // Then + expect(funnelSignals).toEqual([ + { step: "wizard_opened", source: "empty_state" }, + ]); + }); + + it("signals the entry point carried in the URL and cleans it on close", async () => { + // Given + searchParamsValue.current = + "tab=connected&addProvider=true&addProviderSource=sidebar_cta"; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // Then + expect(funnelSignals).toEqual([ + { step: "wizard_opened", source: "sidebar_cta" }, + ]); + + // When + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + "", + "/providers?tab=connected", + ); + }); + + it("treats an unknown URL entry point as a plain URL open", () => { + // Given + searchParamsValue.current = "addProvider=true&addProviderSource=made_up"; + + // When + render( + , + ); + + // Then + expect(funnelSignals).toEqual([{ step: "wizard_opened", source: "url" }]); + }); + + it("starts the tour at the provider-type step when the wizard is already open", () => { + // Given + searchParamsValue.current = "addProvider=true&onboarding=add-provider"; + + // When + render( + , + ); + + // Then: the welcome and "open the wizard" steps have nothing left to ask for. + expect(onboardingTriggerSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ startAtTarget: "provider-type" }), + ); + }); + + it("lets the tour start from its first step while the wizard is closed", () => { + // Given / When + render( + , + ); + + // Then + expect(onboardingTriggerSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ startAtTarget: undefined }), + ); + }); + it("keeps filters and table visible when providers are disconnected", () => { // Given/When render( diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index 2bbd24c0a1..b94202af38 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -16,10 +16,19 @@ import type { ProviderWizardInitialData, } from "@/components/providers/wizard/types"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { useMountEffect } from "@/hooks/use-mount-effect"; import { getFlowById } from "@/lib/onboarding"; +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_STEP, + WIZARD_OPEN_SOURCE, + type WizardOpenSource, +} from "@/lib/provider-funnel/provider-funnel-events"; import { ADD_PROVIDER_SEARCH_PARAM, ADD_PROVIDER_SEARCH_VALUE, + ADD_PROVIDER_SOURCE_PARAM, + resolveAddProviderSource, } from "@/lib/providers-navigation"; import { ADD_PROVIDER_TOUR_TARGETS, @@ -102,7 +111,25 @@ export function ProvidersAccountsView({ OrgWizardInitialData | undefined >(undefined); - const openProviderWizard = (initialData?: ProviderWizardInitialData) => { + const signalWizardOpened = (source: WizardOpenSource) => + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED, + source, + }); + + // A URL-opened wizard never goes through openProviderWizard, so signal it on mount. + useMountEffect(() => { + if (!shouldOpenProviderWizardFromUrl) return; + signalWizardOpened( + resolveAddProviderSource(searchParams.get(ADD_PROVIDER_SOURCE_PARAM)), + ); + }); + + const openProviderWizard = ( + source: WizardOpenSource, + initialData?: ProviderWizardInitialData, + ) => { + signalWizardOpened(source); setOrgWizardInitialData(undefined); setProviderWizardInitialData(initialData); setIsProviderWizardOpen(true); @@ -130,6 +157,7 @@ export function ProvidersAccountsView({ if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) { const params = new URLSearchParams(searchParams.toString()); params.delete(ADD_PROVIDER_SEARCH_PARAM); + params.delete(ADD_PROVIDER_SOURCE_PARAM); const query = params.toString(); window.history.replaceState( null, @@ -146,6 +174,12 @@ export function ProvidersAccountsView({ {/* Signals the navbar that this route's data has loaded (enables the replay icon). */} @@ -154,7 +188,9 @@ export function ProvidersAccountsView({ openProviderWizard()} + onOpenWizard={() => + openProviderWizard(WIZARD_OPEN_SOURCE.EMPTY_STATE) + } ctaTourId="add-provider-trigger" /> ) : ( @@ -175,7 +211,11 @@ export function ProvidersAccountsView({ actions={ <> - openProviderWizard()} /> + + openProviderWizard(WIZARD_OPEN_SOURCE.PAGE_BUTTON) + } + /> } /> @@ -186,7 +226,9 @@ export function ProvidersAccountsView({ scanScheduleCapability={scanScheduleCapability} scanConfigs={scanConfigs} scanConfigStatus={scanConfigStatus} - onOpenProviderWizard={openProviderWizard} + onOpenProviderWizard={(initialData) => + openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData) + } onOpenOrganizationWizard={openOrganizationWizard} />
diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index ea8892ea5b..459d25106a 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -1,6 +1,10 @@ import { act, renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + PROVIDER_FUNNEL_EVENT, + type ProviderFunnelDetail, +} from "@/lib/provider-funnel/provider-funnel-events"; import { useOrgSetupStore } from "@/store/organizations/store"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { ORG_WIZARD_STEP, ORGANIZATION_TYPE } from "@/types/organizations"; @@ -40,7 +44,18 @@ vi.mock("next-auth/react", () => ({ })); describe("useProviderWizardController", () => { + const funnelSignals: ProviderFunnelDetail[] = []; + const recordFunnelSignal: EventListener = (event) => { + funnelSignals.push((event as CustomEvent).detail); + }; + + afterEach(() => { + window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + }); + beforeEach(() => { + funnelSignals.length = 0; + window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); vi.useRealTimers(); vi.clearAllMocks(); requestOpenOnWizardCloseMock.mockClear(); @@ -144,6 +159,70 @@ describe("useProviderWizardController", () => { expect(refreshMock).toHaveBeenCalledTimes(1); }); + it("signals the step where the wizard was left and that no provider was created", () => { + // Given + const { result } = renderHook(() => + useProviderWizardController({ open: true, onOpenChange: vi.fn() }), + ); + + // When + act(() => { + result.current.handleClose(); + }); + + // Then + expect(funnelSignals).toEqual([ + { step: "wizard_closed", lastStep: "connect", providerCreated: false }, + ]); + }); + + it("signals a close after the provider was created, from the step reached", () => { + // Given + const { result } = renderHook(() => + useProviderWizardController({ open: true, onOpenChange: vi.fn() }), + ); + act(() => { + useProviderWizardStore.getState().setProvider({ + id: "provider-1", + type: "aws", + uid: "123456789012", + alias: null, + }); + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + }); + + // When + act(() => { + result.current.handleClose(); + }); + + // Then + expect(funnelSignals).toEqual([ + { step: "wizard_closed", lastStep: "test", providerCreated: true }, + ]); + }); + + it("signals the organization method when the organizations flow opens", () => { + // Given + const { result } = renderHook(() => + useProviderWizardController({ open: true, onOpenChange: vi.fn() }), + ); + + // When + act(() => { + result.current.openOrganizationsFlow(ORGANIZATION_TYPE.AZURE); + }); + + // Then + expect(funnelSignals).toEqual([ + { + step: "method_selected", + providerType: "azure", + method: "organization", + }, + ]); + }); + it("hydrates update mode when initial data is provided", async () => { // Given const onOpenChange = vi.fn(); @@ -251,6 +330,8 @@ describe("useProviderWizardController", () => { expect(result.current.wizardVariant).toBe("provider"); expect(result.current.isProviderFlow).toBe(true); expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); + // Back lands on the AWS connect step the tabs live on, not the provider picker. + expect(result.current.providerTypeHint).toBe("aws"); }); it("moves to launch step after a successful connection test in add mode", () => { diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 0059c43a77..d73b6ce414 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -4,6 +4,11 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls"; +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_METHOD, + PROVIDER_FUNNEL_STEP, +} from "@/lib/provider-funnel/provider-funnel-events"; import { isCloud } from "@/lib/shared/env"; import { endActiveTour } from "@/lib/tours/use-driver-tour"; import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint"; @@ -44,6 +49,20 @@ const ORG_DOCS_URL = { [ORGANIZATION_TYPE.GCP]: DOCS_URLS.GCP_ORGANIZATIONS, } as const satisfies Record; +// Stable names for the abandonment signal; the numeric step ids are not a contract. +const PROVIDER_STEP_NAME = { + [PROVIDER_WIZARD_STEP.CONNECT]: "connect", + [PROVIDER_WIZARD_STEP.CREDENTIALS]: "credentials", + [PROVIDER_WIZARD_STEP.TEST]: "test", + [PROVIDER_WIZARD_STEP.LAUNCH]: "launch", +} as const satisfies Record; + +const ORG_STEP_NAME = { + [ORG_WIZARD_STEP.SETUP]: "organizations_setup", + [ORG_WIZARD_STEP.VALIDATE]: "organizations_validate", + [ORG_WIZARD_STEP.LAUNCH]: "organizations_launch", +} as const satisfies Record; + const EMPTY_FOOTER_CONFIG: WizardFooterConfig = { showBack: false, backLabel: "Back", @@ -205,6 +224,15 @@ export function useProviderWizardController({ // Read providerId before reset clears it — non-null means a provider was connected. const connectedProviderId = useProviderWizardStore.getState().providerId; + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.WIZARD_CLOSED, + lastStep: + wizardVariant === WIZARD_VARIANT.PROVIDER + ? PROVIDER_STEP_NAME[currentStep] + : ORG_STEP_NAME[orgCurrentStep], + providerCreated: connectedProviderId !== null, + }); + resetProviderWizard(); resetOrgWizard(); setWizardVariant(WIZARD_VARIANT.PROVIDER); @@ -251,6 +279,11 @@ export function useProviderWizardController({ // Organizations diverges from the credentials path the tour guides toward; end // it so it doesn't dangle on a step that no longer fits. No-op off-onboarding. endActiveTour(); + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED, + providerType: orgType, + method: PROVIDER_FUNNEL_METHOD.ORGANIZATION, + }); resetOrgWizard(); setOrganizationType(orgType); setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS); @@ -261,11 +294,14 @@ export function useProviderWizardController({ }; const backToProviderFlow = () => { + // The AWS organization flow is entered from the AWS connect step's tabs, so + // going back lands on that step again instead of the provider picker. + const cameFromAwsConnect = organizationType === ORGANIZATION_TYPE.AWS; resetOrgWizard(); setWizardVariant(WIZARD_VARIANT.PROVIDER); setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(null); + setProviderTypeHint(cameFromAwsConnect ? "aws" : null); setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }; diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index f77a866831..5cf6d71e89 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -4,21 +4,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Toaster } from "@/components/shadcn/toast/Toaster"; import { resetToasts } from "@/components/shadcn/toast/use-toast"; +import { + PROVIDER_FUNNEL_EVENT, + type ProviderFunnelDetail, +} from "@/lib/provider-funnel/provider-funnel-events"; +import { endActiveTour } from "@/lib/tours/use-driver-tour"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { useUIStore } from "@/store/ui/store"; import { ProviderWizardModal } from "./provider-wizard-modal"; -const { addRegistryProvider, getInstalledRegistryProviderOptions } = vi.hoisted( - () => ({ - addRegistryProvider: vi.fn(), - getInstalledRegistryProviderOptions: vi.fn(), - }), -); +const { + addCredentialsProvider, + addProvider, + addRegistryProvider, + getInstalledRegistryProviderOptions, +} = vi.hoisted(() => ({ + addCredentialsProvider: vi.fn(), + addProvider: vi.fn(), + addRegistryProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), +})); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), })); -vi.mock("@/actions/providers/providers", () => ({ addProvider: vi.fn() })); +vi.mock("next-auth/react", () => ({ + useSession: () => ({ + data: { tenantId: "tenant-abc" }, + status: "authenticated", + }), +})); +vi.mock("@/actions/providers/providers", () => ({ + addCredentialsProvider, + addProvider, +})); vi.mock("@/actions/providers/registry-provider", () => ({ addRegistryProvider, })); @@ -40,7 +60,18 @@ vi.mock("./steps/credentials-step", () => ({ CredentialsStep: () =>

Credential details

, })); vi.mock("./steps/test-connection-step", () => ({ - TestConnectionStep: () => null, + TestConnectionStep: ({ + onResetCredentials, + }: { + onResetCredentials: () => void; + }) => ( + <> +

Connection test

+ + + ), })); vi.mock("./steps/launch-step", () => ({ LaunchStep: () => null })); vi.mock("../organizations/azure-org-setup-form", () => ({ @@ -132,6 +163,20 @@ describe("provider wizard account creation", () => { expect(await screen.findByText("Credential details")).toBeVisible(); }); + it("tells the rest of the app the tenant now has a provider", async () => { + // Given + useUIStore.setState({ hasProviders: false, hasProvidersResolved: true }); + addRegistryProvider.mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + await screen.findByText("Credential details"); + + // Then: the sidebar stops offering Add Provider without waiting for a reload. + expect(useUIStore.getState().hasProviders).toBe(true); + }); + it("restores Next after a failed creation and retries the same account", async () => { // Given const failure = { errors: [{ detail: "Creation failed. Try again." }] }; @@ -206,6 +251,134 @@ describe("provider wizard account creation", () => { expect(addRegistryProvider).toHaveBeenCalledTimes(2); }); + it("signals the provider type the user picked, once", async () => { + // Given + const funnelSignals: ProviderFunnelDetail[] = []; + const recordFunnelSignal: EventListener = (event) => { + funnelSignals.push((event as CustomEvent).detail); + }; + window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + const user = userEvent.setup(); + render(); + + await screen.findByRole("option", { name: "Acme Cloud Registry" }); + + // When + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + await screen.findByRole("radio", { name: /IAM Role/ }); + window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + + // Then + expect(funnelSignals).toEqual([ + { step: "provider_type_selected", providerType: "aws" }, + ]); + }); + + describe("when the user picks AWS", () => { + const ROLE_ARN = "arn:aws:iam::123456789012:role/ProwlerScan"; + + async function pickAws() { + const user = userEvent.setup(); + render(); + await screen.findByRole("option", { name: "Acme Cloud Registry" }); + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + await screen.findByRole("textbox", { name: /Role ARN/ }); + return user; + } + + it("connects the account and its credentials in one step, then tests the connection", async () => { + // Given + addProvider.mockResolvedValue({ data: { id: "provider-1" } }); + addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + const user = await pickAws(); + + // When + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + const connect = screen.getByRole("button", { name: "Connect account" }); + await waitFor(() => expect(connect).toBeEnabled()); + await user.click(connect); + + // Then: the separate credentials step never shows up. + expect(await screen.findByText("Connection test")).toBeVisible(); + expect(screen.queryByText("Credential details")).not.toBeInTheDocument(); + expect(useProviderWizardStore.getState()).toMatchObject({ + providerId: "provider-1", + secretId: "secret-1", + via: "role", + }); + }); + + it("returns to the one-step form when the connection test is stepped back from", async () => { + // Given + addProvider.mockResolvedValue({ data: { id: "provider-1" } }); + addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + const user = await pickAws(); + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + const connect = screen.getByRole("button", { name: "Connect account" }); + await waitFor(() => expect(connect).toBeEnabled()); + await user.click(connect); + await screen.findByText("Connection test"); + + // When + await user.click( + screen.getByRole("button", { name: "Reset credentials" }), + ); + + // Then: AWS never had a separate credentials step, so it lands on its own form. + expect( + await screen.findByRole("textbox", { name: /Role ARN/ }), + ).toBeVisible(); + expect(screen.queryByText("Credential details")).not.toBeInTheDocument(); + }); + + it("steps the tour aside once the account can be connected", async () => { + // Given + vi.mocked(endActiveTour).mockClear(); + const user = await pickAws(); + + // When + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + + // Then: the footer sits outside the tour's spotlight, so the tour ends + // right when the user is ready to press Connect account. + await waitFor(() => + expect( + screen.getByRole("button", { name: "Connect account" }), + ).toBeEnabled(), + ); + expect(endActiveTour).toHaveBeenCalled(); + }); + + it("goes back to the provider list", async () => { + // Given + const user = await pickAws(); + + // When + await user.click(screen.getByRole("button", { name: "Back" })); + + // Then + expect( + await screen.findByRole("option", { name: /Microsoft Azure/ }), + ).toBeVisible(); + expect( + screen.queryByRole("textbox", { name: /Role ARN/ }), + ).not.toBeInTheDocument(); + }); + }); + it("keeps native providers available during a Registry discovery error and retries", async () => { // Given getInstalledRegistryProviderOptions.mockRejectedValueOnce( diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index fab1a4440f..c86618fb1b 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -12,22 +12,25 @@ import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal"; import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability"; import { useScrollHint } from "@/hooks/use-scroll-hint"; +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_STEP, +} from "@/lib/provider-funnel/provider-funnel-events"; import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP, ORGANIZATION_TYPE, } from "@/types/organizations"; -import { - PROVIDER_WIZARD_MODE, - PROVIDER_WIZARD_STEP, -} from "@/types/provider-wizard"; +import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard"; import type { ScanScheduleCapability } from "@/types/schedules"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { + getCredentialsRetryStep, getOrganizationsStepperOffset, getProviderWizardDocsDestination, + getProviderWizardStepper, } from "./provider-wizard-modal.utils"; import { ConnectStep } from "./steps/connect-step"; import { CredentialsStep } from "./steps/credentials-step"; @@ -35,12 +38,7 @@ import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls"; import { LaunchStep } from "./steps/launch-step"; import { TestConnectionStep } from "./steps/test-connection-step"; import type { OrgWizardInitialData, ProviderWizardInitialData } from "./types"; -import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper"; - -const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice( - 0, - PROVIDER_WIZARD_STEP.LAUNCH, -); +import { WizardStepper } from "./wizard-stepper"; interface ProviderWizardModalProps { open: boolean; @@ -78,6 +76,7 @@ export function ProviderWizardModal({ organizationType, orgCurrentStep, orgSetupPhase, + providerTypeHint, resolvedFooterConfig, setCurrentStep, setFooterConfig, @@ -102,6 +101,11 @@ export function ProviderWizardModal({ isScheduleCapabilityLoading, } = useScanScheduleCapability(scanScheduleCapability); const docsDestination = getProviderWizardDocsDestination(docsLink); + const providerStepper = getProviderWizardStepper({ + mode, + providerType: providerTypeHint, + currentStep, + }); return ( - {/* Anchors the add-provider tour's final step to the wizard content and - footer, keeping the real form controls clickable under the overlay. */} -
+
{isProviderFlow ? ( ) : ( -
+ {/* Anchors the add-provider tour's final step to the form column only, so + its popover has room on the left, under the stepper. */} +
{ setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); // Reaching credentials is the tour's handoff point: end it so the // user continues on their own. No-op off-onboarding. endActiveTour(); }} + onCredentialsSaved={() => { + // AWS stored its credentials in the connect step: skip ahead and + // end the tour like any other handoff to the user. + setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + endActiveTour(); + }} onSelectOrganizations={openOrganizationsFlow} onFooterChange={setFooterConfig} onProviderTypeChange={(providerType) => { // Picking a type reveals the account-detail inputs. Advance the tour // to its wizard-body step, pinned beside the form. No-op off-onboarding. if (providerType) advanceActiveTour(); + // The form re-reports the same type on re-render; signal a pick once. + if (providerType && providerType !== providerTypeHint) { + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.PROVIDER_TYPE_SELECTED, + providerType, + }); + } setProviderTypeHint(providerType); }} /> @@ -200,7 +215,12 @@ export function ProviderWizardModal({ - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS) + setCurrentStep( + getCredentialsRetryStep({ + mode, + providerType: providerTypeHint, + }), + ) } onFooterChange={setFooterConfig} /> @@ -225,6 +245,9 @@ export function ProviderWizardModal({ onBack={ isOrgDirectEntry ? handleClose : backToProviderFlow } + onSelectSingleAccount={ + isOrgDirectEntry ? undefined : backToProviderFlow + } onClose={handleClose} onNext={() => { setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); @@ -347,7 +370,8 @@ export function ProviderWizardModal({ {(resolvedFooterConfig.showBack || resolvedFooterConfig.showSecondaryAction || resolvedFooterConfig.showAction) && ( -
+ // Outside the tour's spotlight, yet the way forward: keep it clickable. +
{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 ( +
+ +
+

1. Create the IAM role

+ +
+ +
+

2. Paste the role ARN

+
+ + {detectedAccountId && ( +

+ Account {detectedAccountId} will be added to Prowler. +

+ )} +
+ } + /> +
+ + + + + + + + + +
+ + ); +} + +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 ( +
+ + value.replace(/\D/g, "").slice(0, 12)} + /> + } + /> + } /> + + + ); +} + +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" : ""} -

+
+

+ {describeRole(integrationType)} +

- + Create the IAM role in AWS + + + -
-
- - or - -
-
+
+ + External ID: + + } /> +
-

- {isAmazonS3 - ? "Refer to the documentation" - : "Use one of the following templates to create the IAM role"} -

- - - -
- - External ID: - - } /> -
-
+ +
); }; 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 = ({ )}
- - Specify which AWS credentials to use - - -
- -
- - {credentialsType === "access-secret-key" && ( - <> - - - - - )} + {type === "providers" ? ( @@ -210,31 +136,7 @@ export const AWSRoleCredentialsForm = ({ isRequired /> - - Optional fields - -
- - -
+ )} 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; + setValue: UseFormSetValue; + credentialsType: string; + isCloudEnv: boolean; +} + +/** Which credentials Prowler uses to assume the role, plus the keys when they are static. */ +export const AwsRoleCredentialsSource = ({ + control, + setValue, + credentialsType, + isCloudEnv, +}: AwsRoleCredentialsSourceProps) => ( + <> +
+ + Specify which AWS credentials to use + + +
+ + {credentialsType === "access-secret-key" && ( + <> + + + + + )} + +); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-optional-fields.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-optional-fields.tsx new file mode 100644 index 0000000000..9522da6ed2 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-optional-fields.tsx @@ -0,0 +1,37 @@ +import { Control } from "react-hook-form"; + +import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { AWSCredentialsRole } from "@/types"; + +interface AwsRoleOptionalFieldsProps { + control: Control; +} + +/** Session name and duration of the assumed role; both optional. */ +export const AwsRoleOptionalFields = ({ + control, +}: AwsRoleOptionalFieldsProps) => ( +
+ + +
+); diff --git a/ui/components/shadcn/discovery-callout/discovery-callout.test.tsx b/ui/components/shadcn/discovery-callout/discovery-callout.test.tsx index e8870c4f0b..0f6ab44169 100644 --- a/ui/components/shadcn/discovery-callout/discovery-callout.test.tsx +++ b/ui/components/shadcn/discovery-callout/discovery-callout.test.tsx @@ -55,6 +55,22 @@ describe("DiscoveryCallout", () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + it("stays open when focus moves elsewhere, e.g. to a product tour", () => { + // Given + const onDismiss = vi.fn(); + renderCallout(true, onDismiss); + const elsewhere = document.createElement("button"); + document.body.appendChild(elsewhere); + + // When: driver.js focuses its own popover as a tour starts. + fireEvent.focusIn(elsewhere); + + // Then: a passive hint is not dismissed by focus it never held. + expect(onDismiss).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Got it" })).toBeInTheDocument(); + elsewhere.remove(); + }); + it("keeps focus free when it opens", () => { // Given / When: the callout opens on its own (not user-invoked) renderCallout(true, vi.fn()); diff --git a/ui/components/shadcn/discovery-callout/discovery-callout.tsx b/ui/components/shadcn/discovery-callout/discovery-callout.tsx index f2c2aad8b4..70a9d1db27 100644 --- a/ui/components/shadcn/discovery-callout/discovery-callout.tsx +++ b/ui/components/shadcn/discovery-callout/discovery-callout.tsx @@ -62,8 +62,14 @@ export function DiscoveryCalloutContent({ side={side} align={align} sideOffset={8} - // A discovery hint must never steal focus from what the user is doing. + // A hint is worth showing even mid-tour: above driver.js's overlay (z 10000) + // and still dismissible while the tour locks the rest of the page. + className="z-[10001]" + data-tour-interactive + // A discovery hint must never steal focus from what the user is doing, + // nor vanish because something else took it (a tour popover, a form). onOpenAutoFocus={(event) => event.preventDefault()} + onFocusOutside={(event) => event.preventDefault()} data-testid={testId} >
diff --git a/ui/components/side-panel/side-panel-trigger.test.tsx b/ui/components/side-panel/side-panel-trigger.test.tsx index e5435a46e3..309e0cfc52 100644 --- a/ui/components/side-panel/side-panel-trigger.test.tsx +++ b/ui/components/side-panel/side-panel-trigger.test.tsx @@ -29,6 +29,7 @@ describe("SidePanelTrigger discovery callout", () => { }); afterEach(() => { + document.body.classList.remove("driver-active"); vi.useRealTimers(); }); @@ -49,6 +50,20 @@ describe("SidePanelTrigger discovery callout", () => { ).toBeInTheDocument(); }); + it("stays usable above a running product tour", () => { + // Given: driver.js dims and disables everything outside its spotlight. + document.body.classList.add("driver-active"); + render(); + + // When + act(() => vi.advanceTimersByTime(HINT_DELAY_MS)); + + // Then: the callout opts out of that, so it is visible and dismissible. + expect(screen.getByTestId("side-panel-ai-hint")).toHaveAttribute( + "data-tour-interactive", + ); + }); + it("never surfaces the callout again once seen", () => { // Given: a returning user useSidePanelStore.setState({ hasSeenAiTriggerHint: true }); diff --git a/ui/lib/onboarding/README.md b/ui/lib/onboarding/README.md index 0b76ec6ced..dd5f7f51a9 100644 --- a/ui/lib/onboarding/README.md +++ b/ui/lib/onboarding/README.md @@ -19,11 +19,33 @@ posts to the API. | Per-route trigger | `ui/components/onboarding/onboarding-trigger.tsx` | | Ephemeral sequence slice | `ui/store/onboarding-sequence.ts` | | Checkpoint watcher + dialog | `ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx` | -| Mandatory new-user gate | `ui/components/onboarding/onboarding-gate.tsx` | +| New-tenant gate (first-run redirect) | `ui/components/onboarding/onboarding-gate.tsx` | +| First-run marker (once per tenant) | `ui/lib/onboarding/first-run-marker.ts` | | Step outcome events (window) | `ui/lib/onboarding/onboarding-events.ts` | | Invite step before the checkpoint | `ui/components/onboarding/onboarding-invite-{step,dialog}.tsx` | | Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` | +## First run + +The gate is mounted in every deployment. When the tenant provably has no +providers (`hasProviders === false`), the user holds `manage_providers` and +neither the first-run marker (`prowler.onboarding.first-run.`, so a +first run in one tenant never silences it for another on the same browser; the +bare `prowler.onboarding.first-run` key is a browser-wide opt-out, which is what +the e2e storage state sets) nor an add-provider completion record exists, it +replaces the route once with +`/providers?addProvider=true&addProviderSource=first_run`, so the add-provider +wizard is already open. Billing routes defer it; an unknown provider count or a +user without the permission (an empty list may only mean limited visibility) +never triggers it. + +In Cloud the URL also carries `&onboarding=add-provider` and the checkpoint is +armed. Because the wizard is already open, the providers page passes +`startAtTarget="provider-type"` to its ``, which skips the +tour's welcome and "open the wizard" steps. A navbar replay with the wizard +closed still starts from the first step. Self-hosted deployments get the +redirect only: tours and the checkpoint stay Cloud-only. + ## How the guided sequence works 1. The `(prowler)/layout.tsx` derives a tri-state `hasProviders` on every diff --git a/ui/lib/onboarding/__tests__/gate-decision.test.ts b/ui/lib/onboarding/__tests__/gate-decision.test.ts index 833987be7e..dbe551c2dc 100644 --- a/ui/lib/onboarding/__tests__/gate-decision.test.ts +++ b/ui/lib/onboarding/__tests__/gate-decision.test.ts @@ -18,14 +18,26 @@ describe("shouldStartOnboarding", () => { it("returns true for a zero-provider user with no completion record", () => { const result = shouldStartOnboarding({ hasProviders: false, + canManageProviders: true, completionRecord: null, }); expect(result).toBe(true); }); + it("returns false when the user cannot add providers, even in an empty tenant", () => { + // Limited-visibility users see zero providers without the tenant being empty. + const result = shouldStartOnboarding({ + hasProviders: false, + canManageProviders: false, + completionRecord: null, + }); + expect(result).toBe(false); + }); + it("returns false when the user already has providers", () => { const result = shouldStartOnboarding({ hasProviders: true, + canManageProviders: true, completionRecord: null, }); expect(result).toBe(false); @@ -34,6 +46,7 @@ describe("shouldStartOnboarding", () => { it("returns false when a dismissed record exists", () => { const result = shouldStartOnboarding({ hasProviders: false, + canManageProviders: true, completionRecord: recordWithState(TOUR_COMPLETION_STATES.DISMISSED), }); expect(result).toBe(false); @@ -42,6 +55,7 @@ describe("shouldStartOnboarding", () => { it("returns false when a completed record exists", () => { const result = shouldStartOnboarding({ hasProviders: false, + canManageProviders: true, completionRecord: recordWithState(TOUR_COMPLETION_STATES.COMPLETED), }); expect(result).toBe(false); @@ -50,6 +64,7 @@ describe("shouldStartOnboarding", () => { it("returns false when a skipped record exists", () => { const result = shouldStartOnboarding({ hasProviders: false, + canManageProviders: true, completionRecord: recordWithState(TOUR_COMPLETION_STATES.SKIPPED), }); expect(result).toBe(false); @@ -59,6 +74,7 @@ describe("shouldStartOnboarding", () => { // strict === false check rejects non-false values; don't force onboarding on unknown state const result = shouldStartOnboarding({ hasProviders: undefined, + canManageProviders: true, completionRecord: null, }); expect(result).toBe(false); @@ -67,6 +83,7 @@ describe("shouldStartOnboarding", () => { it("fails open when hasProviders is null", () => { const result = shouldStartOnboarding({ hasProviders: null as unknown as boolean, + canManageProviders: true, completionRecord: null, }); expect(result).toBe(false); diff --git a/ui/lib/onboarding/first-run-marker.ts b/ui/lib/onboarding/first-run-marker.ts new file mode 100644 index 0000000000..12e4a52ec9 --- /dev/null +++ b/ui/lib/onboarding/first-run-marker.ts @@ -0,0 +1,44 @@ +// Durable "this browser already went through the first-run redirect" memory. +// Self-hosted deployments run no tour, so no completion record would ever be +// written there; without this marker an empty tenant would be redirected on +// every page load. +// +// Scoped per tenant, like the other onboarding markers: going through the first +// run in one tenant must not silence it for another one on the same browser. +// The bare key is a browser-wide opt-out: written before markers were scoped, +// by e2e storage state, or when no usable tenant id exists. +const FIRST_RUN_MARKER_KEY = "prowler.onboarding.first-run"; + +// Tenant ids are UUIDs; anything else is refused rather than concatenated +// into a storage key. +const TENANT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function firstRunMarkerKey(tenantId?: string | null): string { + if (!tenantId || !TENANT_ID_PATTERN.test(tenantId)) { + return FIRST_RUN_MARKER_KEY; + } + return `${FIRST_RUN_MARKER_KEY}.${tenantId.toLowerCase()}`; +} + +export function isFirstRunHandled(tenantId?: string | null): boolean { + if (typeof window === "undefined") return true; + try { + return ( + window.localStorage.getItem(FIRST_RUN_MARKER_KEY) !== null || + window.localStorage.getItem(firstRunMarkerKey(tenantId)) !== null + ); + } catch { + // Unreadable storage must not redirect forever: treat as handled. + return true; + } +} + +export function markFirstRunHandled(tenantId?: string | null): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(firstRunMarkerKey(tenantId), "true"); + } catch { + // Non-fatal: a repeated redirect beats a thrown render. + } +} diff --git a/ui/lib/onboarding/gate-decision.ts b/ui/lib/onboarding/gate-decision.ts index 656a3b0cb4..39f7437a69 100644 --- a/ui/lib/onboarding/gate-decision.ts +++ b/ui/lib/onboarding/gate-decision.ts @@ -3,15 +3,19 @@ import type { TourCompletionRecord } from "@/lib/tours/tour-types"; export interface GateDecisionInput { // `undefined` allowed; strict `=== false` check below fails open on ambiguous signals. hasProviders: boolean | undefined; + // Limited-visibility users list zero providers in a tenant that is not empty. + canManageProviders: boolean; completionRecord: TourCompletionRecord | null; } -// Only forces onboarding when providers are provably absent and no record exists. +// Only forces onboarding when providers are provably absent, the user can add one +// and no record exists. export function shouldStartOnboarding({ hasProviders, + canManageProviders, completionRecord, }: GateDecisionInput): boolean { const hasNoRecord = completionRecord === null || completionRecord === undefined; - return hasProviders === false && hasNoRecord; + return hasProviders === false && canManageProviders && hasNoRecord; } diff --git a/ui/lib/provider-funnel/provider-funnel-events.test.ts b/ui/lib/provider-funnel/provider-funnel-events.test.ts new file mode 100644 index 0000000000..bd85ba01c6 --- /dev/null +++ b/ui/lib/provider-funnel/provider-funnel-events.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_EVENT, + PROVIDER_FUNNEL_STEP, + type ProviderFunnelDetail, + WIZARD_OPEN_SOURCE, +} from "./provider-funnel-events"; + +describe("dispatchProviderFunnel", () => { + const listeners: EventListener[] = []; + + const listen = (listener: (detail: ProviderFunnelDetail) => void) => { + const handler: EventListener = (event) => + listener((event as CustomEvent).detail); + listeners.push(handler); + window.addEventListener(PROVIDER_FUNNEL_EVENT, handler); + }; + + afterEach(() => { + listeners + .splice(0) + .forEach((handler) => + window.removeEventListener(PROVIDER_FUNNEL_EVENT, handler), + ); + }); + + it("delivers the step detail to an outside window listener", () => { + const received = vi.fn(); + listen(received); + + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED, + source: WIZARD_OPEN_SOURCE.SIDEBAR_CTA, + }); + + expect(received).toHaveBeenCalledExactlyOnceWith({ + step: "wizard_opened", + source: "sidebar_cta", + }); + }); + + it("is a no-op during server rendering, where there is no window", () => { + vi.stubGlobal("window", undefined); + + expect(() => + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.PROVIDER_TYPE_SELECTED, + providerType: "aws", + }), + ).not.toThrow(); + + vi.unstubAllGlobals(); + }); +}); diff --git a/ui/lib/provider-funnel/provider-funnel-events.ts b/ui/lib/provider-funnel/provider-funnel-events.ts new file mode 100644 index 0000000000..2d485b9536 --- /dev/null +++ b/ui/lib/provider-funnel/provider-funnel-events.ts @@ -0,0 +1,125 @@ +// Window events the add-provider journey dispatches as the user moves through +// it. They carry no listener of their own: a deployment that wants to observe +// the funnel (product analytics, for instance) subscribes from outside, so the +// UI stays free of any tracking dependency. Details are low-cardinality only: +// never a provider uid, alias, ARN or anything typed into a credentials form. +export const PROVIDER_FUNNEL_EVENT = "prowler:provider-funnel"; + +export const PROVIDER_FUNNEL_STEP = { + SIDEBAR_CTA_CLICKED: "sidebar_cta_clicked", + WIZARD_OPENED: "wizard_opened", + PROVIDER_TYPE_SELECTED: "provider_type_selected", + METHOD_SELECTED: "method_selected", + ROLE_TEMPLATE_OPENED: "role_template_opened", + ACCOUNT_SUBMITTED: "account_submitted", + WIZARD_CLOSED: "wizard_closed", +} as const; + +export type ProviderFunnelStep = + (typeof PROVIDER_FUNNEL_STEP)[keyof typeof PROVIDER_FUNNEL_STEP]; + +export const SIDEBAR_CTA_VARIANT = { + ADD_PROVIDER: "add_provider", + LAUNCH_SCAN: "launch_scan", +} as const; + +export type SidebarCtaVariant = + (typeof SIDEBAR_CTA_VARIANT)[keyof typeof SIDEBAR_CTA_VARIANT]; + +export const WIZARD_OPEN_SOURCE = { + FIRST_RUN: "first_run", + SIDEBAR_CTA: "sidebar_cta", + URL: "url", + PAGE_BUTTON: "page_button", + EMPTY_STATE: "empty_state", + ROW_ACTION: "row_action", +} as const; + +export type WizardOpenSource = + (typeof WIZARD_OPEN_SOURCE)[keyof typeof WIZARD_OPEN_SOURCE]; + +export const PROVIDER_FUNNEL_METHOD = { + SINGLE: "single", + ORGANIZATION: "organization", +} as const; + +export type ProviderFunnelMethod = + (typeof PROVIDER_FUNNEL_METHOD)[keyof typeof PROVIDER_FUNNEL_METHOD]; + +export const ROLE_TEMPLATE_KIND = { + CLOUDFORMATION_QUICK_CREATE: "cloudformation_quick_create", + CLOUDFORMATION_TEMPLATE: "cloudformation_template", + TERRAFORM: "terraform", +} as const; + +export type RoleTemplateKind = + (typeof ROLE_TEMPLATE_KIND)[keyof typeof ROLE_TEMPLATE_KIND]; + +export interface SidebarCtaClickedDetail { + step: typeof PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED; + variant: SidebarCtaVariant; +} + +export interface WizardOpenedDetail { + step: typeof PROVIDER_FUNNEL_STEP.WIZARD_OPENED; + source: WizardOpenSource; +} + +export interface ProviderTypeSelectedDetail { + step: typeof PROVIDER_FUNNEL_STEP.PROVIDER_TYPE_SELECTED; + providerType: string; +} + +export interface MethodSelectedDetail { + step: typeof PROVIDER_FUNNEL_STEP.METHOD_SELECTED; + providerType: string; + method: ProviderFunnelMethod; +} + +export interface RoleTemplateOpenedDetail { + step: typeof PROVIDER_FUNNEL_STEP.ROLE_TEMPLATE_OPENED; + template: RoleTemplateKind; +} + +export const ACCOUNT_SUBMIT_OUTCOME = { + SUCCESS: "success", + ERROR: "error", +} as const; + +export type AccountSubmitOutcome = + (typeof ACCOUNT_SUBMIT_OUTCOME)[keyof typeof ACCOUNT_SUBMIT_OUTCOME]; + +// Account and credentials sent together (the one-step AWS form). +export interface AccountSubmittedDetail { + step: typeof PROVIDER_FUNNEL_STEP.ACCOUNT_SUBMITTED; + providerType: string; + via: string; + outcome: AccountSubmitOutcome; +} + +export interface WizardClosedDetail { + step: typeof PROVIDER_FUNNEL_STEP.WIZARD_CLOSED; + lastStep: string; + // A provider record exists; it may still lack credentials or a connection. + providerCreated: boolean; +} + +export type ProviderFunnelDetail = + | SidebarCtaClickedDetail + | WizardOpenedDetail + | ProviderTypeSelectedDetail + | MethodSelectedDetail + | RoleTemplateOpenedDetail + | AccountSubmittedDetail + | WizardClosedDetail; + +export function dispatchProviderFunnel(detail: ProviderFunnelDetail): void { + if (typeof window === "undefined") return; + try { + window.dispatchEvent( + new CustomEvent(PROVIDER_FUNNEL_EVENT, { detail }), + ); + } catch { + // A listener that throws must never break the journey it observes. + } +} diff --git a/ui/lib/providers-navigation.ts b/ui/lib/providers-navigation.ts index 7428eed540..53b0b03ad3 100644 --- a/ui/lib/providers-navigation.ts +++ b/ui/lib/providers-navigation.ts @@ -1,3 +1,25 @@ +import { + WIZARD_OPEN_SOURCE, + type WizardOpenSource, +} from "@/lib/provider-funnel/provider-funnel-events"; + export const ADD_PROVIDER_SEARCH_PARAM = "addProvider"; export const ADD_PROVIDER_SEARCH_VALUE = "true"; export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`; + +// Optional hint telling the providers page which entry point opened the wizard. +export const ADD_PROVIDER_SOURCE_PARAM = "addProviderSource"; + +export const buildAddProviderHref = (source: WizardOpenSource): string => + `${ADD_PROVIDER_HREF}&${ADD_PROVIDER_SOURCE_PARAM}=${source}`; + +const WIZARD_OPEN_SOURCES: readonly string[] = + Object.values(WIZARD_OPEN_SOURCE); + +// Unknown or missing hints fall back to a plain URL-driven open. +export const resolveAddProviderSource = ( + value: string | null | undefined, +): WizardOpenSource => + value && WIZARD_OPEN_SOURCES.includes(value) + ? (value as WizardOpenSource) + : WIZARD_OPEN_SOURCE.URL; diff --git a/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx b/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx index 1c300787d4..55cc5f6cb0 100644 --- a/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx +++ b/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx @@ -11,6 +11,7 @@ const driverHarness = vi.hoisted(() => { drive: ReturnType; isActive: ReturnType; isLastStep: ReturnType; + setSteps: ReturnType; }> = []; const driverMock = vi.fn((config: { onDestroyed?: () => void }) => { @@ -27,6 +28,7 @@ const driverHarness = vi.hoisted(() => { isLastStep: vi.fn(() => false), moveNext: vi.fn(), movePrevious: vi.fn(), + setSteps: vi.fn(), }; instances.push(instance); return instance; @@ -195,4 +197,200 @@ describe("useDriverTour lifecycle", () => { // ...but no completion record was persisted, so the tour can reappear later. expect(store.get({ id: tour.id, version: tour.version })).toBeNull(); }); + + describe("when asked to start at an anchored step", () => { + const anchoredTour = { + id: "anchored-tour", + version: 1, + coversFiles: [], + steps: [ + { title: "Welcome", description: "Intro" }, + { target: "late", title: "Late anchor", description: "Inside a modal" }, + ], + } satisfies TourDefinition; + + function AnchoredProbe({ + onResult, + }: { + onResult: (result: UseDriverTourResult) => void; + }) { + onResult( + useDriverTour(anchoredTour, { autoOpen: false, store: createStore() }), + ); + return null; + } + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renumbers the tour from the anchor once it is in the DOM", async () => { + // Given + let latestResult: UseDriverTourResult | undefined; + render( (latestResult = result)} />); + const anchor = document.createElement("div"); + anchor.setAttribute("data-tour-id", "anchored-tour-late"); + document.body.appendChild(anchor); + + // When + await act(async () => { + latestResult?.start("late"); + }); + + // Then: the skipped steps describe UI the user already went through, + // so the tour reads "Step 1 of 1", not "Step 2 of 2". + const [instance] = driverHarness.instances; + expect(instance.setSteps).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ + popover: expect.objectContaining({ title: "Late anchor" }), + }), + ]); + expect(instance.drive).toHaveBeenCalledExactlyOnceWith(); + }); + + it("takes over from a pending auto-open so the tour starts at the anchor", async () => { + // Given: auto-open is armed but the caller asks for the anchored start first. + vi.useFakeTimers(); + let latestResult: UseDriverTourResult | undefined; + function AutoOpenAnchoredProbe() { + latestResult = useDriverTour(anchoredTour, { + autoOpen: true, + store: createStore(), + }); + return null; + } + render(); + act(() => { + latestResult?.start("late"); + }); + + // When: the auto-open delay elapses before the anchor exists. + act(() => { + vi.advanceTimersByTime(50); + }); + + // Then: nothing opens from the top. + const [instance] = driverHarness.instances; + expect(instance.drive).not.toHaveBeenCalled(); + + // When: the anchor mounts. + const anchor = document.createElement("div"); + anchor.setAttribute("data-tour-id", "anchored-tour-late"); + document.body.appendChild(anchor); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Then: the tour is driven once, renumbered from the anchor. + expect(instance.setSteps).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ + popover: expect.objectContaining({ title: "Late anchor" }), + }), + ]); + expect(instance.drive).toHaveBeenCalledExactlyOnceWith(); + }); + + it("plays the whole tour again when started from the top afterwards", async () => { + // Given + let latestResult: UseDriverTourResult | undefined; + render( (latestResult = result)} />); + const anchor = document.createElement("div"); + anchor.setAttribute("data-tour-id", "anchored-tour-late"); + document.body.appendChild(anchor); + await act(async () => { + latestResult?.start("late"); + }); + + // When + await act(async () => { + latestResult?.stop(); + latestResult?.start(); + }); + + // Then + const [instance] = driverHarness.instances; + expect(instance.setSteps).toHaveBeenLastCalledWith([ + expect.objectContaining({ + popover: expect.objectContaining({ title: "Welcome" }), + }), + expect.objectContaining({ + popover: expect.objectContaining({ title: "Late anchor" }), + }), + ]); + }); + + it("starts at the step when only its fallback anchor is in the DOM", async () => { + // Given + const tourWithFallback = { + ...anchoredTour, + id: "fallback-tour", + steps: [ + anchoredTour.steps[0], + { ...anchoredTour.steps[1], fallbackTarget: "stable" }, + ], + } satisfies TourDefinition; + let latestResult: UseDriverTourResult | undefined; + function FallbackProbe() { + latestResult = useDriverTour(tourWithFallback, { + autoOpen: false, + store: createStore(), + }); + return null; + } + render(); + const anchor = document.createElement("div"); + anchor.setAttribute("data-tour-id", "fallback-tour-stable"); + document.body.appendChild(anchor); + + // When + await act(async () => { + latestResult?.start("late"); + }); + + // Then + const [instance] = driverHarness.instances; + expect(instance.setSteps).toHaveBeenCalledExactlyOnceWith([ + expect.objectContaining({ + popover: expect.objectContaining({ title: "Late anchor" }), + }), + ]); + expect(instance.drive).toHaveBeenCalledExactlyOnceWith(); + }); + + it("stays closed when stopped before the anchor mounts", async () => { + // Given + let latestResult: UseDriverTourResult | undefined; + render( (latestResult = result)} />); + await act(async () => { + latestResult?.start("late"); + }); + + // When + await act(async () => { + latestResult?.stop(); + const anchor = document.createElement("div"); + anchor.setAttribute("data-tour-id", "anchored-tour-late"); + document.body.appendChild(anchor); + }); + + // Then + expect(driverHarness.instances[0].drive).not.toHaveBeenCalled(); + }); + + it("starts from the first step when the target is not part of the tour", async () => { + // Given + let latestResult: UseDriverTourResult | undefined; + render( (latestResult = result)} />); + + // When + await act(async () => { + latestResult?.start("unknown"); + }); + + // Then + expect( + driverHarness.instances[0].drive, + ).toHaveBeenCalledExactlyOnceWith(); + }); + }); }); diff --git a/ui/lib/tours/add-provider.tour.ts b/ui/lib/tours/add-provider.tour.ts index 614735d79b..ba0c5e22be 100644 --- a/ui/lib/tours/add-provider.tour.ts +++ b/ui/lib/tours/add-provider.tour.ts @@ -8,10 +8,11 @@ import { export const ADD_PROVIDER_TOUR_TARGETS = { TRIGGER: "trigger", PROVIDER_TYPE: "provider-type", - // Wraps the whole wizard modal so the final step's spotlight covers every input - // (UID, alias) and the footer — driver.js only keeps the highlighted element and - // its descendants interactive, so anchoring here stops the overlay from freezing - // those inputs. + // Wraps the wizard's form column so the final step's spotlight covers every + // input (UID, alias) — driver.js only keeps the highlighted element and its + // descendants interactive, so anchoring here stops the overlay from freezing + // those inputs. The footer sits outside the anchor and stays clickable through + // `data-tour-interactive` (see styles/tours.css). WIZARD_BODY: "wizard-body", } as const; @@ -53,15 +54,16 @@ export const addProviderTour = defineTour({ }, { target: "wizard-body", - // Pinned to the left of the form column, mirroring the provider-type step. + // Left of the form column, in the gap under the stepper and level with the + // footer the user continues from, so it never covers the form itself. side: TOUR_STEP_SIDES.LEFT, - align: TOUR_STEP_ALIGNMENTS.START, + align: TOUR_STEP_ALIGNMENTS.END, // Final step: stays until the user closes it or advances to credentials, which // the wizard ends the tour from. No Next button. autoAdvance: true, title: "Add your account details", description: - "Enter your account ID and an optional alias, then continue. From here you'll add credentials, test the connection, and launch your first scan — at your own pace.", + "Fill in the connection details for this provider, then continue. From here you'll test the connection and launch your first scan — at your own pace.", }, ], }); diff --git a/ui/lib/tours/use-driver-tour.ts b/ui/lib/tours/use-driver-tour.ts index d7228cd303..8fa4ac94a8 100644 --- a/ui/lib/tours/use-driver-tour.ts +++ b/ui/lib/tours/use-driver-tour.ts @@ -102,7 +102,8 @@ export interface UseDriverTourOptions { } export interface UseDriverTourResult { - start: () => void; + /** Optional step `target` to begin from, skipping the steps before it. */ + start: (startAtTarget?: string) => void; stop: () => void; /** True if a completion record exists for `(tour.id, tour.version)`. */ hasCompleted: boolean; @@ -259,6 +260,11 @@ export function useDriverTour( // tour would be marked resolved forever after a simple theme toggle. const teardownRef = useRef(false); + // Bumped by start() and stop() so a pending anchored start knows it went stale. + const startGenerationRef = useRef(0); + // Every adapted step, so start() can hand driver.js a trimmed or full list. + const stepsRef = useRef([]); + const tourId = tour.id; const tourVersion = tour.version; const existing = store.get({ id: tourId, version: tourVersion }); @@ -361,6 +367,7 @@ export function useDriverTour( }); driverRef.current = driver(config); + stepsRef.current = steps; return () => { const instance = driverRef.current; @@ -381,7 +388,11 @@ export function useDriverTour( const instance = driverRef.current; if (!instance || instance.isActive()) return; + // A start()/stop() issued meanwhile takes over: an anchored start must not + // be pre-empted by the full tour opening from the top. + const generation = startGenerationRef.current; const timer = window.setTimeout(() => { + if (startGenerationRef.current !== generation) return; if (!instance.isActive()) { activeTourInstance = instance; instance.drive(); @@ -394,13 +405,46 @@ export function useDriverTour( }, [autoOpen, enabled, hasCompleted, tourId, tourVersion]); return { - start: () => { + start: (startAtTarget) => { const instance = driverRef.current; if (!instance) return; - activeTourInstance = instance; - instance.drive(); + const generation = ++startGenerationRef.current; + + const startIndex = startAtTarget + ? tour.steps.findIndex((step) => step.target === startAtTarget) + : -1; + if (!startAtTarget || startIndex <= 0) { + instance.setSteps(stepsRef.current); + activeTourInstance = instance; + instance.drive(); + return; + } + + // The anchor may mount right after the caller (e.g. a modal opening), so wait for it. + // Either anchor will do, mirroring how adaptStep resolves the step's element. + const fallbackTarget = tour.steps[startIndex].fallbackTarget; + const anchorSelector = [startAtTarget, fallbackTarget] + .filter((target): target is string => target !== undefined) + .map((target) => getTourTargetSelector(tourId, target)) + .join(", "); + waitForElement(anchorSelector) + .then(() => { + if (startGenerationRef.current !== generation) return; + if (driverRef.current !== instance || instance.isActive()) return; + // The skipped steps describe UI the caller already went through, so + // the tour is renumbered from the anchor ("Step 1 of 2", not "3 of 4"). + instance.setSteps(stepsRef.current.slice(startIndex)); + activeTourInstance = instance; + instance.drive(); + }) + .catch(() => { + // Anchor never appeared (e.g. the modal was dismissed); skip the tour. + }); + }, + stop: () => { + startGenerationRef.current += 1; + driverRef.current?.destroy(); }, - stop: () => driverRef.current?.destroy(), hasCompleted, }; } diff --git a/ui/store/provider-wizard/store.ts b/ui/store/provider-wizard/store.ts index 90182dc88b..2d94eff7db 100644 --- a/ui/store/provider-wizard/store.ts +++ b/ui/store/provider-wizard/store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { + AwsConnectDraft, PROVIDER_WIZARD_MODE, ProviderWizardIdentity, ProviderWizardMode, @@ -16,10 +17,12 @@ interface ProviderWizardState { via: string | null; secretId: string | null; mode: ProviderWizardMode; + awsConnectDraft: AwsConnectDraft | null; setProvider: (provider: ProviderWizardIdentity) => void; setVia: (via: string | null) => void; setSecretId: (secretId: string | null) => void; setMode: (mode: ProviderWizardMode) => void; + setAwsConnectDraft: (patch: Partial) => void; reset: () => void; } @@ -31,6 +34,13 @@ const initialState = { via: null, secretId: null, mode: PROVIDER_WIZARD_MODE.ADD, + awsConnectDraft: null, +}; + +const EMPTY_AWS_CONNECT_DRAFT: AwsConnectDraft = { + method: "role", + roleValues: {}, + keysValues: {}, }; export const useProviderWizardStore = create()( @@ -47,11 +57,21 @@ export const useProviderWizardStore = create()( setVia: (via) => set({ via }), setSecretId: (secretId) => set({ secretId }), setMode: (mode) => set({ mode }), + setAwsConnectDraft: (patch) => + set((state) => ({ + awsConnectDraft: { + ...EMPTY_AWS_CONNECT_DRAFT, + ...state.awsConnectDraft, + ...patch, + }, + })), reset: () => set(initialState), }), { name: "provider-wizard-store", storage: createJSONStorage(() => sessionStorage), + // The draft may hold access keys: it never leaves memory. + partialize: ({ awsConnectDraft: _draft, ...persisted }) => persisted, }, ), ); diff --git a/ui/store/ui/store-initializer.test.tsx b/ui/store/ui/store-initializer.test.tsx index 01a3545bb9..77428af1c5 100644 --- a/ui/store/ui/store-initializer.test.tsx +++ b/ui/store/ui/store-initializer.test.tsx @@ -7,7 +7,11 @@ import { StoreInitializer } from "./store-initializer"; describe("StoreInitializer", () => { beforeEach(() => { localStorage.clear(); - useUIStore.setState({ hasProviders: false, registryEligible: false }); + useUIStore.setState({ + hasProviders: false, + hasProvidersResolved: false, + registryEligible: false, + }); }); it("keeps Registry hidden when the server sends no eligibility decision", () => { @@ -33,4 +37,23 @@ describe("StoreInitializer", () => { expect(persisted.state?.hasProviders).toBe(true); expect(persisted.state).not.toHaveProperty("registryEligible"); }); + + it("leaves the provider count unresolved when the server could not determine it", () => { + // Given / When + render(); + + // Then + expect(useUIStore.getState().hasProvidersResolved).toBe(false); + }); + + it("resolves a confirmed empty tenant without persisting the resolution", () => { + // Given / When + render(); + + // Then + expect(useUIStore.getState().hasProviders).toBe(false); + expect(useUIStore.getState().hasProvidersResolved).toBe(true); + const persisted = JSON.parse(localStorage.getItem("ui-store") ?? "{}"); + expect(persisted.state).not.toHaveProperty("hasProvidersResolved"); + }); }); diff --git a/ui/store/ui/store.ts b/ui/store/ui/store.ts index 5aa321e0b1..522658995a 100644 --- a/ui/store/ui/store.ts +++ b/ui/store/ui/store.ts @@ -4,6 +4,8 @@ import { persist } from "zustand/middleware"; interface UIStoreState { isSideMenuOpen: boolean; hasProviders: boolean; + // True once the server reported a definitive provider count for this session. + hasProvidersResolved: boolean; registryEligible: boolean; openSideMenu: () => void; @@ -17,17 +19,19 @@ export const useUIStore = create()( (set) => ({ isSideMenuOpen: false, hasProviders: false, + hasProvidersResolved: false, registryEligible: false, openSideMenu: () => set({ isSideMenuOpen: true }), closeSideMenu: () => set({ isSideMenuOpen: false }), - setHasProviders: (value: boolean) => set({ hasProviders: value }), + setHasProviders: (value: boolean) => + set({ hasProviders: value, hasProvidersResolved: true }), setRegistryEligible: (value: boolean) => set({ registryEligible: value }), }), { name: "ui-store", - // Registry eligibility is a per-request server decision; persisting it - // would resurface a stale entry on the next session before the server - // seed corrects it. + // Registry eligibility and the provider-count resolution are per-request + // server decisions; persisting them would resurface a stale entry on the + // next session before the server seed corrects it. partialize: ({ isSideMenuOpen, hasProviders }) => ({ isSideMenuOpen, hasProviders, diff --git a/ui/styles/tours.css b/ui/styles/tours.css index 3764e606b8..36828183ed 100644 --- a/ui/styles/tours.css +++ b/ui/styles/tours.css @@ -10,6 +10,14 @@ pointer-events: auto; } +/* Surfaces that must stay usable while a tour drives (the wizard footer, a + * discovery callout). Clickable regardless of the spotlight; a surface that lives + * in its own top-level layer (a portal) also rises above the overlay (z 10000). */ +.driver-active [data-tour-interactive], +.driver-active [data-tour-interactive] * { + pointer-events: auto; +} + @keyframes driver-fade-in { 0% { opacity: 0; diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index e9bd96cf08..ecb8755ccd 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -397,19 +397,21 @@ export class ProvidersPage extends BasePage { // "Add Provider" control; with zero providers the page renders the empty // state whose CTA is labelled "Open Add Provider modal" (button on // /providers, link on /scans). Only one of these is ever in the DOM at once. - this.addProviderButton = page + // Scoped to
: an empty tenant also gets an "Add Provider" CTA in the sidebar. + const main = page.getByRole("main"); + this.addProviderButton = main .getByRole("button", { name: "Add Provider", exact: true, }) .or( - page.getByRole("link", { + main.getByRole("link", { name: "Add Provider", exact: true, }), ) - .or(page.getByRole("button", { name: "Open Add Provider modal" })) - .or(page.getByRole("link", { name: "Open Add Provider modal" })); + .or(main.getByRole("button", { name: "Open Add Provider modal" })) + .or(main.getByRole("link", { name: "Open Add Provider modal" })); // Table displaying existing providers this.providersTable = page.getByRole("table"); @@ -701,13 +703,15 @@ export class ProvidersPage extends BasePage { await this.selectProviderRadio(this.githubProviderRadio); } - async selectAWSSingleAccountMethod(): Promise { - const singleAccountOption = this.page.getByRole("radio", { - name: "Add A Single AWS Cloud Account", - exact: true, - }); - await expect(singleAccountOption).toBeVisible({ timeout: 10000 }); - await singleAccountOption.click(); + // AWS picks its access method on the same step that registers the account. + async selectAwsAccessMethod(type: AWSCredentialType): Promise { + const name = + type === AWS_CREDENTIAL_OPTIONS.AWS_CREDENTIALS + ? "Static access keys" + : /IAM Role/; + const accessMethod = this.wizardModal.getByRole("radio", { name }); + await expect(accessMethod).toBeVisible({ timeout: 10000 }); + await accessMethod.click(); } async selectAzureSingleSubscriptionMethod(): Promise { @@ -729,12 +733,7 @@ export class ProvidersPage extends BasePage { } async selectAWSOrganizationsMethod(): Promise { - await this.page - .getByRole("radio", { - name: "Add Multiple Accounts With AWS Organizations", - exact: true, - }) - .click(); + await this.page.getByRole("tab", { name: /Full AWS Organization/ }).click(); } async verifyOrganizationsAuthenticationStepLoaded(): Promise { @@ -774,10 +773,12 @@ export class ProvidersPage extends BasePage { await this.page.getByRole("option", { name: optionName }).click(); } + // The account id is only typed for access keys; with a role it is read from the ARN. async fillAWSProviderDetails(data: AWSProviderData): Promise { - await this.selectAWSSingleAccountMethod(); - await expect(this.accountIdInput).toBeVisible({ timeout: 10000 }); - await this.accountIdInput.fill(data.accountId); + await expect(this.aliasInput).toBeVisible({ timeout: 10000 }); + if (await this.accountIdInput.isVisible().catch(() => false)) { + await this.accountIdInput.fill(data.accountId); + } if (data.alias) { await this.aliasInput.fill(data.alias); @@ -881,6 +882,7 @@ export class ProvidersPage extends BasePage { const actionNames = [ "Go to scans", "Authenticate", + "Connect account", "Next", "Save", "Check connection", @@ -1123,41 +1125,6 @@ export class ProvidersPage extends BasePage { async fillRoleCredentials(credentials: AWSProviderCredential): Promise { await expect(this.roleArnInput).toBeVisible({ timeout: 10000 }); - const accessKeyInputInWizard = this.wizardModal.getByPlaceholder( - "Enter the AWS Access Key ID", - ); - const secretKeyInputInWizard = this.wizardModal.getByPlaceholder( - "Enter the AWS Secret Access Key", - ); - const accessKeyId = - credentials.accessKeyId || process.env.E2E_AWS_PROVIDER_ACCESS_KEY; - const secretAccessKey = - credentials.secretAccessKey || process.env.E2E_AWS_PROVIDER_SECRET_KEY; - - const shouldFillStaticKeys = Boolean(accessKeyId || secretAccessKey); - if (shouldFillStaticKeys) { - const accessKeyIsVisible = await accessKeyInputInWizard - .isVisible() - .catch(() => false); - - // In cloud env the default can be SDK mode, so expose Access/Secret explicitly. - if (!accessKeyIsVisible) { - await this.selectAuthenticationMethod( - AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN, - ); - } - } - - if (accessKeyId) { - await expect(accessKeyInputInWizard).toBeVisible({ timeout: 10000 }); - await accessKeyInputInWizard.fill(accessKeyId); - await expect(accessKeyInputInWizard).toHaveValue(accessKeyId); - } - if (secretAccessKey) { - await expect(secretKeyInputInWizard).toBeVisible({ timeout: 10000 }); - await secretKeyInputInWizard.fill(secretAccessKey); - await expect(secretKeyInputInWizard).toHaveValue(secretAccessKey); - } if (credentials.roleArn) { await this.roleArnInput.fill(credentials.roleArn); } @@ -1673,33 +1640,6 @@ export class ProvidersPage extends BasePage { } } - async selectAuthenticationMethod(method: AWSCredentialType): Promise { - // Select the authentication method (shadcn Select renders as combobox + listbox) - - const trigger = this.page.locator('[role="combobox"]').filter({ - hasText: /AWS SDK Default|Prowler Cloud will assume|Access & Secret Key/i, - }); - - await trigger.click(); - - const listbox = this.page.getByRole("listbox"); - await expect(listbox).toBeVisible({ timeout: 10000 }); - - if (method === AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN) { - await this.page - .getByRole("option", { name: "Access & Secret Key" }) - .click({ force: true }); - } else if (method === AWS_CREDENTIAL_OPTIONS.AWS_SDK_DEFAULT) { - await this.page - .getByRole("option", { - name: /AWS SDK Default|Prowler Cloud will assume your IAM role/i, - }) - .click({ force: true }); - } else { - throw new Error(`Invalid authentication method: ${method}`); - } - } - async clickProviderRowActions(providerUid: string): Promise { // Click the actions dropdown for a specific provider row const row = this.providersTable.locator("tbody tr", { diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 6aacca06da..0bc1b9d47d 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -29,9 +29,9 @@ 1. Navigate to providers page 2. Click "Add Provider" button 3. Select AWS provider type -4. Fill provider details (account ID and alias) -5. Select "credentials" authentication type -6. Fill static credentials (access key and secret key) +4. On the single AWS step, select the "Access keys" access method +5. Fill provider details (account ID and alias) +6. Fill static credentials (access key and secret key) and click "Connect account" 7. Confirm provider connection without launching a scan 8. Verify return to Providers page 9. Verify provider exists in Providers table @@ -61,63 +61,6 @@ --- -## Test Case: `PROVIDER-E2E-002` - Add AWS Provider with Assume Role Credentials Access Key and Secret Key - -**Priority:** `critical` - -**Tags:** - -- type → @e2e, @serial -- feature → @providers -- provider → @aws - -**Description/Objective:** Validates the complete flow of adding a new AWS provider using role-based authentication with Access Key and Secret Key - -**Preconditions:** - -- Admin user authentication required (admin.auth.setup setup) -- Environment variables configured: E2E_AWS_PROVIDER_ACCOUNT_ID, E2E_AWS_PROVIDER_ACCESS_KEY, E2E_AWS_PROVIDER_SECRET_KEY, E2E_AWS_PROVIDER_ROLE_ARN -- Remove any existing provider with the same Account ID before starting the test -- This test must be run serially and never in parallel with other tests, as it requires the Account ID not to be already registered beforehand. - -### Flow Steps - -1. Navigate to providers page -2. Click "Add Provider" button -3. Select AWS provider type -4. Fill provider details (account ID and alias) -5. Select "role" authentication type -6. Fill role credentials (access key, secret key, and role ARN) -7. Confirm provider connection without launching a scan -8. Verify return to Providers page -9. Verify provider exists in Providers table - -### Expected Result - -- AWS provider successfully added with role credentials -- Provider connection validated without launching a scan -- User returned to Providers page -- Provider appears in Providers table with the expected UID - -### Key verification points - -- Provider page loads correctly -- Connect account page displays AWS option -- Role credentials form accepts all required fields -- Launch step appears -- Successful return to Providers page after closing the launch step -- Provider exists in Providers table (verified by account ID) -- Provider UID matches the expected value - -### Notes - -- Test uses environment variables for AWS credentials and role ARN -- Provider cleanup performed before each test to ensure clean state -- Requires valid AWS account with role assumption permissions -- Role ARN must be properly configured - ---- - ## Test Case: `PROVIDER-E2E-003` - Add Azure Provider with Static Credentials **Priority:** `critical` @@ -618,10 +561,10 @@ 1. Navigate to providers page 2. Click "Add Provider" button 3. Select AWS provider type -4. Fill provider details (account ID and alias) -5. Select "role" authentication type -6. Switch authentication method to "Use AWS SDK default credentials" -7. Fill role ARN using AWS SDK credential inputs +4. On the single AWS step, keep the "IAM Role" access method +5. Fill the alias (the account ID is read from the role ARN) +6. Nothing to choose: the role is assumed with the host's AWS SDK default credentials +7. Fill the role ARN and click "Connect account" 8. Confirm provider connection without launching a scan 9. Verify return to Providers page 10. Verify provider exists in Providers table @@ -637,8 +580,8 @@ - Provider page loads correctly - Connect account page displays AWS option -- Credentials form exposes AWS SDK default authentication method -- Role ARN field accepts provided value when SDK method is selected +- The role form asks for no credentials of its own: the AWS SDK default chain assumes the role +- Role ARN field accepts the provided value - Launch step appears - Successful return to Providers page after closing the launch step - Provider exists in Providers table (verified by account ID) diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 724428df5e..4052a1d4c0 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -108,16 +108,11 @@ test.describe("Add Provider", () => { // Select AWS provider await providersPage.selectAWSProvider(); - // Fill provider details - await providersPage.fillAWSProviderDetails(awsProviderData); - await providersPage.clickNext(); - - await providersPage.verifyCredentialsPageLoaded(); - - // Select static credentials type - await providersPage.selectCredentialsType( + // AWS registers the account and its credentials in a single step + await providersPage.selectAwsAccessMethod( AWS_CREDENTIAL_OPTIONS.AWS_CREDENTIALS, ); + await providersPage.fillAWSProviderDetails(awsProviderData); // Fill static credentials await providersPage.fillStaticCredentials(staticCredentials); @@ -130,73 +125,6 @@ test.describe("Add Provider", () => { }, ); - test( - "should add a new AWS provider with assume role credentials with Access Key and Secret Key", - { - tag: [ - "@critical", - "@e2e", - "@providers", - "@aws", - "@serial", - "@PROVIDER-E2E-002", - ], - }, - async ({ page }) => { - // Validate required environment variables - if (!roleArn) { - throw new Error( - "E2E_AWS_PROVIDER_ROLE_ARN environment variable is not set", - ); - } - - // Prepare test data for AWS provider - const awsProviderData: AWSProviderData = { - accountId: accountId, - alias: "Test E2E AWS Account - Credentials", - }; - - // Prepare role-based credentials - const roleCredentials: AWSProviderCredential = { - type: AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN, - accessKeyId: accessKey, - secretAccessKey: secretKey, - roleArn: roleArn, - }; - - // Navigate to providers page - await providersPage.goto(); - await providersPage.verifyPageLoaded(); - - // Start adding new provider - await providersPage.clickAddProvider(); - await providersPage.verifyConnectAccountPageLoaded(); - - // Select AWS provider - await providersPage.selectAWSProvider(); - - // Fill provider details - await providersPage.fillAWSProviderDetails(awsProviderData); - await providersPage.clickNext(); - - await providersPage.verifyCredentialsPageLoaded(); - - // Select role credentials type - await providersPage.selectCredentialsType( - AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN, - ); - - // Fill role credentials - await providersPage.fillRoleCredentials(roleCredentials); - await providersPage.clickNext(); - - // Confirm the provider connection without launching a scan - await providersPage.completeProviderConnectionWithoutLaunchingScan( - accountId, - ); - }, - ); - test( "should add a new AWS provider with assume role credentials using AWS SDK", { @@ -240,21 +168,15 @@ test.describe("Add Provider", () => { // Select AWS provider await providersPage.selectAWSProvider(); - // Fill provider details - await providersPage.fillAWSProviderDetails(awsProviderData); - await providersPage.clickNext(); - - // Select role credentials type - await providersPage.selectCredentialsType( + // AWS registers the account (read from the role ARN) and its + // credentials in a single step + await providersPage.selectAwsAccessMethod( AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN, ); - await providersPage.verifyCredentialsPageLoaded(); - - // Select Authentication Method - await providersPage.selectAuthenticationMethod( - AWS_CREDENTIAL_OPTIONS.AWS_SDK_DEFAULT, - ); + await providersPage.fillAWSProviderDetails(awsProviderData); + // The role is assumed with the credentials of the host running Prowler + // (AWS SDK default); the wizard asks for nothing else. // Fill role credentials await providersPage.fillRoleCredentials(roleCredentials); await providersPage.clickNext(); diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts index e75c2d3508..9a18d341c0 100644 --- a/ui/tests/scans/scans-page.ts +++ b/ui/tests/scans/scans-page.ts @@ -24,9 +24,9 @@ export class ScansPage extends BasePage { super(page); // Scan provider selection elements - // The sidebar exposes its own icon-button labeled "Launch Scan" - // (aria-label, wrapped in a Tooltip), so scoping by accessible name - // alone hits a strict-mode duplicate. Scope to the page-shell's + // The sidebar exposes its own action labeled "Launch Scan" (it reads + // "Add Provider" only while the tenant has no providers), so scoping by + // accessible name alone hits a strict-mode duplicate. Scope to the page-shell's // tabs-and-actions group, which only contains the visible-text // Launch Scan button. this.launchScanButton = page diff --git a/ui/tests/setups/manage-registry.auth.setup.ts b/ui/tests/setups/manage-registry.auth.setup.ts index bf3daac561..8fd5195af1 100644 --- a/ui/tests/setups/manage-registry.auth.setup.ts +++ b/ui/tests/setups/manage-registry.auth.setup.ts @@ -21,6 +21,8 @@ authManageRegistrySetup( const signInPage = new SignInPage(page); await signInPage.goto(); + // The fixture tenant has no providers: keep the first-run redirect out of the way. + await signInPage.skipFirstRunRedirect(); await signInPage.login(fixtureCredentials); await page.waitForURL("/"); await new RegistryPage(page).dismissWelcomeDialog(); diff --git a/ui/tests/sign-in-base/sign-in-base-page.ts b/ui/tests/sign-in-base/sign-in-base-page.ts index 8966e26831..799841f68a 100644 --- a/ui/tests/sign-in-base/sign-in-base-page.ts +++ b/ui/tests/sign-in-base/sign-in-base-page.ts @@ -395,7 +395,23 @@ export class SignInPage extends BasePage { ); } - await this.loginAndVerify(credentials); + await this.goto(); + await this.skipFirstRunRedirect(); + await this.login(credentials); + await this.verifySuccessfulLogin(); await this.page.context().storageState({ path: storagePath }); } + + /** + * An empty tenant redirects each fresh browser context to the add-provider + * wizard once. Suites expect a plain landing, so mark that first run as done + * with the browser-wide key (the per-tenant one is only written by the app) + * before signing in; sign-up.spec covers the redirect itself with a brand-new + * tenant. Call it on a page already on the app origin. + */ + async skipFirstRunRedirect(): Promise { + await this.page.evaluate(() => { + window.localStorage.setItem("prowler.onboarding.first-run", "true"); + }); + } } diff --git a/ui/tests/sign-up/sign-up.md b/ui/tests/sign-up/sign-up.md index 542d1c0778..00b4639dbf 100644 --- a/ui/tests/sign-up/sign-up.md +++ b/ui/tests/sign-up/sign-up.md @@ -33,13 +33,14 @@ ### Expected Result - Sign-up succeeds and redirects to Login. -- User can log in successfully using the created credentials and reach the home page. +- User can log in successfully using the created credentials. +- Because the new tenant has no providers, the first run lands on `/providers` with the add-provider wizard already open (instead of the home page). ### Key verification points - After submitting sign-up, the URL changes to `/sign-in`. - The newly created credentials can be used to sign in successfully. -- After login, the user lands on the home (`/`) and main content is visible. +- After login, the user lands on `/providers` and the "Adding A Provider" wizard is visible. ### Notes diff --git a/ui/tests/sign-up/sign-up.spec.ts b/ui/tests/sign-up/sign-up.spec.ts index 3c732c6497..62336db7c6 100644 --- a/ui/tests/sign-up/sign-up.spec.ts +++ b/ui/tests/sign-up/sign-up.spec.ts @@ -1,6 +1,7 @@ -import { test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { SignUpPage } from "./sign-up-page"; import { SignInPage } from "../sign-in-base/sign-in-base-page"; +import { ProvidersPage } from "../providers/providers-page"; import { makeSuffix } from "../helpers"; test.describe("Sign Up Flow", () => { @@ -45,7 +46,12 @@ test.describe("Sign Up Flow", () => { email: uniqueEmail, password: password, }); - await signInPage.verifySuccessfulLogin(); + + // A brand-new tenant has no providers, so the first run lands on the + // add-provider wizard instead of the Overview. + const providersPage = new ProvidersPage(page); + await expect(page).toHaveURL(/\/providers/); + await providersPage.verifyWizardModalOpen(); }, ); }); diff --git a/ui/types/provider-wizard.ts b/ui/types/provider-wizard.ts index c4ad805edd..8f8dd5c17a 100644 --- a/ui/types/provider-wizard.ts +++ b/ui/types/provider-wizard.ts @@ -24,3 +24,12 @@ export interface ProviderWizardIdentity { uid: string | null; alias: string | null; } + +export type AwsConnectDraftValues = Record; + +/** What the AWS connect step typed so far; in memory only, gone with the wizard. */ +export interface AwsConnectDraft { + method: string; + roleValues: AwsConnectDraftValues; + keysValues: AwsConnectDraftValues; +}