mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): connect AWS accounts in a single wizard step (#12852)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
50a9138bea
commit
ea36f12a01
@@ -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<string, number>();
|
||||
@@ -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);
|
||||
|
||||
+15
-14
@@ -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({
|
||||
<Suspense>
|
||||
<NavigationProgress />
|
||||
</Suspense>
|
||||
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
|
||||
<StoreInitializer
|
||||
values={{ hasProviders: hasProviders ?? false, registryEligible }}
|
||||
/>
|
||||
{/* Tri-state for both: an unknown count leaves the store unresolved and the gate closed. */}
|
||||
<StoreInitializer values={{ hasProviders, registryEligible }} />
|
||||
{/* Every deployment: an empty tenant lands on the add-provider wizard once. */}
|
||||
<OnboardingGate hasProviders={hasProviders} tenantId={tenantId} />
|
||||
{cloudEnabled && (
|
||||
<>
|
||||
<OnboardingGate hasProviders={hasProviders} />
|
||||
{/* Single mount point so the watcher survives post-connect navigation. */}
|
||||
<OnboardingCheckpointWatcher tenantId={tenantId} />
|
||||
{/* Persistent banner shown only while a guided sequence is active. */}
|
||||
|
||||
@@ -54,6 +54,17 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
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<OrgFixture> {
|
||||
/** Enter the AWS Organizations onboarding flow from a fresh wizard. */
|
||||
async chooseAwsOrganizations(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.clickPrimary(/Connect account/);
|
||||
}
|
||||
|
||||
/** Wait until the connection test step is showing with its action ready. */
|
||||
async waitForConnectionTestStep(): Promise<void> {
|
||||
await this.waitForButton(/Check connection/, 10000);
|
||||
}
|
||||
|
||||
/** Switch back to a single account from the organization flow's tabs. */
|
||||
async switchToAwsSingleAccount(): Promise<void> {
|
||||
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<void> {
|
||||
await this.selectProviderType(/Google Cloud Platform/);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal
|
||||
@@ -0,0 +1 @@
|
||||
Sidebar action reads Add Provider while the tenant has no providers
|
||||
@@ -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<string, boolean> },
|
||||
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(<AppSidebarContent />);
|
||||
|
||||
// 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(<AppSidebarContent />);
|
||||
|
||||
// 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(<AppSidebarContent />);
|
||||
|
||||
// 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(<AppSidebarContent />);
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -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 (
|
||||
<Button asChild size="lg" className="w-full">
|
||||
<Link
|
||||
href={ADD_PROVIDER_FROM_SIDEBAR_HREF}
|
||||
aria-label="Add Provider"
|
||||
onClick={() => {
|
||||
dispatchProviderFunnel({
|
||||
step: PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED,
|
||||
variant: SIDEBAR_CTA_VARIANT.ADD_PROVIDER,
|
||||
});
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<CloudCog aria-hidden="true" className="size-5" />
|
||||
<span>Add Provider</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const trackLaunchScan = () =>
|
||||
dispatchProviderFunnel({
|
||||
step: PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED,
|
||||
variant: SIDEBAR_CTA_VARIANT.LAUNCH_SCAN,
|
||||
});
|
||||
|
||||
if (isScansPage) {
|
||||
return (
|
||||
<Button
|
||||
@@ -38,6 +86,7 @@ export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
|
||||
className="w-full"
|
||||
aria-label="Launch Scan"
|
||||
onClick={() => {
|
||||
trackLaunchScan();
|
||||
openLaunchScanModal();
|
||||
onSelect?.();
|
||||
}}
|
||||
@@ -49,7 +98,14 @@ export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
|
||||
|
||||
return (
|
||||
<Button asChild size="lg" className="w-full">
|
||||
<Link href={LAUNCH_SCAN_HREF} aria-label="Launch Scan" onClick={onSelect}>
|
||||
<Link
|
||||
href={LAUNCH_SCAN_HREF}
|
||||
aria-label="Launch Scan"
|
||||
onClick={() => {
|
||||
trackLaunchScan();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<LaunchScanContent />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders={false} />);
|
||||
@@ -68,170 +81,158 @@ describe("OnboardingGate", () => {
|
||||
rerender(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
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(<OnboardingGate hasProviders={true} />);
|
||||
|
||||
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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
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(<OnboardingGate hasProviders={undefined} />);
|
||||
|
||||
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(<OnboardingGate />);
|
||||
|
||||
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(<OnboardingGate hasProviders={false} />);
|
||||
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(<OnboardingGate hasProviders={false} />);
|
||||
const getStarted = await screen.findByRole("button", {
|
||||
name: /get started/i,
|
||||
});
|
||||
it("happens only once per tenant on this browser", async () => {
|
||||
// Given
|
||||
const { unmount } = render(
|
||||
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
|
||||
);
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
|
||||
unmount();
|
||||
replaceMock.mockClear();
|
||||
|
||||
await user.click(getStarted);
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
|
||||
|
||||
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(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
|
||||
|
||||
// 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(
|
||||
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
|
||||
);
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
|
||||
unmount();
|
||||
replaceMock.mockClear();
|
||||
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} tenantId={TENANT_B} />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders={false} />);
|
||||
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(<OnboardingGate hasProviders={false} />);
|
||||
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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// 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(<OnboardingGate hasProviders={undefined} />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can be mounted with the prop omitted entirely", () => {
|
||||
// Given / When
|
||||
render(<OnboardingGate />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<OnboardingTrigger
|
||||
flow={addProviderFlow}
|
||||
startAtTarget="provider-type"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -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(
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle="Add your first provider"
|
||||
flowDescription="Connect a cloud account so Prowler has something to scan."
|
||||
onAccept={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle="Add your first provider"
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle="Add your first provider"
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<OnboardingWelcomeModal
|
||||
open={false}
|
||||
flowTitle="Add your first provider"
|
||||
onAccept={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <FirstRunRedirect flow={flow} tenantId={tenantId} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle={activeFlow.title}
|
||||
flowDescription={activeFlow.description}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ interface OnboardingTriggerProps<TTarget extends string = string> {
|
||||
flow: OnboardingFlow; // force-started when the sequence names it or `?onboarding=<id>` matches
|
||||
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
|
||||
configOverrides?: Partial<Config>;
|
||||
// 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<TTarget extends string = string>({
|
||||
flow,
|
||||
stepHandlers,
|
||||
configOverrides,
|
||||
startAtTarget,
|
||||
}: OnboardingTriggerProps<TTarget>) {
|
||||
const searchParams = useSearchParams();
|
||||
const param = searchParams?.get(ONBOARDING_PARAM) ?? null; // null outside Suspense context
|
||||
@@ -108,6 +111,7 @@ export function OnboardingTrigger<TTarget extends string = string>({
|
||||
queryString={request.queryString}
|
||||
stepHandlers={stepHandlers}
|
||||
configOverrides={configOverrides}
|
||||
startAtTarget={startAtTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +122,7 @@ interface OnboardingTourRunnerProps<TTarget extends string> {
|
||||
queryString: string;
|
||||
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
|
||||
configOverrides?: Partial<Config>;
|
||||
startAtTarget?: TTarget;
|
||||
}
|
||||
|
||||
function OnboardingTourRunner<TTarget extends string>({
|
||||
@@ -126,6 +131,7 @@ function OnboardingTourRunner<TTarget extends string>({
|
||||
queryString,
|
||||
stepHandlers,
|
||||
configOverrides,
|
||||
startAtTarget,
|
||||
}: OnboardingTourRunnerProps<TTarget>) {
|
||||
// onClosed is intentionally inert — the banner owns advance/exit for both modes.
|
||||
const { start } = useDriverTour(flow.tour, {
|
||||
@@ -144,7 +150,7 @@ function OnboardingTourRunner<TTarget extends string>({
|
||||
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).
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
open={open}
|
||||
title={flowTitle}
|
||||
description={flowDescription}
|
||||
size="lg"
|
||||
// Overlay/Escape/X counts as dismiss — gate persists the record once.
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onDismiss();
|
||||
}}
|
||||
>
|
||||
<DialogFooter>
|
||||
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
|
||||
<Button variant="outline" onClick={onDismiss}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button onClick={onAccept}>Get started</Button>
|
||||
</DialogFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<AwsMethodSelector
|
||||
onSelectSingle={vi.fn()}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a method to add your accounts to Prowler.
|
||||
</p>
|
||||
|
||||
<RadioCard
|
||||
icon={Box}
|
||||
title="Add A Single AWS Cloud Account"
|
||||
onClick={onSelectSingle}
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
icon={Boxes}
|
||||
title="Add Multiple Accounts With AWS Organizations"
|
||||
onClick={() =>
|
||||
isCloudEnv
|
||||
? onSelectOrganizations()
|
||||
: openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS)
|
||||
}
|
||||
>
|
||||
{!isCloudEnv && <Badge variant="cloud">Cloud</Badge>}
|
||||
</RadioCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{onSelectSingleAccount && (
|
||||
<AwsOnboardingMethodTabs
|
||||
value={AWS_ONBOARDING_METHOD.ORGANIZATION}
|
||||
onSelectSingle={onSelectSingleAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enter the Organization ID for the accounts you want to add to
|
||||
Prowler.
|
||||
|
||||
@@ -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: () => <div data-testid="providers-skeleton" />,
|
||||
}));
|
||||
@@ -132,9 +146,21 @@ const disconnectedProviders: ProviderProps[] = [
|
||||
];
|
||||
|
||||
describe("ProvidersAccountsView", () => {
|
||||
const funnelSignals: ProviderFunnelDetail[] = [];
|
||||
const recordFunnelSignal: EventListener = (event) => {
|
||||
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).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(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onboardingTriggerSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ startAtTarget: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps filters and table visible when providers are disconnected", () => {
|
||||
// Given/When
|
||||
render(
|
||||
|
||||
@@ -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({
|
||||
<OnboardingTrigger
|
||||
flow={addProviderFlow}
|
||||
configOverrides={ADD_PROVIDER_TOUR_CONFIG}
|
||||
// An already-open wizard (first run) leaves nothing for the earlier steps to ask.
|
||||
startAtTarget={
|
||||
isProviderWizardOpen
|
||||
? ADD_PROVIDER_TOUR_TARGETS.PROVIDER_TYPE
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
|
||||
@@ -154,7 +188,9 @@ export function ProvidersAccountsView({
|
||||
<NoProvidersAdded
|
||||
action="button"
|
||||
containerClassName="min-h-[calc(100dvh-28rem)]"
|
||||
onOpenWizard={() => openProviderWizard()}
|
||||
onOpenWizard={() =>
|
||||
openProviderWizard(WIZARD_OPEN_SOURCE.EMPTY_STATE)
|
||||
}
|
||||
ctaTourId="add-provider-trigger"
|
||||
/>
|
||||
) : (
|
||||
@@ -175,7 +211,11 @@ export function ProvidersAccountsView({
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
<AddProviderButton
|
||||
onOpenWizard={() =>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<ProviderFunnelDetail>).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", () => {
|
||||
|
||||
@@ -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<OrgFlowType, string>;
|
||||
|
||||
// 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<ProviderWizardStep, string>;
|
||||
|
||||
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<OrgWizardStep, string>;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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: () => <p>Credential details</p>,
|
||||
}));
|
||||
vi.mock("./steps/test-connection-step", () => ({
|
||||
TestConnectionStep: () => null,
|
||||
TestConnectionStep: ({
|
||||
onResetCredentials,
|
||||
}: {
|
||||
onResetCredentials: () => void;
|
||||
}) => (
|
||||
<>
|
||||
<p>Connection test</p>
|
||||
<button type="button" onClick={onResetCredentials}>
|
||||
Reset credentials
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
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<ProviderFunnelDetail>).detail);
|
||||
};
|
||||
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
||||
const user = userEvent.setup();
|
||||
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
|
||||
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(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
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(
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
@@ -126,22 +130,14 @@ export function ProviderWizardModal({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Anchors the add-provider tour's final step to the wizard content and
|
||||
footer, keeping the real form controls clickable under the overlay. */}
|
||||
<div
|
||||
data-tour-id="add-provider-wizard-body"
|
||||
className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8"
|
||||
>
|
||||
<div className="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden lg:mt-8">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
||||
<div className="mb-4 box-border w-full shrink-0 lg:mb-0 lg:w-[328px]">
|
||||
{isProviderFlow ? (
|
||||
<WizardStepper
|
||||
currentStep={currentStep}
|
||||
steps={
|
||||
mode === PROVIDER_WIZARD_MODE.UPDATE
|
||||
? UPDATE_MODE_WIZARD_STEPS
|
||||
: undefined
|
||||
}
|
||||
stepOffset={providerStepper.stepOffset}
|
||||
steps={providerStepper.steps}
|
||||
/>
|
||||
) : (
|
||||
<WizardStepper
|
||||
@@ -158,7 +154,12 @@ export function ProviderWizardModal({
|
||||
className="hidden w-[100px] min-w-0 shrink lg:block"
|
||||
/>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Anchors the add-provider tour's final step to the form column only, so
|
||||
its popover has room on the left, under the stepper. */}
|
||||
<div
|
||||
data-tour-id="add-provider-wizard-body"
|
||||
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -167,18 +168,32 @@ export function ProviderWizardModal({
|
||||
{isProviderFlow &&
|
||||
currentStep === PROVIDER_WIZARD_STEP.CONNECT && (
|
||||
<ConnectStep
|
||||
initialProviderType={providerTypeHint}
|
||||
onNext={() => {
|
||||
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({
|
||||
<TestConnectionStep
|
||||
onSuccess={handleTestSuccess}
|
||||
onResetCredentials={() =>
|
||||
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) && (
|
||||
<div className="mt-8 pt-6">
|
||||
// Outside the tour's spotlight, yet the way forward: keep it clickable.
|
||||
<div className="mt-8 pt-6" data-tour-interactive>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{resolvedFooterConfig.showBack && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AwsConnectUiState | null>(null);
|
||||
return (
|
||||
<>
|
||||
<AwsConnectStep
|
||||
formId={FORM_ID}
|
||||
onConnected={onConnected}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
onUiStateChange={setUiState}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={uiState?.actionDisabled ?? true}
|
||||
>
|
||||
{uiState?.actionLabel ?? "Connect account"}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep() {
|
||||
const onConnected = vi.fn();
|
||||
const onSelectOrganizations = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
onConnected={onConnected}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>,
|
||||
);
|
||||
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<ProviderFunnelDetail>).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(
|
||||
<Harness
|
||||
onConnected={onConnected}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<Harness
|
||||
onConnected={onConnected}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<Harness onConnected={vi.fn()} onSelectOrganizations={vi.fn()} />,
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Role ARN/ }),
|
||||
ROLE_ARN,
|
||||
);
|
||||
unmount();
|
||||
|
||||
// When
|
||||
useProviderWizardStore.getState().reset();
|
||||
render(<Harness onConnected={vi.fn()} onSelectOrganizations={vi.fn()} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T extends FieldValues>(
|
||||
form: UseFormReturn<T>,
|
||||
key: keyof Pick<AwsConnectDraft, "roleValues" | "keysValues">,
|
||||
) {
|
||||
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<AwsAccessMethod>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProviderTitleDocs providerType="aws" />
|
||||
|
||||
<AwsOnboardingMethodTabs
|
||||
value={AWS_ONBOARDING_METHOD.SINGLE}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="AWS access method"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Choose how Prowler should access your account.
|
||||
</p>
|
||||
<RadioCard
|
||||
icon={ShieldCheck}
|
||||
title="IAM Role"
|
||||
selected={isRole}
|
||||
disabled={isBusy}
|
||||
onClick={() => chooseMethod(AWS_ACCESS_METHOD.ROLE)}
|
||||
>
|
||||
<Badge variant="success" size="sm">
|
||||
Recommended
|
||||
</Badge>
|
||||
</RadioCard>
|
||||
<RadioCard
|
||||
icon={KeyRound}
|
||||
title="Static access keys"
|
||||
selected={!isRole}
|
||||
disabled={isBusy}
|
||||
onClick={() => chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isRole ? (
|
||||
<AwsRoleConnectForm
|
||||
formId={formId}
|
||||
onConnected={onConnected}
|
||||
onBusyChange={setIsBusy}
|
||||
onUiStateChange={onUiStateChange}
|
||||
/>
|
||||
) : (
|
||||
<AwsKeysConnectForm
|
||||
formId={formId}
|
||||
onConnected={onConnected}
|
||||
onBusyChange={setIsBusy}
|
||||
onUiStateChange={onUiStateChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectFormProps
|
||||
extends Pick<
|
||||
AwsConnectStepProps,
|
||||
"formId" | "onConnected" | "onUiStateChange"
|
||||
> {
|
||||
onBusyChange: (isBusy: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseAwsConnectSubmitOptions<T extends FieldValues> {
|
||||
form: UseFormReturn<T>;
|
||||
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<string, string>;
|
||||
onConnected: () => void;
|
||||
onBusyChange: (isBusy: boolean) => void;
|
||||
onUiStateChange: (state: AwsConnectUiState) => void;
|
||||
}
|
||||
|
||||
function useAwsConnectSubmit<T extends FieldValues>({
|
||||
form,
|
||||
method,
|
||||
accountField,
|
||||
accountResolved = true,
|
||||
extraValues,
|
||||
onConnected,
|
||||
onBusyChange,
|
||||
onUiStateChange,
|
||||
}: UseAwsConnectSubmitOptions<T>) {
|
||||
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<AwsRoleConnectValues>({
|
||||
resolver: zodResolver(
|
||||
awsRoleConnectSchema,
|
||||
) as unknown as Resolver<AwsRoleConnectValues>,
|
||||
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<AWSCredentialsRole>;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||
<section className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-semibold">1. Create the IAM role</h4>
|
||||
<CredentialsRoleHelper
|
||||
externalId={externalId}
|
||||
templateLinks={templateLinks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-semibold">2. Paste the role ARN</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<WizardInputField
|
||||
control={form.control}
|
||||
name={ProviderCredentialFields.ROLE_ARN}
|
||||
type="text"
|
||||
label="Role ARN"
|
||||
labelPlacement="inside"
|
||||
placeholder="arn:aws:iam::123456789012:role/ProwlerScan"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{detectedAccountId && (
|
||||
<p className="text-text-success-primary text-xs">
|
||||
Account {detectedAccountId} will be added to Prowler.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AliasField
|
||||
control={form.control as unknown as Control<FieldValues>}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Collapsible className="flex flex-col gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
className="group h-auto w-fit gap-1 p-0"
|
||||
>
|
||||
Advanced options
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="flex flex-col gap-4">
|
||||
<AwsRoleOptionalFields control={roleControl} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function AwsKeysConnectForm({
|
||||
formId,
|
||||
onConnected,
|
||||
onBusyChange,
|
||||
onUiStateChange,
|
||||
}: ConnectFormProps) {
|
||||
const form = useForm<AwsKeysConnectValues>({
|
||||
resolver: zodResolver(
|
||||
awsKeysConnectSchema,
|
||||
) as unknown as Resolver<AwsKeysConnectValues>,
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<WizardInputField
|
||||
control={form.control}
|
||||
name={ProviderCredentialFields.PROVIDER_UID}
|
||||
type="text"
|
||||
label="Account ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="e.g. 123456789012"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
normalizeValue={(value) => value.replace(/\D/g, "").slice(0, 12)}
|
||||
/>
|
||||
<AWSStaticCredentialsForm
|
||||
control={form.control as unknown as Control<AWSCredentials>}
|
||||
/>
|
||||
<AliasField control={form.control as unknown as Control<FieldValues>} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function AliasField({ control }: { control: Control<FieldValues> }) {
|
||||
return (
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.PROVIDER_ALIAS}
|
||||
type="text"
|
||||
label="Provider alias (optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the provider alias"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
<AwsOnboardingMethodTabs
|
||||
value={AWS_ONBOARDING_METHOD.SINGLE}
|
||||
onSelectSingle={vi.fn()}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AwsOnboardingMethodTabs
|
||||
value={AWS_ONBOARDING_METHOD.SINGLE}
|
||||
onSelectSingle={vi.fn()}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AwsOnboardingMethodTabs
|
||||
value={AWS_ONBOARDING_METHOD.ORGANIZATION}
|
||||
onSelectSingle={onSelectSingle}
|
||||
onSelectOrganizations={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Single AWS Account" }));
|
||||
|
||||
expect(onSelectSingle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
activationMode="manual"
|
||||
>
|
||||
<TabsList aria-label="AWS onboarding method">
|
||||
<TabsTrigger value={AWS_ONBOARDING_METHOD.SINGLE}>
|
||||
Single AWS Account
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={AWS_ONBOARDING_METHOD.ORGANIZATION}
|
||||
adornment={
|
||||
!isCloudEnv && (
|
||||
<Badge variant="cloud" size="sm">
|
||||
Cloud
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
Full AWS Organization
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>) => {
|
||||
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<AwsConnectResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<AwsConnectStep
|
||||
formId={formId}
|
||||
onConnected={onCredentialsSaved}
|
||||
onSelectOrganizations={() =>
|
||||
onSelectOrganizations(ORGANIZATION_TYPE.AWS)
|
||||
}
|
||||
onUiStateChange={setUiState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectAccountForm
|
||||
@@ -76,7 +115,7 @@ export function ConnectStep({
|
||||
hideNavigation
|
||||
onSuccess={handleSuccess}
|
||||
onSelectOrganizations={onSelectOrganizations}
|
||||
onProviderTypeChange={onProviderTypeChange}
|
||||
onProviderTypeChange={handleProviderTypeChange}
|
||||
onUiStateChange={setUiState}
|
||||
onBackHandlerChange={(handler) => {
|
||||
backHandlerRef.current = handler;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ProviderFunnelDetail>).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(
|
||||
<CredentialsRoleHelper
|
||||
externalId="tenant-1"
|
||||
templateLinks={templateLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<CredentialsRoleHelper
|
||||
externalId="tenant-1"
|
||||
templateLinks={templateLinks}
|
||||
integrationType="amazon_s3"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("link", { name: /Create the IAM role in AWS/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(funnelSignals).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
A <strong>read-only IAM role</strong> must be manually created
|
||||
{isAmazonS3 ? " or updated" : ""}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{describeRole(integrationType)}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
aria-label="Use the following AWS CloudFormation Quick Link to deploy the IAM Role"
|
||||
variant="link"
|
||||
className="h-auto w-fit min-w-0 p-0"
|
||||
asChild
|
||||
<Button size="lg" className="w-fit" asChild>
|
||||
<a
|
||||
href={templateLinks.cloudformationQuickLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
signalTemplateOpened(ROLE_TEMPLATE_KIND.CLOUDFORMATION_QUICK_CREATE)
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={templateLinks.cloudformationQuickLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Use the following AWS CloudFormation Quick Link to create the IAM
|
||||
Role
|
||||
</a>
|
||||
</Button>
|
||||
Create the IAM role in AWS
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs font-bold text-gray-900 dark:text-gray-300">
|
||||
or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary block text-xs font-medium">
|
||||
External ID:
|
||||
</span>
|
||||
<CodeSnippet value={externalId} icon={<IdIcon size={16} />} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{isAmazonS3
|
||||
? "Refer to the documentation"
|
||||
: "Use one of the following templates to create the IAM role"}
|
||||
</p>
|
||||
|
||||
<div className="flex w-fit flex-col gap-2">
|
||||
<Collapsible className="flex flex-col gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label="CloudFormation Template"
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto w-fit min-w-0 p-0"
|
||||
asChild
|
||||
size="link-sm"
|
||||
className="group h-auto w-fit gap-1 p-0"
|
||||
>
|
||||
Other ways to create the role
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="flex w-fit flex-col gap-2">
|
||||
{integrationType === "amazon_s3" && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Refer to the documentation
|
||||
</p>
|
||||
)}
|
||||
<Button variant="link" className="h-auto w-fit min-w-0 p-0" asChild>
|
||||
<a
|
||||
href={templateLinks.cloudformation}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
signalTemplateOpened(ROLE_TEMPLATE_KIND.CLOUDFORMATION_TEMPLATE)
|
||||
}
|
||||
>
|
||||
CloudFormation {integrationType ? "" : "Template"}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Terraform Code"
|
||||
variant="link"
|
||||
className="h-auto w-fit min-w-0 p-0"
|
||||
asChild
|
||||
>
|
||||
<Button variant="link" className="h-auto w-fit min-w-0 p-0" asChild>
|
||||
<a
|
||||
href={templateLinks.terraform}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => signalTemplateOpened(ROLE_TEMPLATE_KIND.TERRAFORM)}
|
||||
>
|
||||
Terraform {integrationType ? "" : "Code"}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary block text-xs font-medium">
|
||||
External ID:
|
||||
</span>
|
||||
<CodeSnippet value={externalId} icon={<IdIcon size={16} />} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Step 2: AWS method selector (before choosing a method) */}
|
||||
{prevStep === 2 && providerType === "aws" && method === null && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
<AwsMethodSelector
|
||||
onSelectSingle={() => setMethod("single")}
|
||||
onSelectOrganizations={() =>
|
||||
onSelectOrganizations?.(ORGANIZATION_TYPE.AWS)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Step 2: Azure method selector (before choosing a method) */}
|
||||
{prevStep === 2 && providerType === "azure" && method === null && (
|
||||
<>
|
||||
|
||||
+10
-108
@@ -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 = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-text-neutral-tertiary text-xs font-bold">
|
||||
Specify which AWS credentials to use
|
||||
</span>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-1.5">
|
||||
<Select
|
||||
value={credentialsType || defaultCredentialsType}
|
||||
onValueChange={(value) => {
|
||||
setValue(
|
||||
ProviderCredentialFields.CREDENTIALS_TYPE,
|
||||
value as "aws-sdk-default" | "access-secret-key",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select credentials type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[60]">
|
||||
<SelectItem value="aws-sdk-default">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>
|
||||
{isCloudEnv
|
||||
? "Prowler Cloud will assume your IAM role"
|
||||
: "AWS SDK Default"}
|
||||
</span>
|
||||
{isCloudEnv && (
|
||||
<Badge variant="tag" className="ml-2">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="access-secret-key">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>Access & Secret Key</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{credentialsType === "access-secret-key" && (
|
||||
<>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.AWS_ACCESS_KEY_ID}
|
||||
type="password"
|
||||
label="AWS Access Key ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the AWS Access Key ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.AWS_SECRET_ACCESS_KEY}
|
||||
type="password"
|
||||
label="AWS Secret Access Key"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the AWS Secret Access Key"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.AWS_SESSION_TOKEN}
|
||||
type="password"
|
||||
label="AWS Session Token (optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the AWS Session Token"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AwsRoleCredentialsSource
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
credentialsType={credentialsType || defaultCredentialsType}
|
||||
isCloudEnv={isCloudEnv}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
{type === "providers" ? (
|
||||
@@ -210,31 +136,7 @@ export const AWSRoleCredentialsForm = ({
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
Optional fields
|
||||
</span>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.ROLE_SESSION_NAME}
|
||||
type="text"
|
||||
label="Role session name"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the role session name"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.SESSION_DURATION}
|
||||
type="number"
|
||||
label="Session duration (seconds)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the session duration (default: 3600 seconds)"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</div>
|
||||
<AwsRoleOptionalFields control={control} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
+105
@@ -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<AWSCredentialsRole>;
|
||||
setValue: UseFormSetValue<AWSCredentialsRole>;
|
||||
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) => (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
Specify which AWS credentials to use
|
||||
</span>
|
||||
<Select
|
||||
value={credentialsType}
|
||||
onValueChange={(value) => {
|
||||
setValue(
|
||||
ProviderCredentialFields.CREDENTIALS_TYPE,
|
||||
value as "aws-sdk-default" | "access-secret-key",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select credentials type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[60]">
|
||||
<SelectItem value="aws-sdk-default">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>
|
||||
{isCloudEnv
|
||||
? "Prowler Cloud will assume your IAM role"
|
||||
: "AWS SDK Default"}
|
||||
</span>
|
||||
{isCloudEnv && (
|
||||
<Badge variant="tag" className="ml-2">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="access-secret-key">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>Access & Secret Key</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{credentialsType === "access-secret-key" && (
|
||||
<>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.AWS_ACCESS_KEY_ID}
|
||||
type="password"
|
||||
label="AWS Access Key ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the AWS Access Key ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.AWS_SECRET_ACCESS_KEY}
|
||||
type="password"
|
||||
label="AWS Secret Access Key"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the AWS Secret Access Key"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.AWS_SESSION_TOKEN}
|
||||
type="password"
|
||||
label="AWS Session Token (optional)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the AWS Session Token"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
+37
@@ -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<AWSCredentialsRole>;
|
||||
}
|
||||
|
||||
/** Session name and duration of the assumed role; both optional. */
|
||||
export const AwsRoleOptionalFields = ({
|
||||
control,
|
||||
}: AwsRoleOptionalFieldsProps) => (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.ROLE_SESSION_NAME}
|
||||
type="text"
|
||||
label="Role session name"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the role session name"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name={ProviderCredentialFields.SESSION_DURATION}
|
||||
type="number"
|
||||
label="Session duration (seconds)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the session duration (default: 3600 seconds)"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -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(<SidePanelTrigger />);
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -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.<tenantId>`, 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 `<OnboardingTrigger />`, 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ProviderFunnelDetail>).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();
|
||||
});
|
||||
});
|
||||
@@ -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<ProviderFunnelDetail>(PROVIDER_FUNNEL_EVENT, { detail }),
|
||||
);
|
||||
} catch {
|
||||
// A listener that throws must never break the journey it observes.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ const driverHarness = vi.hoisted(() => {
|
||||
drive: ReturnType<typeof vi.fn>;
|
||||
isActive: ReturnType<typeof vi.fn>;
|
||||
isLastStep: ReturnType<typeof vi.fn>;
|
||||
setSteps: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
|
||||
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(<AnchoredProbe onResult={(result) => (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(<AutoOpenAnchoredProbe />);
|
||||
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(<AnchoredProbe onResult={(result) => (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(<FallbackProbe />);
|
||||
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(<AnchoredProbe onResult={(result) => (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(<AnchoredProbe onResult={(result) => (latestResult = result)} />);
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
latestResult?.start("unknown");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
driverHarness.instances[0].drive,
|
||||
).toHaveBeenCalledExactlyOnceWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AddProviderTourTarget>({
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -102,7 +102,8 @@ export interface UseDriverTourOptions<TTarget extends string = string> {
|
||||
}
|
||||
|
||||
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<TTarget extends string>(
|
||||
// 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<DriveStep[]>([]);
|
||||
|
||||
const tourId = tour.id;
|
||||
const tourVersion = tour.version;
|
||||
const existing = store.get({ id: tourId, version: tourVersion });
|
||||
@@ -361,6 +367,7 @@ export function useDriverTour<TTarget extends string>(
|
||||
});
|
||||
|
||||
driverRef.current = driver(config);
|
||||
stepsRef.current = steps;
|
||||
|
||||
return () => {
|
||||
const instance = driverRef.current;
|
||||
@@ -381,7 +388,11 @@ export function useDriverTour<TTarget extends string>(
|
||||
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<TTarget extends string>(
|
||||
}, [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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AwsConnectDraft>) => 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<ProviderWizardState>()(
|
||||
@@ -47,11 +57,21 @@ export const useProviderWizardStore = create<ProviderWizardState>()(
|
||||
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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(<StoreInitializer values={{ hasProviders: undefined }} />);
|
||||
|
||||
// Then
|
||||
expect(useUIStore.getState().hasProvidersResolved).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a confirmed empty tenant without persisting the resolution", () => {
|
||||
// Given / When
|
||||
render(<StoreInitializer values={{ hasProviders: false }} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UIStoreState>()(
|
||||
(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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <main>: 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -729,12 +733,7 @@ export class ProvidersPage extends BasePage {
|
||||
}
|
||||
|
||||
async selectAWSOrganizationsMethod(): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// Click the actions dropdown for a specific provider row
|
||||
const row = this.providersTable.locator("tbody tr", {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window.localStorage.setItem("prowler.onboarding.first-run", "true");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,3 +24,12 @@ export interface ProviderWizardIdentity {
|
||||
uid: string | null;
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
export type AwsConnectDraftValues = Record<string, string>;
|
||||
|
||||
/** What the AWS connect step typed so far; in memory only, gone with the wizard. */
|
||||
export interface AwsConnectDraft {
|
||||
method: string;
|
||||
roleValues: AwsConnectDraftValues;
|
||||
keysValues: AwsConnectDraftValues;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user