diff --git a/docs/images/test-connection-button.png b/docs/images/test-connection-button.png deleted file mode 100644 index 38bd5c8a7c..0000000000 Binary files a/docs/images/test-connection-button.png and /dev/null differ diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index f42a4db8f8..a7e1670598 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -85,20 +85,14 @@ For detailed instructions on configuring credentials for each provider, refer to ## Step 5: Test Connection -After adding your cloud account credentials, click the `Check connection` button to verify that Prowler can successfully connect to your provider: - -Test Connection +To verify the credentials, click the connect action at the bottom of the same step (for example, "Connect account", "Connect subscription" or "Connect project"). Prowler saves the provider and its credentials and tests the connection in that single action. When the test succeeds, the wizard moves straight to launching the scan. When it fails, the reason appears above the form and the values stay in place: click "Retry connection" to test again, or correct a field, which brings back the connect action, and click it. Neither adds the provider twice. -For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan. - - - -To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account. +To delegate the connection, select "I don't have access, invite a teammate" on the same step when you cannot create the credentials yourself. The option is available for every provider to users who can manage the account. Prowler Local Server adds the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. ## Step 6: Scan Started -After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress: +Once the connection test succeeds, the wizard moves to the launch step, since the provider is already saved. To start the first scan, click "Launch scan", or "Save and launch scan" when scanning on a schedule. The `Scans` section shows the scan in progress: Start Now diff --git a/ui/app/(prowler)/providers/providers-page.harness.tsx b/ui/app/(prowler)/providers/providers-page.harness.tsx index 49abce58c5..8b6e627cfa 100644 --- a/ui/app/(prowler)/providers/providers-page.harness.tsx +++ b/ui/app/(prowler)/providers/providers-page.harness.tsx @@ -258,6 +258,29 @@ export class ProvidersPageHarness extends BrowserHarness { await this.user.fill(secretInput, secretAccessKey); } + /** Type the project id and service account key on the GCP connect step. */ + async fillGcpServiceAccount({ + projectId, + serviceAccountKey, + }: { + projectId: string; + serviceAccountKey: string; + }): Promise { + const projectInput = await this.waitFor(() => + this.inputByName("providerUid"), + ); + await this.user.fill(projectInput, projectId); + const keyInput = await this.waitFor(() => + this.q('textarea[name="service_account_key"]'), + ); + await this.user.fill(keyInput, serviceAccountKey); + } + + /** Submit the connect step with its provider-specific action label. */ + async connectWith(label: RegExp): Promise { + await this.clickPrimary(label); + } + /** Submit the AWS one-step form; waits for its action to become enabled. */ async connectAccount(): Promise { await this.clickPrimary(/Connect account/); @@ -278,10 +301,20 @@ export class ProvidersPageHarness extends BrowserHarness { await this.user.click(tab); } - /** Select GCP and open the GCP Organization method card (no advance wait). */ + /** Click the organization tab of the connect step (no advance wait). */ + async chooseOrganizationTab(name: RegExp): Promise { + const tab = await this.waitFor( + () => this.byRoleName("tab", name), + undefined, + "organization tab", + ); + await this.user.click(tab); + } + + /** Select GCP and open its organization tab (no advance wait). */ async chooseGcpOrganizationsMethod(): Promise { await this.selectProviderType(/Google Cloud Platform/); - await this.chooseMethod(/Add Multiple Projects With GCP Organization/); + await this.chooseOrganizationTab(/Full GCP Organization/); } /** Enter the GCP Organization onboarding flow from a fresh wizard. */ @@ -290,12 +323,10 @@ export class ProvidersPageHarness extends BrowserHarness { await this.waitForText(/Organization Details/); } - /** Select Azure and open the Management Group method card (no advance wait). */ + /** Select Azure and open its Management Group tab (no advance wait). */ async chooseAzureOrganizationsMethod(): Promise { await this.selectProviderType(/Microsoft Azure/); - await this.chooseMethod( - /Add Multiple Subscriptions With Azure Management Group/, - ); + await this.chooseOrganizationTab(/Azure Management Group/); } /** Enter the Azure Management Group onboarding flow from a fresh wizard. */ @@ -304,9 +335,13 @@ export class ProvidersPageHarness extends BrowserHarness { await this.waitForText(/Organization Details/); } - /** Wait until the wizard shows the selected provider's method fork. */ - async waitForMethodStep(): Promise { - await this.waitForText(/Select a method to add your/); + /** Wait until the selected provider's connect step shows its single-account tab. */ + async waitForConnectStep(): Promise { + await this.waitFor( + () => this.byRoleName("tab", /^Single /), + undefined, + "connect step", + ); } /** Whether the organization setup step is showing (the flow actually started). */ diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx index 070c43be68..78a3f18100 100644 --- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx +++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx @@ -212,13 +212,11 @@ describe("Organization onboarding wizard", () => { }); describe("Wizard progress", () => { - it("drops the credentials and test rows once AWS is picked, since one step covers them", async () => { + it("shows the same two rows before and after a provider is picked, since one step connects it", async () => { const harness = new ProvidersPageHarness(awsOnboardingFixture()); await harness.mount(); expect(harness.stepperLabels()).toEqual([ - "Link a Provider", - "Authenticate Credentials", - "Validate Connection", + "Connect Provider", "Launch Scan", ]); @@ -226,12 +224,34 @@ describe("Organization onboarding wizard", () => { await harness.waitForAwsConnectStep(); expect(harness.stepperLabels()).toEqual([ - "Link a Provider", + "Connect Provider", "Launch Scan", ]); }, 40000); }); + describe("A provider other than AWS", () => { + // Compiled by the React Compiler: the generic form's validity must stay + // reactive too, or the action would never enable. + it("connects a GCP project with its service account key in one step and jumps to launch", async () => { + const harness = new ProvidersPageHarness(awsOnboardingFixture()); + await harness.mount(); + await harness.selectProviderType(/Google Cloud Platform/); + await harness.waitForConnectStep(); + await harness.fillGcpServiceAccount({ + projectId: "my-gcp-project", + serviceAccountKey: JSON.stringify({ type: "service_account" }), + }); + + await harness.connectWith(/Connect project/); + + await harness.waitForProviderLaunchStep(); + expect(harness.providerCreateCallCount).toBe(1); + expect(harness.secretCreateCallCount).toBe(1); + expect(harness.connectionCallCount).toBe(1); + }, 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. @@ -401,12 +421,10 @@ describe("Organization onboarding wizard", () => { await harness.mount(); await harness.selectProviderType(/Google Cloud Platform/); - await harness.waitForMethodStep(); + await harness.waitForConnectStep(); - await harness.chooseMethod( - /Add Multiple Projects With GCP Organization/, - ); - await harness.waitForMethodStep(); + await harness.chooseOrganizationTab(/Full GCP Organization/); + await harness.waitForConnectStep(); expect(harness.hasOrganizationSetupStep()).toBe(false); }, 30000); }); @@ -817,12 +835,10 @@ describe("Organization onboarding wizard", () => { await harness.mount(); await harness.selectProviderType(/Microsoft Azure/); - await harness.waitForMethodStep(); + await harness.waitForConnectStep(); - await harness.chooseMethod( - /Add Multiple Subscriptions With Azure Management Group/, - ); - await harness.waitForMethodStep(); + await harness.chooseOrganizationTab(/Azure Management Group/); + await harness.waitForConnectStep(); expect(harness.hasOrganizationSetupStep()).toBe(false); }, 30000); }); diff --git a/ui/changelog.d/provider-wizard-one-step.changed.md b/ui/changelog.d/provider-wizard-one-step.changed.md new file mode 100644 index 0000000000..85acf6ff63 --- /dev/null +++ b/ui/changelog.d/provider-wizard-one-step.changed.md @@ -0,0 +1 @@ +Add provider wizard connects every provider in one step, saving the account and credentials and testing the connection together, with teammate invitations for every provider and Lighthouse AI help beside the steps diff --git a/ui/components/providers/credentials-update-info.tsx b/ui/components/providers/credentials-update-info.tsx deleted file mode 100644 index 7b47be5b05..0000000000 --- a/ui/components/providers/credentials-update-info.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud"; -import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws"; -import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare"; -import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp"; -import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github"; -import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365"; -import { ProviderType } from "@/types/providers"; - -interface UpdateCredentialsInfoProps { - providerType: ProviderType; - initialVia?: string; -} - -export const CredentialsUpdateInfo = ({ - providerType, - initialVia, -}: UpdateCredentialsInfoProps) => { - const renderSelectComponent = () => { - if (providerType === "aws") { - return ; - } - if (providerType === "gcp") { - return ; - } - if (providerType === "github") { - return ; - } - if (providerType === "m365") { - return ; - } - if (providerType === "cloudflare") { - return ; - } - if (providerType === "alibabacloud") { - return ; - } - return null; - }; - - return
{renderSelectComponent()}
; -}; diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts index 9184ceab2e..10442405fd 100644 --- a/ui/components/providers/index.ts +++ b/ui/components/providers/index.ts @@ -1,6 +1,5 @@ export * from "../../store/ui/store-initializer"; export * from "./add-provider-button"; -export * from "./credentials-update-info"; export * from "./forms/delete-form"; export * from "./link-to-scans"; export * from "./muted-findings-config-button"; diff --git a/ui/components/providers/organizations/azure-method-selector.tsx b/ui/components/providers/organizations/azure-method-selector.tsx deleted file mode 100644 index fbfc75e24e..0000000000 --- a/ui/components/providers/organizations/azure-method-selector.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Box, Boxes } from "lucide-react"; - -import { RadioCard } from "@/components/providers/radio-card"; -import { Badge } from "@/components/shadcn/badge/badge"; -import { isCloud } from "@/lib/shared/env"; -import { useCloudUpgradeStore } from "@/store"; -import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -interface AzureMethodSelectorProps { - onSelectSingle: () => void; - onSelectOrganizations: () => void; -} - -export function AzureMethodSelector({ - onSelectSingle, - onSelectOrganizations, -}: AzureMethodSelectorProps) { - const isCloudEnv = isCloud(); - const openCloudUpgrade = useCloudUpgradeStore( - (state) => state.openCloudUpgrade, - ); - - return ( -
-

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

- - - - - isCloudEnv - ? onSelectOrganizations() - : openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS) - } - > - {!isCloudEnv && Cloud} - -
- ); -} diff --git a/ui/components/providers/organizations/azure-org-setup-form.tsx b/ui/components/providers/organizations/azure-org-setup-form.tsx index 6d637dc82e..dfad6e3dd6 100644 --- a/ui/components/providers/organizations/azure-org-setup-form.tsx +++ b/ui/components/providers/organizations/azure-org-setup-form.tsx @@ -7,6 +7,10 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { AzureProviderBadge } from "@/components/icons/providers-badge"; +import { + ONBOARDING_METHOD, + OnboardingMethodTabs, +} from "@/components/providers/wizard/steps/connect/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"; @@ -56,6 +60,8 @@ interface AzureOrgSetupFormInitialValues { interface AzureOrgSetupFormProps { onBack: () => void; + /** Keeps the single/organization tabs on screen; absent when the flow was entered directly. */ + onSelectSingleAccount?: () => void; onNext: () => void; onFooterChange: (config: WizardFooterConfig) => void; onPhaseChange: (phase: OrgSetupPhase) => void; @@ -66,6 +72,7 @@ interface AzureOrgSetupFormProps { export function AzureOrgSetupForm({ onBack, + onSelectSingleAccount, onNext, onFooterChange, onPhaseChange, @@ -232,6 +239,14 @@ export function AzureOrgSetupForm({ + {onSelectSingleAccount && ( + + )} +

Enter the Microsoft Entra tenant ID for the subscriptions you want to add to Prowler. diff --git a/ui/components/providers/organizations/gcp-method-selector.test.tsx b/ui/components/providers/organizations/gcp-method-selector.test.tsx deleted file mode 100644 index a50e0e6615..0000000000 --- a/ui/components/providers/organizations/gcp-method-selector.test.tsx +++ /dev/null @@ -1,45 +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 { GcpMethodSelector } from "./gcp-method-selector"; - -// OSS gating only: `CloudUpgradeModal` mounts outside the providers page, so the -// harness cannot see the upsell open. The Cloud case is restated by integration. -describe("GcpMethodSelector", () => { - afterEach(() => { - vi.unstubAllEnvs(); - useCloudUpgradeStore.getState().closeCloudUpgrade(); - }); - - it("opens the GCP Organizations upgrade in Local Server", async () => { - // Given - vi.stubEnv("UI_CLOUD_ENABLED", "false"); - const user = userEvent.setup(); - const onSelectOrganizations = vi.fn(); - - // When - render( - , - ); - - // Then - await user.click( - screen.getByRole("radio", { - name: /add multiple projects with gcp organization/i, - }), - ); - - expect(onSelectOrganizations).not.toHaveBeenCalled(); - expect(screen.getByText("Cloud")).toBeVisible(); - expect(useCloudUpgradeStore.getState().activeFeature).toBe( - CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS, - ); - }); -}); diff --git a/ui/components/providers/organizations/gcp-method-selector.tsx b/ui/components/providers/organizations/gcp-method-selector.tsx deleted file mode 100644 index abefd91a0a..0000000000 --- a/ui/components/providers/organizations/gcp-method-selector.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Box, Boxes } from "lucide-react"; - -import { RadioCard } from "@/components/providers/radio-card"; -import { Badge } from "@/components/shadcn/badge/badge"; -import { isCloud } from "@/lib/shared/env"; -import { useCloudUpgradeStore } from "@/store"; -import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -interface GcpMethodSelectorProps { - onSelectSingle: () => void; - onSelectOrganizations: () => void; -} - -export function GcpMethodSelector({ - onSelectSingle, - onSelectOrganizations, -}: GcpMethodSelectorProps) { - const isCloudEnv = isCloud(); - const openCloudUpgrade = useCloudUpgradeStore( - (state) => state.openCloudUpgrade, - ); - - return ( -

-

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

- - - - - isCloudEnv - ? onSelectOrganizations() - : openCloudUpgrade(CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS) - } - > - {!isCloudEnv && Cloud} - -
- ); -} diff --git a/ui/components/providers/organizations/gcp-org-setup-form.tsx b/ui/components/providers/organizations/gcp-org-setup-form.tsx index 203e1302b6..73ec135767 100644 --- a/ui/components/providers/organizations/gcp-org-setup-form.tsx +++ b/ui/components/providers/organizations/gcp-org-setup-form.tsx @@ -9,6 +9,10 @@ import { z } from "zod"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { GCPProviderBadge } from "@/components/icons/providers-badge"; import { RadioCard } from "@/components/providers/radio-card"; +import { + ONBOARDING_METHOD, + OnboardingMethodTabs, +} from "@/components/providers/wizard/steps/connect/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"; @@ -104,6 +108,8 @@ interface GcpOrgSetupFormInitialValues { interface GcpOrgSetupFormProps { onBack: () => void; + /** Keeps the single/organization tabs on screen; absent when the flow was entered directly. */ + onSelectSingleAccount?: () => void; onClose?: () => void; onNext: () => void; onFooterChange: (config: WizardFooterConfig) => void; @@ -115,6 +121,7 @@ interface GcpOrgSetupFormProps { export function GcpOrgSetupForm({ onBack, + onSelectSingleAccount, onClose, onNext, onFooterChange, @@ -328,6 +335,14 @@ export function GcpOrgSetupForm({ + {onSelectSingleAccount && ( + + )} +

Enter the Google Cloud organization ID for the projects you want to add to Prowler. diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index 839cd3a365..06c2bf5a80 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -11,9 +11,9 @@ 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"; + ONBOARDING_METHOD, + OnboardingMethodTabs, +} from "@/components/providers/wizard/steps/connect/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"; @@ -334,8 +334,9 @@ export function OrgSetupForm({ {onSelectSingleAccount && ( - )} diff --git a/ui/components/providers/radio-group-provider.integration.test.tsx b/ui/components/providers/radio-group-provider.integration.test.tsx index e7a2d7f4e3..a92d1b8589 100644 --- a/ui/components/providers/radio-group-provider.integration.test.tsx +++ b/ui/components/providers/radio-group-provider.integration.test.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { useForm } from "react-hook-form"; import { describe, expect, it } from "vitest"; @@ -11,17 +12,27 @@ import { RadioGroupProvider } from "./radio-group-provider"; // Keep even speculative browser requests on loopback; MSW serves the image. const LOGO_URL = "https://127.0.0.1/providers/vcf/logo.png"; -function Selector() { +const LONG_LABEL = + "Acme Sovereign Multi Region Enterprise Compliance Cloud Platform"; + +function Selector({ + registryLoading = false, + registryLabel = "Vcf", +}: { + registryLoading?: boolean; + registryLabel?: string; +}) { const form = useForm(); return ( +

{children}
+ + ); +} + +const boxOf = (element: Element) => element.getBoundingClientRect(); + // The logo is the first element of the option's label row. function logoBox(option: HTMLElement) { const logo = option.lastElementChild?.firstElementChild; @@ -61,3 +83,148 @@ describe("provider selector logos", () => { expect(logoBox(registry)).toEqual(logoBox(builtIn)); }); }); + +describe("provider selector layout", () => { + it("lists two providers per row beside a wide wizard", async () => { + // When + const screen = await render( + + + , + ); + + // Then + const aws = boxOf( + screen.getByRole("option", { name: "Amazon Web Services" }).element(), + ); + const azure = boxOf( + screen.getByRole("option", { name: "Microsoft Azure" }).element(), + ); + const gcp = boxOf( + screen.getByRole("option", { name: "Google Cloud Platform" }).element(), + ); + expect(azure.top).toBe(aws.top); + expect(azure.left).toBeGreaterThan(aws.right); + expect(gcp.top).toBeGreaterThan(aws.bottom); + }); + + it("keeps one provider per row while the wizard stacks", async () => { + // When + const screen = await render( + + + , + ); + + // Then + const aws = boxOf( + screen.getByRole("option", { name: "Amazon Web Services" }).element(), + ); + const azure = boxOf( + screen.getByRole("option", { name: "Microsoft Azure" }).element(), + ); + expect(azure.top).toBeGreaterThan(aws.bottom); + }); + + it("cuts a long name with an ellipsis inside its card", async () => { + // When + const screen = await render( + + + , + ); + + // Then + const long = screen + .getByRole("option", { name: `${LONG_LABEL} Registry` }) + .element() as HTMLElement; + const aws = screen + .getByRole("option", { name: "Amazon Web Services" }) + .element() as HTMLElement; + const name = screen.getByText(LONG_LABEL).element() as HTMLElement; + expect(getComputedStyle(name).textOverflow).toBe("ellipsis"); + expect(name.scrollWidth).toBeGreaterThan(name.clientWidth); + expect(boxOf(long).width).toBe(boxOf(aws).width); + expect(boxOf(long).height).toBe(boxOf(aws).height); + // The Registry badge stays whole beside the cut name. + await expect + .element(screen.getByText("Registry", { exact: true }).last()) + .toBeVisible(); + }); + + it("lays out the Registry placeholders like the providers they stand for", async () => { + // When + const screen = await render( + + + , + ); + await screen.getByRole("tab", { name: "Registry" }).click(); + + // Then + const rows = Array.from( + screen + .getByRole("status", { name: "Loading Registry providers" }) + .element().children, + ).map(boxOf); + expect(rows[1].top).toBe(rows[0].top); + expect(rows[1].left).toBeGreaterThan(rows[0].right); + }); +}); + +describe("provider selector header", () => { + // Like the wizard's form column: a fixed-height box that scrolls. + async function renderScrolled() { + const screen = await render( + +
+ +
+
, + ); + const scroller = screen.getByTestId("scroller").element() as HTMLElement; + scroller.scrollTop = scroller.scrollHeight; + await expect.poll(() => scroller.scrollTop).toBeGreaterThan(0); + return { screen, scroller }; + } + + it("keeps the tabs and the search on top while the providers scroll", async () => { + // When + const { screen, scroller } = await renderScrolled(); + + // Then + const search = screen + .getByRole("textbox", { name: "Search providers" }) + .element() as HTMLElement; + const tabs = screen + .getByRole("tablist", { name: "Provider source" }) + .element() as HTMLElement; + const top = scroller.getBoundingClientRect().top; + expect(tabs.getBoundingClientRect().top).toBeGreaterThanOrEqual(top); + expect(search.getBoundingClientRect().bottom).toBeLessThan(top + 200); + // Nothing scrolled under it shows through: the search is what is hit. + const box = search.getBoundingClientRect(); + expect( + document.elementFromPoint(box.left + 8, box.top + box.height / 2), + ).toBe(search); + }); + + it("lands a provider scrolled back into view below the header", async () => { + // Given: the first provider sits above the visible area. + const { screen } = await renderScrolled(); + const header = document.querySelector( + "[data-slot=provider-picker-header]", + ) as HTMLElement; + const first = screen + .getByRole("option", { name: "Amazon Web Services" }) + .element() as HTMLElement; + + // When: edge-aligned scrolling, as keyboard navigation does. + first.scrollIntoView({ block: "nearest" }); + + // Then + await expect + .poll(() => first.getBoundingClientRect().top) + .toBeGreaterThanOrEqual(header.getBoundingClientRect().bottom); + }); +}); diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index 89d22f06ce..04d9d9c6a9 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -28,6 +28,10 @@ const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map( const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const; const REGISTRY_SKELETON_ROWS = 3; +// Tabs and search stay on top, opaque, while the providers scroll under them. +const STICKY_HEADER = "bg-bg-neutral-secondary sticky top-0 z-10 flex flex-col"; +// Two providers per row beside a wide wizard, one while it stacks. +const PROVIDER_GRID = "grid gap-3 lg:grid-cols-2"; type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB]; interface RadioGroupProviderProps { @@ -95,7 +99,7 @@ export const RadioGroupProvider: FC = ({ control={control} render={({ field }) => { const searchInput = ( -
+
= ({
{filteredProviders.length > 0 ? ( filteredProviders.map((provider) => { @@ -125,6 +129,8 @@ export const RadioGroupProvider: FC = ({ onClick={() => field.onChange(provider.value)} className={cn( "flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors", + // Focus scrolling clears the sticky header above the list. + registryAvailable ? "scroll-mt-32" : "scroll-mt-20", "focus-visible:border-button-primary focus-visible:outline-none", isSelected ? "border-button-primary bg-bg-neutral-tertiary" @@ -148,7 +154,7 @@ export const RadioGroupProvider: FC = ({ ) : ( )} - + {provider.label} {provider.registry && ( @@ -159,7 +165,7 @@ export const RadioGroupProvider: FC = ({ ); }) ) : ( -

+

{lowerSearch ? ( <>No providers found matching "{searchTerm}" ) : ( @@ -173,7 +179,7 @@ export const RadioGroupProvider: FC = ({

{Array.from({ length: REGISTRY_SKELETON_ROWS }, (_, index) => ( @@ -191,7 +197,9 @@ export const RadioGroupProvider: FC = ({ if (!registryAvailable) { return (
- {searchInput} +
+ {searchInput} +
{providerList}
{validationMessage}
@@ -204,11 +212,17 @@ export const RadioGroupProvider: FC = ({ value={activeTab} onValueChange={(value) => setSelectedTab(value as ProviderTab)} > - - All providers - Registry - - {searchInput} +
+ + + All providers + + + Registry + + + {searchInput} +
{activeTab === PROVIDER_TAB.REGISTRY && registryPlaceholder ? registryPlaceholder diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index 13384917d3..faee9daef6 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -104,7 +104,7 @@ describe("useProviderWizardController", () => { uid: "123456789012", alias: null, }); - result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); }); // When @@ -114,7 +114,7 @@ describe("useProviderWizardController", () => { // Then expect(funnelSignals).toEqual([ - { step: "wizard_closed", lastStep: "test", providerCreated: true }, + { step: "wizard_closed", lastStep: "launch", providerCreated: true }, ]); }); @@ -161,14 +161,14 @@ describe("useProviderWizardController", () => { // Then await waitFor(() => { - expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); }); expect(result.current.modalTitle).toBe("Update Provider Credentials"); expect(result.current.isProviderFlow).toBe(true); - // Update mode enters at the credentials step, so the docs link scrolls - // the getting-started page to the credentials/authentication section. + // Update mode opens the connect step on its first method (IAM role here, + // no `via` stored), so the docs link lands on that method's section. expect(result.current.docsLink).toBe( - "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#step-3-set-up-aws-authentication", + "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", ); const state = useProviderWizardStore.getState(); @@ -180,34 +180,85 @@ describe("useProviderWizardController", () => { expect(state.mode).toBe(PROVIDER_WIZARD_MODE.UPDATE); }); - it("updates the credentials docs link when AWS assume role is selected", async () => { - const onOpenChange = vi.fn(); + it("points the docs at the method picked on the connect step, for any provider", async () => { + // Given + const { result } = renderHook(() => + useProviderWizardController({ open: true, onOpenChange: vi.fn() }), + ); + act(() => result.current.setProviderTypeHint("m365")); + + // When + act(() => { + useProviderWizardStore + .getState() + .setConnectDraft("m365", { method: "app_client_secret" }); + }); + + // Then + expect(result.current.connectionMethod).toBe("app_client_secret"); + expect(result.current.docsLink).toBe( + "https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-client-secret-authentication", + ); + }); + + it.each([ + { + case: "the first listed method before any pick", + providerType: "aws", + expected: "role", + }, + { + case: "no method for a provider with a single one", + providerType: "azure", + expected: null, + }, + ])("tells Lighthouse $case", ({ providerType, expected }) => { + // Given + const { result } = renderHook(() => + useProviderWizardController({ open: true, onOpenChange: vi.fn() }), + ); + + // When + act(() => result.current.setProviderTypeHint(providerType)); + + // Then + expect(result.current.connectionMethod).toBe(expected); + }); + + it("tells Lighthouse the method an account being updated was stored with", async () => { + // When const { result } = renderHook(() => useProviderWizardController({ open: true, - onOpenChange, + onOpenChange: vi.fn(), initialData: { providerId: "provider-1", - providerType: "aws", - providerUid: "111111111111", - providerAlias: "production", - secretId: null, - mode: PROVIDER_WIZARD_MODE.ADD, + providerType: "gcp", + providerUid: "my-project", + providerAlias: null, + secretId: "secret-1", + via: "credentials", }, }), ); + // Then await waitFor(() => { - expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + expect(result.current.connectionMethod).toBe("credentials"); }); + }); - act(() => { - useProviderWizardStore.getState().setVia("role"); - }); - - expect(result.current.docsLink).toBe( - "https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#assume-role-recommended", + it("tells Lighthouse an organization is being onboarded", () => { + // Given + const { result } = renderHook(() => + useProviderWizardController({ open: true, onOpenChange: vi.fn() }), ); + + // When + act(() => result.current.openOrganizationsFlow(ORGANIZATION_TYPE.GCP)); + + // Then + expect(result.current.connectionMethod).toBe("organizations"); }); it("switches into and out of organizations flow", () => { @@ -262,7 +313,7 @@ describe("useProviderWizardController", () => { // When act(() => { - result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); result.current.handleTestSuccess(); }); @@ -290,12 +341,12 @@ describe("useProviderWizardController", () => { ); await waitFor(() => { - expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); }); // When act(() => { - result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); result.current.handleTestSuccess(); }); @@ -391,12 +442,12 @@ describe("useProviderWizardController", () => { ); await waitFor(() => { - expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.CONNECT); }); act(() => { useProviderWizardStore.getState().setVia("service-account"); - result.current.setCurrentStep(PROVIDER_WIZARD_STEP.TEST); + result.current.setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); }); // Provider data refreshes while modal is still open — user progress must be kept. @@ -412,7 +463,7 @@ describe("useProviderWizardController", () => { }, }); - expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.TEST); + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH); expect(useProviderWizardStore.getState().via).toBe("service-account"); }); }); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 5c304807d5..68d2bdc76e 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -27,6 +27,7 @@ import { import { ProviderType } from "@/types/providers"; import { getProviderWizardModalTitle } from "../provider-wizard-modal.utils"; +import { resolveConnectMethod } from "../steps/connect/connect-methods"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, @@ -49,8 +50,6 @@ const ORG_DOCS_URL = { // 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; @@ -60,6 +59,9 @@ const ORG_STEP_NAME = { [ORG_WIZARD_STEP.LAUNCH]: "organizations_launch", } as const satisfies Record; +// What Lighthouse is told an organization is connected with. +const ORGANIZATIONS_CONNECTION_METHOD = "organizations"; + const EMPTY_FOOTER_CONFIG: WizardFooterConfig = { showBack: false, backLabel: "Back", @@ -125,6 +127,7 @@ export function useProviderWizardController({ providerType, via, } = useProviderWizardStore(); + const connectDraft = useProviderWizardStore((state) => state.connectDraft); const { reset: resetOrgWizard, setOrganization, @@ -175,7 +178,7 @@ export function useProviderWizardController({ ? PROVIDER_WIZARD_MODE.UPDATE : PROVIDER_WIZARD_MODE.ADD), ); - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); setFooterConfig(EMPTY_FOOTER_CONFIG); setProviderTypeHint(initialProviderType); @@ -284,28 +287,40 @@ 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; + // Organizations are entered from the connect step's tabs, so going back + // lands on that provider's connect step instead of the provider picker. + const cameFromProvider = organizationType; resetOrgWizard(); setWizardVariant(WIZARD_VARIANT.PROVIDER); setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(cameFromAwsConnect ? "aws" : null); + setProviderTypeHint(cameFromProvider); setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); }; + const activeProviderType = providerTypeHint ?? providerType ?? null; + // The method the connect step shows: picked in this wizard, else the one the + // account was stored with, else the first listed. Only the method leaves the + // draft; its values may hold secrets. + const draftMethod = + activeProviderType && connectDraft?.providerType === activeProviderType + ? connectDraft.method + : null; + const providerMethod = activeProviderType + ? resolveConnectMethod(activeProviderType, draftMethod ?? via).via + : null; + const connectionMethod = isProviderFlow + ? providerMethod + : ORGANIZATIONS_CONNECTION_METHOD; const docsLink = isProviderFlow - ? getProviderHelpText( - providerTypeHint ?? providerType ?? "", - currentStep, - via, - ).link + ? getProviderHelpText(activeProviderType ?? "", currentStep, providerMethod) + .link : ORG_DOCS_URL[organizationType]; const resolvedFooterConfig: WizardFooterConfig = footerConfig; const modalTitle = getProviderWizardModalTitle(mode); return { + connectionMethod, currentStep, docsLink, footerConfig, diff --git a/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx b/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx index 7c08dd248c..8563c76280 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx +++ b/ui/components/providers/wizard/lighthouse-connect-help.integration.test.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { describe, expect, vi } from "vitest"; import { it } from "@/__tests__/fixtures"; @@ -17,20 +18,38 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({ const DESCRIPTION = "Lighthouse AI walks you through it with Prowler's docs, right beside this form."; +const LEFT_COLUMN_WIDTH = 328; + +function Help({ docsDestination = "Provider" }: { docsDestination?: string }) { + return ( + + ); +} + // Like the modal frame: a marked size container, so lg: follows its width. +// A wide wizard holds the help in its left column, above the steps. +function Frame({ width, children }: { width: number; children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + function renderInWizardFrame(width: number) { return render( -
- -
, + + + , ); } @@ -65,4 +84,29 @@ describe("Lighthouse help in the wizard header", () => { .toBeVisible(); await expect.element(screen.getByText(DESCRIPTION)).toBeVisible(); }); + + it("fits the left column and keeps its height when the docs link changes", async () => { + // Given + const screen = await renderInWizardFrame(1200); + await expect + .element(screen.getByRole("button", { name: "Ask Lighthouse AI" })) + .toBeVisible(); + const callout = () => + screen + .getByRole("region", { name: "Help from Lighthouse AI" }) + .element() + .getBoundingClientRect(); + const before = callout(); + + // When: picking another method points the docs link elsewhere. + await screen.rerender( + + + , + ); + + // Then: the steps below the help do not move. + expect(before.width).toBeLessThanOrEqual(LEFT_COLUMN_WIDTH); + expect(callout().height).toBe(before.height); + }); }); diff --git a/ui/components/providers/wizard/lighthouse-connect-help.test.tsx b/ui/components/providers/wizard/lighthouse-connect-help.test.tsx index 011112a6b7..7b9bbe9065 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.test.tsx +++ b/ui/components/providers/wizard/lighthouse-connect-help.test.tsx @@ -33,7 +33,10 @@ vi.mock("@/app/(prowler)/lighthouse/_lib/panel-chat-store", () => ({ const AWS_DOCS = "https://goto.prowler.com/provider-aws"; -function renderHelp(providerType: string | null = "aws") { +function renderHelp( + providerType: string | null = "aws", + connectionMethod: string | null = "role", +) { return render( , ); @@ -281,8 +285,9 @@ describe("LighthouseConnectHelp", () => { docsDestination="AWS" providerType="aws" mode="add" - stepName="test" - stepLabel="Test Connection" + stepName="launch" + stepLabel="Launch Scan" + connectionMethod="credentials" isOrganizationsFlow={false} />, ); @@ -300,11 +305,11 @@ describe("LighthouseConnectHelp", () => { it("asks for a connection guide about the current step without sharing typed credentials", async () => { // Given: the user switched AWS to access keys and typed a secret const user = userEvent.setup(); - useProviderWizardStore.getState().setAwsConnectDraft({ + useProviderWizardStore.getState().setConnectDraft("aws", { method: "credentials", - keysValues: { awsSecretAccessKey: "super-secret-value" }, + values: { credentials: { aws_secret_access_key: "super-secret-value" } }, }); - renderHelp("aws"); + renderHelp("aws", "credentials"); const askButton = await screen.findByRole("button", { name: "Ask Lighthouse AI", }); @@ -362,9 +367,11 @@ describe("LighthouseConnectHelp", () => { // When: the user switches to access keys and moves on to the next step act(() => - useProviderWizardStore.getState().setAwsConnectDraft({ + useProviderWizardStore.getState().setConnectDraft("aws", { method: "credentials", - keysValues: { awsSecretAccessKey: "super-secret-value" }, + values: { + credentials: { aws_secret_access_key: "super-secret-value" }, + }, }), ); rerender( @@ -373,8 +380,9 @@ describe("LighthouseConnectHelp", () => { docsDestination="AWS" providerType="aws" mode="add" - stepName="test" - stepLabel="Test Connection" + stepName="launch" + stepLabel="Launch Scan" + connectionMethod="credentials" isOrganizationsFlow={false} />, ); @@ -385,7 +393,7 @@ describe("LighthouseConnectHelp", () => { expect.objectContaining({ kind: "provider", providerType: "aws", - wizardStep: "test", + wizardStep: "launch", connectionMethod: "credentials", }), ); @@ -417,6 +425,7 @@ describe("LighthouseConnectHelp", () => { mode="add" stepName="setup" stepLabel="Organization Setup" + connectionMethod="organizations" isOrganizationsFlow />, ); diff --git a/ui/components/providers/wizard/lighthouse-connect-help.tsx b/ui/components/providers/wizard/lighthouse-connect-help.tsx index 27a4c7e0b6..6ca245c812 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.tsx +++ b/ui/components/providers/wizard/lighthouse-connect-help.tsx @@ -7,6 +7,7 @@ import { LighthouseContextContributor } from "@/components/lighthouse/context-co import { usePanelSkillLaunch } from "@/components/lighthouse/use-panel-skill-launch"; import { Button } from "@/components/shadcn/button/button"; import { + LIGHTHOUSE_CALLOUT_LAYOUT, LighthouseCallout, LighthouseCalloutAction, LighthouseCalloutActions, @@ -33,10 +34,7 @@ import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills"; import type { ProviderWizardMode } from "@/types/provider-wizard"; import { getProviderDisplayName } from "@/types/providers"; -import { - buildProviderWizardHelpContext, - resolveConnectionMethod, -} from "./lighthouse-connect-help.utils"; +import { buildProviderWizardHelpContext } from "./lighthouse-connect-help.utils"; const CONNECTION_GUIDE_SKILL = getSkillById( LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE, @@ -57,6 +55,8 @@ interface LighthouseConnectHelpProps { // Stable step id for Lighthouse and analytics, and the label the stepper shows. stepName: string; stepLabel: string; + // What the connect step shows: the picked method, or "organizations". + connectionMethod: string | null; isOrganizationsFlow: boolean; } @@ -68,6 +68,7 @@ export function LighthouseConnectHelp({ mode, stepName, stepLabel, + connectionMethod, isOrganizationsFlow, }: LighthouseConnectHelpProps) { const helpState = useHelpState(); @@ -76,11 +77,6 @@ export function LighthouseConnectHelp({ ); const launchPanelSkill = usePanelSkillLaunch(); const providerUid = useProviderWizardStore((state) => state.providerUid); - const via = useProviderWizardStore((state) => state.via); - // Only the method leaves the AWS draft: its values may hold access keys. - const awsDraftMethod = useProviderWizardStore( - (state) => state.awsConnectDraft?.method, - ); if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) { return ( @@ -104,12 +100,7 @@ export function LighthouseConnectHelp({ mode, stepName, stepLabel, - connectionMethod: resolveConnectionMethod({ - isOrganizationsFlow, - providerType, - via, - awsDraftMethod, - }), + connectionMethod, docsUrl: docsLink, }; @@ -158,10 +149,15 @@ export function LighthouseConnectHelp({
{/* Invisible copy sizes the placeholder exactly like the callout. */} - + {calloutContent} @@ -172,7 +168,8 @@ export function LighthouseConnectHelp({ return ( {calloutContent} diff --git a/ui/components/providers/wizard/lighthouse-connect-help.utils.test.ts b/ui/components/providers/wizard/lighthouse-connect-help.utils.test.ts index e0b37572ca..0db02fdcdb 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.utils.test.ts +++ b/ui/components/providers/wizard/lighthouse-connect-help.utils.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest"; import { getLighthouseScopeKey } from "@/lib/lighthouse/context/pages"; -import { - buildProviderWizardHelpContext, - resolveConnectionMethod, -} from "./lighthouse-connect-help.utils"; +import { buildProviderWizardHelpContext } from "./lighthouse-connect-help.utils"; const PROVIDERS_SCOPE_KEY = getLighthouseScopeKey("/providers"); @@ -71,40 +68,3 @@ describe("buildProviderWizardHelpContext", () => { ]); }); }); - -describe("resolveConnectionMethod", () => { - it.each([ - { - case: "an organization onboarding", - input: { isOrganizationsFlow: true, providerType: "aws", via: null }, - expected: "organizations", - }, - { - case: "the method a connected account used", - input: { isOrganizationsFlow: false, providerType: "gcp", via: "sa" }, - expected: "sa", - }, - { - case: "the AWS method picked but not submitted yet", - input: { - isOrganizationsFlow: false, - providerType: "aws", - via: null, - awsDraftMethod: "credentials", - }, - expected: "credentials", - }, - { - case: "the AWS default before any pick", - input: { isOrganizationsFlow: false, providerType: "aws", via: null }, - expected: "role", - }, - { - case: "a provider whose method is still unknown", - input: { isOrganizationsFlow: false, providerType: "azure", via: null }, - expected: null, - }, - ])("resolves $case", ({ input, expected }) => { - expect(resolveConnectionMethod(input)).toBe(expected); - }); -}); diff --git a/ui/components/providers/wizard/lighthouse-connect-help.utils.ts b/ui/components/providers/wizard/lighthouse-connect-help.utils.ts index fe3f3580a3..68c692cf62 100644 --- a/ui/components/providers/wizard/lighthouse-connect-help.utils.ts +++ b/ui/components/providers/wizard/lighthouse-connect-help.utils.ts @@ -4,32 +4,6 @@ import { type LighthouseContextEnvelope, } from "@/types/lighthouse-context"; -import { AWS_ACCESS_METHOD } from "./steps/aws/types"; - -const ORGANIZATIONS_CONNECTION_METHOD = "organizations"; - -interface ConnectionMethodInput { - isOrganizationsFlow: boolean; - providerType: string | null; - // The method a stored account was connected with. - via: string | null; - // The AWS access method picked in the connect step, before it is submitted. - awsDraftMethod?: string; -} - -export function resolveConnectionMethod({ - isOrganizationsFlow, - providerType, - via, - awsDraftMethod, -}: ConnectionMethodInput): string | null { - if (isOrganizationsFlow) return ORGANIZATIONS_CONNECTION_METHOD; - if (via) return via; - // The AWS connect step starts on the role method until the user switches. - if (providerType === "aws") return awsDraftMethod ?? AWS_ACCESS_METHOD.ROLE; - return null; -} - export function buildProviderWizardHelpContext( input: Parameters[0], ): LighthouseContextEnvelope { diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index 24000231d8..b3c13f0293 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +19,8 @@ const { addProvider, addRegistryProvider, getInstalledRegistryProviderOptions, + getProviderSchemas, + saveDynamicProviderCredentials, testProviderConnection, updateCredentialsProvider, updateProvider, @@ -27,6 +29,8 @@ const { addProvider: vi.fn(), addRegistryProvider: vi.fn(), getInstalledRegistryProviderOptions: vi.fn(), + getProviderSchemas: vi.fn(), + saveDynamicProviderCredentials: vi.fn(), testProviderConnection: vi.fn(), updateCredentialsProvider: vi.fn(), updateProvider: vi.fn(), @@ -69,35 +73,17 @@ vi.mock("@/actions/registry/registry", () => ({ })); vi.mock( "@/components/providers/workflow/forms", - async () => import("../workflow/forms/connect-account-form"), + async () => import("../workflow/forms/provider-picker"), ); +vi.mock("@/actions/providers/provider-schemas", () => ({ + getProviderSchemas, +})); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials, +})); vi.mock("@/hooks/use-scroll-hint", () => ({ useScrollHint: () => ({ showScrollHint: false }), })); -vi.mock("./steps/credentials-step", () => ({ - CredentialsStep: ({ onBack }: { onBack: () => void }) => ( - <> -

Credential details

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

Connection test

- - - ), -})); vi.mock("./steps/launch-step", () => ({ LaunchStep: ({ onBack }: { onBack: () => void }) => ( <> @@ -124,35 +110,7 @@ vi.mock("../organizations/org-launch-scan", () => ({ OrgLaunchScan: () => null, })); -const createdAccount = { - data: { - id: "account", - attributes: { provider: "acme", uid: "acme-account", alias: null }, - }, -}; - -async function enterAccountDetails() { - const user = userEvent.setup(); - render( - <> - - - , - ); - await user.click( - await screen.findByRole("option", { name: "Acme Cloud Registry" }), - ); - await user.type( - screen.getByRole("textbox", { name: "Provider UID" }), - "acme-account", - ); - await waitFor(() => - expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(), - ); - return user; -} - -describe("provider wizard account creation", () => { +describe("provider wizard", () => { beforeEach(() => { // Registry discovery only runs in Cloud with Registry enabled. vi.stubEnv("UI_CLOUD_ENABLED", "true"); @@ -176,121 +134,107 @@ describe("provider wizard account creation", () => { session.data = { tenantId: "tenant-abc" }; }); - it("shows progress, blocks repeat clicks, and advances after creation", async () => { - // Given - let resolveCreation!: (value: typeof createdAccount) => void; - addRegistryProvider.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveCreation = resolve; - }), - ); - const user = await enterAccountDetails(); - - // When - await user.click(screen.getByRole("button", { name: "Next" })); - - // Then - const pending = await screen.findByRole("button", { - name: "Creating provider...", - }); - expect(pending).toBeDisabled(); - expect(pending).toHaveAttribute("aria-busy", "true"); - expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); - await user.dblClick(pending); - expect(addRegistryProvider).toHaveBeenCalledOnce(); - - // When / Then - await act(async () => resolveCreation(createdAccount)); - 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." }] }; - let resolveCreation!: (value: typeof failure) => void; - addRegistryProvider - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveCreation = resolve; - }), - ) - .mockResolvedValueOnce(createdAccount); - const user = await enterAccountDetails(); - - // When - await user.click(screen.getByRole("button", { name: "Next" })); - await screen.findByRole("button", { name: "Creating provider..." }); - await act(async () => resolveCreation(failure)); - - // Then - expect(await screen.findByText(failure.errors[0].detail)).toBeVisible(); - const next = screen.getByRole("button", { name: "Next" }); - await waitFor(() => expect(next).toBeEnabled()); - expect(next).not.toHaveAttribute("aria-busy", "true"); - expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); - expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( - "acme-account", - ); - - // When / Then - await user.click(next); - expect(await screen.findByText("Credential details")).toBeVisible(); - expect(addRegistryProvider).toHaveBeenCalledTimes(2); - expect( - Object.fromEntries(addRegistryProvider.mock.calls[1][0]), - ).toMatchObject({ providerType: "acme", providerUid: "acme-account" }); - }); - - it("shows provider conflicts in the account step and allows retrying", async () => { - // Given - const detail = - "The artifact 'acme' is not installed on this deployment yet. Install it again and retry."; - addRegistryProvider - .mockResolvedValueOnce({ - errors: [ - { - status: "409", - detail, - source: { pointer: "/data/attributes/provider" }, + describe("when the user picks a Registry provider", () => { + beforeEach(() => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + properties: { + token: { + type: "string", + title: "API token", + format: "password", + writeOnly: true, + }, + }, + required: ["token"], }, - ], - }) - .mockResolvedValueOnce(createdAccount); - const user = await enterAccountDetails(); + }, + }); + saveDynamicProviderCredentials.mockResolvedValue({ + status: "saved", + secretId: "secret-acme", + }); + }); - // When - await user.click(screen.getByRole("button", { name: "Next" })); + async function fillAcme() { + const user = userEvent.setup(); + render( + <> + + + , + ); + await user.click( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ); + await user.type( + await screen.findByRole("textbox", { name: "Provider UID" }), + "acme-account", + ); + await user.type(await screen.findByLabelText(/API token/), "token"); + return user; + } - // Then - expect(await screen.findByRole("alert")).toHaveTextContent(detail); - expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( - "acme-account", - ); - const next = await screen.findByRole("button", { name: "Next" }); - await waitFor(() => expect(next).toBeEnabled()); - expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + it("registers the account, stores its credentials and tests them in one step", async () => { + // Given + useUIStore.setState({ hasProviders: false, hasProvidersResolved: true }); + addRegistryProvider.mockResolvedValue({ data: { id: "account" } }); + const user = await fillAcme(); - // When / Then: the provider becomes available and the same account retries. - await user.click(next); - expect(await screen.findByText("Credential details")).toBeVisible(); - expect(screen.queryByText(detail)).not.toBeInTheDocument(); - expect(addRegistryProvider).toHaveBeenCalledTimes(2); + // When + await user.click( + screen.getByRole("button", { name: "Connect provider" }), + ); + + // Then + expect(await screen.findByText("Launch scan")).toBeVisible(); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "token" }, + }); + expect(testProviderConnection).toHaveBeenCalledWith("account"); + // The sidebar stops offering Add Provider without waiting for a reload. + expect(useUIStore.getState().hasProviders).toBe(true); + }); + + it("shows an account conflict on its id and retries the same account", async () => { + // Given + addRegistryProvider + .mockResolvedValueOnce({ + errors: [ + { + detail: "Provider with this uid already exists.", + source: { pointer: "/data/attributes/uid" }, + }, + ], + }) + .mockResolvedValueOnce({ data: { id: "account" } }); + const user = await fillAcme(); + + // When + await user.click( + screen.getByRole("button", { name: "Connect provider" }), + ); + + // Then + expect( + await screen.findByText("Provider with this uid already exists."), + ).toBeVisible(); + expect(saveDynamicProviderCredentials).not.toHaveBeenCalled(); + + // When / Then: the action reads "Connect provider" again once the + // refused attempt has settled. + await user.click( + await screen.findByRole("button", { name: "Connect provider" }), + ); + expect(await screen.findByText("Launch scan")).toBeVisible(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + }); }); it("signals the provider type the user picked, once", async () => { @@ -347,10 +291,14 @@ describe("provider wizard account creation", () => { await waitFor(() => expect(connect).toBeEnabled()); await user.click(connect); - // Then: neither the credentials step nor the connection test shows up. + // Then: the connection is tested in the same step, then the scan follows. expect(await screen.findByText("Launch scan")).toBeVisible(); - expect(screen.queryByText("Credential details")).not.toBeInTheDocument(); - expect(screen.queryByText("Connection test")).not.toBeInTheDocument(); + const progress = screen.getByRole("navigation", { + name: "Wizard progress", + }); + expect(progress).toHaveTextContent(/Connect Provider/); + expect(progress).toHaveTextContent(/Launch Scan/); + expect(progress).not.toHaveTextContent(/Authenticate|Validate/); expect(testProviderConnection).toHaveBeenCalledWith("provider-1"); expect(useProviderWizardStore.getState()).toMatchObject({ providerId: "provider-1", @@ -406,15 +354,15 @@ describe("provider wizard account creation", () => { />, ); - // When: Back reaches the AWS one-step form, still in update mode. - await user.click( - await screen.findByRole("button", { name: "Back to provider" }), - ); + // When: the wizard opens straight on the account's credentials. + expect( + screen.getByRole("navigation", { name: "Wizard progress" }), + ).toHaveTextContent("Update Credentials"); await user.type( await screen.findByRole("textbox", { name: /Role ARN/ }), ROLE_ARN, ); - const connect = screen.getByRole("button", { name: "Connect account" }); + const connect = screen.getByRole("button", { name: "Save and test" }); await waitFor(() => expect(connect).toBeEnabled()); await user.click(connect); @@ -440,11 +388,10 @@ describe("provider wizard account creation", () => { // When await user.click(screen.getByRole("button", { name: "Back to form" })); - // Then: AWS has no separate credentials step, so it lands on its own form. + // Then: the connect step comes back with what was typed. expect( await screen.findByRole("textbox", { name: /Role ARN/ }), - ).toBeVisible(); - expect(screen.queryByText("Credential details")).not.toBeInTheDocument(); + ).toHaveValue(ROLE_ARN); }); it("closes the wizard once a teammate has been invited to connect the account instead", async () => { diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 3b00fc278c..60d9c4cb3b 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -27,21 +27,16 @@ import type { ScanScheduleCapability } from "@/types/schedules"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { LighthouseConnectHelp } from "./lighthouse-connect-help"; import { - getCredentialsRetryStep, - getLaunchBackStep, - getOrganizationsStepperOffset, getProviderWizardDocsDestination, - getProviderWizardStepper, + getProviderWizardSteps, } from "./provider-wizard-modal.utils"; import { ConnectStep } from "./steps/connect-step"; -import { CredentialsStep } from "./steps/credentials-step"; 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 { getActiveStepIndex, - PROVIDER_WIZARD_STEPS, + ORGANIZATION_WIZARD_STEPS, WizardStepper, } from "./wizard-stepper"; @@ -68,6 +63,7 @@ export function ProviderWizardModal({ }: ProviderWizardModalProps) { const { backToProviderFlow, + connectionMethod, currentStep, docsLink, handleClose, @@ -108,31 +104,12 @@ export function ProviderWizardModal({ isScheduleCapabilityLoading, } = useScanScheduleCapability(scanScheduleCapability); const docsDestination = getProviderWizardDocsDestination(docsLink); - const providerStepper = getProviderWizardStepper({ - mode, - providerType: providerTypeHint, - currentStep, - isDirectCredentialsEntry, - }); - const orgStepOffset = getOrganizationsStepperOffset( - orgCurrentStep, - orgSetupPhase, - ); - const stepLabel = isProviderFlow - ? providerStepper.steps[ - getActiveStepIndex( - currentStep, - providerStepper.stepOffset, - providerStepper.steps.length, - ) - ].label - : PROVIDER_WIZARD_STEPS[ - getActiveStepIndex( - orgCurrentStep, - orgStepOffset, - PROVIDER_WIZARD_STEPS.length, - ) - ].label; + const stepperSteps = isProviderFlow + ? getProviderWizardSteps(mode) + : ORGANIZATION_WIZARD_STEPS; + const stepperCurrent = isProviderFlow ? currentStep : orgCurrentStep; + const stepLabel = + stepperSteps[getActiveStepIndex(stepperCurrent, stepperSteps.length)].label; return ( {modalTitle} -
-
- {isProviderFlow ? ( - - ) : ( - - )} + {/* Help and progress share the left column, so the form keeps the height. */} +
+ +
{ - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); - }} - onCredentialsSaved={() => { - // AWS stored its credentials and tested the connection in this - // step, so it takes the same exit the test step took: an update - // closes the wizard, an add moves on to the launch step. - handleTestSuccess(); - }} - onClose={handleClose} - onSelectOrganizations={openOrganizationsFlow} - onFooterChange={setFooterConfig} + providerType={providerTypeHint} + locked={isDirectCredentialsEntry} onProviderTypeChange={(providerType) => { - // The form re-reports the same type on re-render; signal a pick once. + // The picker re-reports the same type on re-render; signal a pick once. if (providerType && providerType !== providerTypeHint) { dispatchProviderFunnel({ step: PROVIDER_FUNNEL_STEP.PROVIDER_TYPE_SELECTED, @@ -210,33 +169,11 @@ export function ProviderWizardModal({ } setProviderTypeHint(providerType); }} - /> - )} - - {isProviderFlow && - currentStep === PROVIDER_WIZARD_STEP.CREDENTIALS && ( - setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} - onBack={() => - setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT) - } - onFooterChange={setFooterConfig} - /> - )} - - {isProviderFlow && - currentStep === PROVIDER_WIZARD_STEP.TEST && ( - - setCurrentStep( - getCredentialsRetryStep({ - mode, - providerType: providerTypeHint, - isDirectCredentialsEntry, - }), - ) - } + // The account is saved and tested: an update closes the + // wizard, an add moves on to the launch step. + onConnected={handleTestSuccess} + onClose={handleClose} + onSelectOrganizations={openOrganizationsFlow} onFooterChange={setFooterConfig} /> )} @@ -245,12 +182,7 @@ export function ProviderWizardModal({ currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( - setCurrentStep( - getLaunchBackStep({ - providerType: providerTypeHint, - isDirectCredentialsEntry, - }), - ) + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT) } onClose={handleClose} onFooterChange={setFooterConfig} @@ -296,6 +228,9 @@ export function ProviderWizardModal({ onBack={ isOrgDirectEntry ? handleClose : backToProviderFlow } + onSelectSingleAccount={ + isOrgDirectEntry ? undefined : backToProviderFlow + } onNext={() => { setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); }} @@ -321,6 +256,9 @@ export function ProviderWizardModal({ onBack={ isOrgDirectEntry ? handleClose : backToProviderFlow } + onSelectSingleAccount={ + isOrgDirectEntry ? undefined : backToProviderFlow + } onClose={handleClose} onNext={() => { setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts index 1664f2a4cb..33def84519 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls"; -import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE, PROVIDER_WIZARD_STEP, @@ -9,189 +8,27 @@ import { import { type KnownProviderType, PROVIDER_TYPES } from "@/types/providers"; import { - getCredentialsRetryStep, - getLaunchBackStep, - getOrganizationsStepperOffset, getProviderWizardDocsDestination, getProviderWizardModalTitle, - getProviderWizardStepper, + getProviderWizardSteps, } from "./provider-wizard-modal.utils"; -describe("getProviderWizardStepper", () => { - const labels = (steps: { label: string }[]) => steps.map((s) => s.label); +describe("getProviderWizardSteps", () => { + const labels = (steps: readonly { label: string }[]) => + steps.map((step) => step.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", + it("connects and launches in two rows for every provider", () => { + expect(labels(getProviderWizardSteps(PROVIDER_WIZARD_MODE.ADD))).toEqual([ + "Connect Provider", "Launch Scan", ]); - expect(stepper.stepOffset).toBe(0); }); - it("leaves only two rows 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", "Launch Scan"]); - expect(stepper.stepOffset).toBe(0); - }); - - it("keeps the first AWS row active if the wizard ever lands on a folded step", () => { - const stepper = getProviderWizardStepper({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "aws", - currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS, - }); - - expect(stepper.stepOffset).toBe(-PROVIDER_WIZARD_STEP.CREDENTIALS); - }); - - it("puts the AWS launch step on the second row", () => { - const stepper = getProviderWizardStepper({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "aws", - currentStep: PROVIDER_WIZARD_STEP.LAUNCH, - }); - - // LAUNCH is index 3 in the wizard but the second row of the AWS stepper. - expect(stepper.stepOffset).toBe(-2); - }); - - it("keeps the generic rows when adding credentials to a registered AWS account", () => { - const stepper = getProviderWizardStepper({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "aws", - currentStep: PROVIDER_WIZARD_STEP.CREDENTIALS, - isDirectCredentialsEntry: true, - }); - - expect(labels(stepper.steps)).toEqual([ - "Link a Provider", - "Authenticate Credentials", - "Validate Connection", - "Launch Scan", - ]); - expect(stepper.stepOffset).toBe(0); - }); - - it("keeps the generic rows for a provider that is not AWS", () => { - const stepper = getProviderWizardStepper({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "azure", - currentStep: PROVIDER_WIZARD_STEP.TEST, - }); - - expect(stepper.steps).toHaveLength(4); - expect(stepper.stepOffset).toBe(0); - }); - - 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("getCredentialsRetryStep", () => { - it("returns an AWS account being added to its one-step form", () => { - expect( - getCredentialsRetryStep({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "aws", - }), - ).toBe(PROVIDER_WIZARD_STEP.CONNECT); - }); - - it("returns to the credentials step when AWS credentials were added from the list", () => { - expect( - getCredentialsRetryStep({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "aws", - isDirectCredentialsEntry: true, - }), - ).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); - }); - - it("returns every other provider to the credentials step", () => { - expect( - getCredentialsRetryStep({ - mode: PROVIDER_WIZARD_MODE.ADD, - providerType: "azure", - }), - ).toBe(PROVIDER_WIZARD_STEP.CREDENTIALS); - }); -}); - -describe("getLaunchBackStep", () => { - it("returns an AWS account to its one-step form", () => { - expect(getLaunchBackStep({ providerType: "aws" })).toBe( - PROVIDER_WIZARD_STEP.CONNECT, + it("updates credentials in a single row", () => { + expect(labels(getProviderWizardSteps(PROVIDER_WIZARD_MODE.UPDATE))).toEqual( + ["Update Credentials"], ); }); - - it("returns every other provider to the connection test", () => { - expect(getLaunchBackStep({ providerType: "azure" })).toBe( - PROVIDER_WIZARD_STEP.TEST, - ); - }); - - it("returns to the connection test when AWS credentials were added from the list", () => { - expect( - getLaunchBackStep({ - providerType: "aws", - isDirectCredentialsEntry: true, - }), - ).toBe(PROVIDER_WIZARD_STEP.TEST); - }); -}); - -describe("getOrganizationsStepperOffset", () => { - it("keeps step 1 active during organization details", () => { - const offset = getOrganizationsStepperOffset( - ORG_WIZARD_STEP.SETUP, - ORG_SETUP_PHASE.DETAILS, - ); - - expect(offset).toBe(0); - }); - - it("moves to step 2 during credentials phase", () => { - const offset = getOrganizationsStepperOffset( - ORG_WIZARD_STEP.SETUP, - ORG_SETUP_PHASE.ACCESS, - ); - - expect(offset).toBe(1); - }); - - it("uses step 2+ offset for later wizard steps", () => { - const offset = getOrganizationsStepperOffset( - ORG_WIZARD_STEP.VALIDATE, - ORG_SETUP_PHASE.DETAILS, - ); - - expect(offset).toBe(1); - }); }); describe("getProviderWizardModalTitle", () => { @@ -374,7 +211,7 @@ const EXPECTED_MODAL_HEADER_LABEL: Record = { okta: "Okta", }; -// Method-specific labels for the credentials step. Providers with per-method +// Method-specific labels for the connect step. Providers with per-method // docs subsections (AWS, M365, Alibaba Cloud, Cloudflare) must round-trip // through `getProviderWizardDocsDestination` into a specific label — // otherwise the URL in `PROVIDER_CREDENTIALS_METHOD_DOCS_URL` and the label @@ -397,8 +234,6 @@ const EXPECTED_METHOD_MODAL_HEADER_LABEL: Array< describe("provider label parity", () => { const STEPS = [ PROVIDER_WIZARD_STEP.CONNECT, - PROVIDER_WIZARD_STEP.CREDENTIALS, - PROVIDER_WIZARD_STEP.TEST, PROVIDER_WIZARD_STEP.LAUNCH, ] as const; @@ -428,7 +263,7 @@ describe("provider label parity", () => { ] of EXPECTED_METHOD_MODAL_HEADER_LABEL) { const { link } = getProviderHelpText( provider, - PROVIDER_WIZARD_STEP.CREDENTIALS, + PROVIDER_WIZARD_STEP.CONNECT, method, ); const label = getProviderWizardDocsDestination(link); diff --git a/ui/components/providers/wizard/provider-wizard-modal.utils.ts b/ui/components/providers/wizard/provider-wizard-modal.utils.ts index 1a3932ff47..7bcc223f44 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.utils.ts +++ b/ui/components/providers/wizard/provider-wizard-modal.utils.ts @@ -1,108 +1,18 @@ -import { - ORG_SETUP_PHASE, - ORG_WIZARD_STEP, - OrgSetupPhase, - OrgWizardStep, -} 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, + UPDATE_PROVIDER_WIZARD_STEPS, } from "./wizard-stepper"; -const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice( - 0, - PROVIDER_WIZARD_STEP.LAUNCH, -); - -const AWS_CONNECT_STEPPER_ROW = 0; -const AWS_LAUNCH_STEPPER_ROW = 1; - -interface ProviderWizardStepperInput { - mode: ProviderWizardMode; - providerType: ProviderType | null; - currentStep: ProviderWizardStep; - // "Add credentials" on a registered account opens on CREDENTIALS and still walks - // the separate steps, so it keeps the generic rows. - isDirectCredentialsEntry?: boolean; -} - -/** Rows for the provider-flow stepper plus the offset that maps `currentStep` onto them. */ -export function getProviderWizardStepper({ - mode, - providerType, - currentStep, - isDirectCredentialsEntry = false, -}: ProviderWizardStepperInput) { - if (mode === PROVIDER_WIZARD_MODE.UPDATE) { - return { steps: UPDATE_MODE_WIZARD_STEPS, stepOffset: 0 }; - } - if (providerType === "aws" && !isDirectCredentialsEntry) { - // Only CONNECT and LAUNCH are reachable here; CREDENTIALS and TEST have no - // row of their own, so anything short of LAUNCH folds onto the first row. - const stepOffset = - currentStep === PROVIDER_WIZARD_STEP.LAUNCH - ? AWS_LAUNCH_STEPPER_ROW - PROVIDER_WIZARD_STEP.LAUNCH - : AWS_CONNECT_STEPPER_ROW - currentStep; - return { steps: AWS_PROVIDER_WIZARD_STEPS, stepOffset }; - } - return { steps: PROVIDER_WIZARD_STEPS, stepOffset: 0 }; -} - -interface CredentialsRetryStepInput { - mode: ProviderWizardMode; - providerType: ProviderType | null; - isDirectCredentialsEntry?: boolean; -} - -/** Where "Back" from the connection test lands: AWS re-enters its one-step form. */ -export function getCredentialsRetryStep({ - mode, - providerType, - isDirectCredentialsEntry = false, -}: CredentialsRetryStepInput): ProviderWizardStep { - if ( - mode === PROVIDER_WIZARD_MODE.ADD && - providerType === "aws" && - !isDirectCredentialsEntry - ) { - return PROVIDER_WIZARD_STEP.CONNECT; - } - return PROVIDER_WIZARD_STEP.CREDENTIALS; -} - -interface LaunchBackStepInput { - providerType: ProviderType | null; - isDirectCredentialsEntry?: boolean; -} - -/** Where "Back" from the launch step lands: AWS returns to its one-step form. */ -export function getLaunchBackStep({ - providerType, - isDirectCredentialsEntry = false, -}: LaunchBackStepInput): ProviderWizardStep { - if (providerType === "aws" && !isDirectCredentialsEntry) { - return PROVIDER_WIZARD_STEP.CONNECT; - } - return PROVIDER_WIZARD_STEP.TEST; -} - -export function getOrganizationsStepperOffset( - currentStep: OrgWizardStep, - setupPhase: OrgSetupPhase, -) { - if (currentStep === ORG_WIZARD_STEP.SETUP) { - return setupPhase === ORG_SETUP_PHASE.ACCESS ? 1 : 0; - } - - return 1; +/** Stepper rows for the provider flow: updating credentials is a single step. */ +export function getProviderWizardSteps(mode: ProviderWizardMode) { + return mode === PROVIDER_WIZARD_MODE.UPDATE + ? UPDATE_PROVIDER_WIZARD_STEPS + : PROVIDER_WIZARD_STEPS; } export function getProviderWizardModalTitle(mode: ProviderWizardMode) { diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-forms.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-forms.tsx new file mode 100644 index 0000000000..0482868a1d --- /dev/null +++ b/ui/components/providers/wizard/steps/aws/aws-connect-forms.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDownIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { + Control, + FieldValues, + Resolver, + useForm, + useWatch, +} from "react-hook-form"; + +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 { Button } from "@/components/shadcn/button/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/shadcn/collapsible"; +import { Form } from "@/components/shadcn/form"; +import { getAWSCredentialsTemplateLinks } from "@/lib/external-urls"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { AWSCredentials, AWSCredentialsRole } from "@/types"; + +import { AliasField } from "../connect/alias-field"; +import { ConnectionFeedback } from "../connect/connection-feedback"; +import type { ConnectFormProps } from "../connect/types"; +import { readDraftValues, useDraftValues } from "../connect/use-connect-draft"; +import { useConnectSubmit } from "../connect/use-connect-submit"; + +import { + awsKeysConnectSchema, + type AwsKeysConnectValues, + awsRoleConnectSchema, + type AwsRoleConnectValues, +} from "./aws-connect.schema"; +import { parseAwsAccountIdFromRoleArn } from "./aws-role-arn"; +import { AWS_ACCESS_METHOD } from "./types"; + +const lockedUid = () => useProviderWizardStore.getState().providerUid; + +/** IAM role: the account is read from the role ARN. */ +export function AwsRoleConnectForm({ + formId, + locked, + ...connectProps +}: ConnectFormProps) { + const { data: session } = useSession(); + const externalId = session?.tenantId ?? ""; + + const form = useForm({ + resolver: zodResolver( + awsRoleConnectSchema, + ) as unknown as Resolver, + mode: "onChange", + defaultValues: { + [ProviderCredentialFields.PROVIDER_ID]: "", + [ProviderCredentialFields.PROVIDER_TYPE]: "aws", + [ProviderCredentialFields.PROVIDER_ALIAS]: "", + // The role is assumed with Prowler's own credentials (Cloud's identity or the + // host's AWS SDK chain); static keys are a method of their own, never mixed in. + [ProviderCredentialFields.CREDENTIALS_TYPE]: + ProviderCredentialFields.CREDENTIALS_TYPE_AWS, + [ProviderCredentialFields.ROLE_ARN]: "", + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", + [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", + [ProviderCredentialFields.ROLE_SESSION_NAME]: "", + [ProviderCredentialFields.SESSION_DURATION]: "3600", + ...readDraftValues("aws", AWS_ACCESS_METHOD.ROLE), + }, + }); + useDraftValues(form, "aws", AWS_ACCESS_METHOD.ROLE); + + const roleArn = useWatch({ + control: form.control, + name: ProviderCredentialFields.ROLE_ARN, + }); + const detectedAccountId = parseAwsAccountIdFromRoleArn(roleArn ?? ""); + + const { onSubmit, isTesting, connectionError, connectionPending } = + useConnectSubmit({ + ...connectProps, + form, + locked, + providerType: "aws", + method: AWS_ACCESS_METHOD.ROLE, + accountField: ProviderCredentialFields.ROLE_ARN, + accountResolved: detectedAccountId !== null, + resolveIdentity: (values) => ({ + uid: parseAwsAccountIdFromRoleArn( + values[ProviderCredentialFields.ROLE_ARN] ?? "", + ), + alias: locked + ? undefined + : (values[ProviderCredentialFields.PROVIDER_ALIAS] ?? ""), + }), + // The external id is the tenant's, never user input, so it joins at submit time. + extraValues: { [ProviderCredentialFields.EXTERNAL_ID]: externalId }, + }); + + // One template for every build: self-hosted users set the account that assumes + // the role, so the AccountId parameter must stay editable in the console. + const templateLinks = getAWSCredentialsTemplateLinks(externalId); + const roleControl = form.control as unknown as Control; + + return ( +
+ + + +
+

1. Create the IAM role

+ +
+ +
+

2. Paste the role ARN

+
+ + {detectedAccountId && !locked && ( +

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

+ )} +
+ {!locked && ( + } + /> + )} +
+ + + + + + + + + + + + ); +} + +/** Static access keys: the account id is typed (or fixed when updating). */ +export function AwsKeysConnectForm({ + formId, + locked, + ...connectProps +}: ConnectFormProps) { + const form = useForm({ + resolver: zodResolver( + awsKeysConnectSchema, + ) as unknown as Resolver, + mode: "onChange", + defaultValues: { + [ProviderCredentialFields.PROVIDER_ID]: "", + [ProviderCredentialFields.PROVIDER_TYPE]: "aws", + [ProviderCredentialFields.PROVIDER_UID]: "", + [ProviderCredentialFields.PROVIDER_ALIAS]: "", + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", + [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", + ...readDraftValues("aws", AWS_ACCESS_METHOD.CREDENTIALS), + ...(locked + ? { [ProviderCredentialFields.PROVIDER_UID]: lockedUid() ?? "" } + : {}), + }, + }); + useDraftValues(form, "aws", AWS_ACCESS_METHOD.CREDENTIALS); + + const { onSubmit, isTesting, connectionError, connectionPending } = + useConnectSubmit({ + ...connectProps, + form, + locked, + providerType: "aws", + method: AWS_ACCESS_METHOD.CREDENTIALS, + accountField: ProviderCredentialFields.PROVIDER_UID, + resolveIdentity: (values) => ({ + uid: values[ProviderCredentialFields.PROVIDER_UID]?.trim() || null, + alias: locked + ? undefined + : (values[ProviderCredentialFields.PROVIDER_ALIAS] ?? ""), + }), + }); + + return ( +
+ + + + {!locked && ( + value.replace(/\D/g, "").slice(0, 12)} + /> + )} + } + /> + {!locked && ( + } + /> + )} + + + ); +} diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx deleted file mode 100644 index b713814cab..0000000000 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx +++ /dev/null @@ -1,687 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { - ChevronDownIcon, - CircleAlert, - KeyRound, - Loader2, - ShieldCheck, - UserPlus, -} from "lucide-react"; -import { useSession } from "next-auth/react"; -import { useEffect, useRef, useState } from "react"; -import { - Control, - FieldValues, - Resolver, - UseFormReturn, - useForm, - useFormState, - useWatch, -} from "react-hook-form"; - -import { ConnectionPending } from "@/components/icons"; -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 { useAuth } from "@/hooks/use-auth"; -import { useFormServerErrors } from "@/hooks/use-form-server-errors"; -import { useMountEffect } from "@/hooks/use-mount-effect"; -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_METHOD, - PROVIDER_FUNNEL_STEP, -} from "@/lib/provider-funnel/provider-funnel-events"; -import { testProviderConnection } from "@/lib/provider-helpers"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import type { AWSCredentials, AWSCredentialsRole } from "@/types"; -import { - AWS_CONNECT_PANEL, - type AwsConnectDraft, - type AwsConnectPanel, -} from "@/types/provider-wizard"; -import { CONNECTION_CHECK_STATUS } from "@/types/providers"; - -import { InviteTeammatePanel } from "../invite-teammate/invite-teammate-panel"; - -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 launch step) 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; - -const initialPanel = (): AwsConnectPanel => - readDraft()?.panel === AWS_CONNECT_PANEL.INVITE - ? AWS_CONNECT_PANEL.INVITE - : AWS_CONNECT_PANEL.ACCESS; - -function useDraftValues( - form: UseFormReturn, - key: keyof Pick, -) { - const values = useWatch({ control: form.control }); - useEffect(() => { - useProviderWizardStore - .getState() - .setAwsConnectDraft({ [key]: values as AwsConnectDraft[typeof key] }); - }, [key, values]); -} - -interface AwsConnectStepProps { - formId: string; - onConnected: () => void; - onSelectOrganizations: () => void; - onUiStateChange: (state: AwsConnectUiState) => void; -} - -/** One form to register an AWS account, store its credentials and test the connection. */ -export function AwsConnectStep({ - formId, - onConnected, - onSelectOrganizations, - onUiStateChange, -}: AwsConnectStepProps) { - // Local state needed: the access method only matters until the account is connected. - const [method, setMethod] = useState(initialMethod); - // Local state needed: whether the step shows the access forms or hands the - // account over to a teammate. Separate from the method, which is the `via` - // an account gets connected with. - const [panel, setPanel] = useState(initialPanel); - // Local state needed: the active form reports it so the method cannot change mid-submit. - const [isBusy, setIsBusy] = useState(false); - const { permissions } = useAuth(); - // Inviting takes `manage_account`, which the API also asks of the roles list. - const canInvite = permissions.manage_account === true; - - const isInvite = canInvite && panel === AWS_CONNECT_PANEL.INVITE; - const isRole = !isInvite && method === AWS_ACCESS_METHOD.ROLE; - const isKeys = !isInvite && method === AWS_ACCESS_METHOD.CREDENTIALS; - - const chooseMethod = (next: AwsAccessMethod) => { - setPanel(AWS_CONNECT_PANEL.ACCESS); - setMethod(next); - useProviderWizardStore - .getState() - .setAwsConnectDraft({ method: next, panel: AWS_CONNECT_PANEL.ACCESS }); - }; - - const chooseInvite = () => { - if (isInvite) return; - setPanel(AWS_CONNECT_PANEL.INVITE); - useProviderWizardStore - .getState() - .setAwsConnectDraft({ panel: AWS_CONNECT_PANEL.INVITE }); - dispatchProviderFunnel({ - step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED, - providerType: "aws", - method: PROVIDER_FUNNEL_METHOD.INVITE_TEAMMATE, - }); - }; - - return ( -
- - - - -
-

- Choose how to connect this account. -

- chooseMethod(AWS_ACCESS_METHOD.ROLE)} - > - - Recommended - - - chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)} - /> - {canInvite && ( - - )} -
- - {isInvite && ( - - )} - - {isRole && ( - - )} - - {isKeys && ( - - )} -
- ); -} - -interface ConnectFormProps - extends Pick< - AwsConnectStepProps, - "formId" | "onConnected" | "onUiStateChange" - > { - onBusyChange: (isBusy: boolean) => void; -} - -interface UseAwsConnectSubmitOptions { - form: UseFormReturn; - method: AwsAccessMethod; - // The field an account-level API error belongs to for this method. - accountField: string; - // Beyond form validity: the role form also needs an account read from the ARN. - accountResolved?: boolean; - extraValues?: Record; - onConnected: () => void; - onBusyChange: (isBusy: boolean) => void; - onUiStateChange: (state: AwsConnectUiState) => void; -} - -const CONNECTION_FAILED_MESSAGE = - "Prowler could not connect with these credentials. Review them and try again."; - -const CONNECTION_UNREACHABLE_MESSAGE = - "The connection test could not be completed. The account is saved, so you can try again."; - -// Fallback only: `testProviderConnection` already carries this same message on -// `error` for a pending result (see `resolveProviderConnectionState`). -const CONNECTION_PENDING_MESSAGE = - "The connection test is still running. Refresh in a moment to see the result."; - -/** Footer label for the one-step form: the test and the retry share the submit. */ -const resolveActionLabel = ({ - isTesting, - isSubmitting, - hasFailed, - hasPending, -}: { - isTesting: boolean; - isSubmitting: boolean; - hasFailed: boolean; - hasPending: boolean; -}) => { - if (isTesting) return "Testing connection..."; - if (isSubmitting) return "Connecting account..."; - if (hasFailed) return "Retry connection"; - return hasPending ? "Check again" : "Connect account"; -}; - -/** Registers the account, stores its credentials and tests the connection in one submit. */ -function useAwsConnectSubmit({ - form, - method, - accountField, - accountResolved = true, - extraValues, - onConnected, - onBusyChange, - onUiStateChange, -}: UseAwsConnectSubmitOptions) { - const { handleServerResponse } = useFormServerErrors(form, { - ...PROVIDER_CREDENTIALS_ERROR_MAPPING, - [AWS_UID_ERROR_POINTER]: accountField, - [UNIQUE_TOGETHER_ERROR_POINTER]: accountField, - [ALIAS_ERROR_POINTER]: ProviderCredentialFields.PROVIDER_ALIAS, - }); - // Local state needed: the connection test runs inside the submit, and its - // outcome belongs to this step rather than to any form field. - const [isTesting, setIsTesting] = useState(false); - const [connectionError, setConnectionError] = useState(null); - // Still running past the wait -- neither a pass nor a fail. Kept separate - // from `connectionError` so it never renders with the destructive styling a - // confirmed failure gets, and never counts as one. - const [connectionPending, setConnectionPending] = useState( - null, - ); - // 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; - const isBusy = isSubmitting || isTesting; - // Closing the wizard (or switching to organizations) unmounts the step while a - // test may still be running; its result must not advance a wizard already reset. - const isActiveRef = useRef(true); - useMountEffect(() => { - isActiveRef.current = true; - return () => { - isActiveRef.current = false; - }; - }); - - // Same contract ConnectAccountForm uses: the wizard footer lives outside the step. - // Both callbacks must be stable setters, or this effect would loop. - useEffect(() => { - onBusyChange(isBusy); - onUiStateChange({ - showBack: true, - showAction: true, - actionLabel: resolveActionLabel({ - isTesting, - isSubmitting, - hasFailed: connectionError !== null, - hasPending: connectionPending !== null, - }), - actionDisabled: !canSubmit || isBusy, - isLoading: isBusy, - }); - }, [ - canSubmit, - connectionError, - connectionPending, - isBusy, - isSubmitting, - isTesting, - onBusyChange, - onUiStateChange, - ]); - - // A past failure or a still-pending result must not sit above the field the - // user is already correcting. - useEffect(() => { - if (connectionError === null && connectionPending === null) return; - const subscription = form.watch(() => { - setConnectionError(null); - setConnectionPending(null); - }); - return () => subscription.unsubscribe(); - }, [connectionError, connectionPending, form]); - - const onSubmit = form.handleSubmit(async (values) => { - setConnectionError(null); - setConnectionPending(null); - 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; - } - - // The account stays registered whatever the test says; resubmitting edits it - // in place. Task polling rejects on a 5xx, so the flag has to be cleared in a - // finally or the step would stay stuck on "Testing connection...". - let connected = false; - setIsTesting(true); - try { - const connection = await testProviderConnection(result.providerId); - connected = connection.status === CONNECTION_CHECK_STATUS.SUCCESS; - if (connection.status === CONNECTION_CHECK_STATUS.FAILED) { - setConnectionError(connection.error || CONNECTION_FAILED_MESSAGE); - } else if (connection.status === CONNECTION_CHECK_STATUS.PENDING) { - setConnectionPending(connection.error || CONNECTION_PENDING_MESSAGE); - } - } catch { - setConnectionError(CONNECTION_UNREACHABLE_MESSAGE); - } finally { - setIsTesting(false); - } - - if (connected && isActiveRef.current) onConnected(); - }); - - return { onSubmit, isTesting, connectionError, connectionPending }; -} - -/** - * Progress line while the test runs, the neutral message once the wait is - * exhausted with no verdict, or the API's reason once it is refused. - */ -function ConnectionFeedback({ - isTesting, - error, - pending, -}: { - isTesting: boolean; - error: string | null; - pending: string | null; -}) { - const alertRef = useRef(null); - - // The form scrolls inside the modal and the action button sits outside it, so - // an error or a pending result raised from the footer can land above the fold. - useEffect(() => { - if (!error && !pending) return; - // Guarded: jsdom has no scrollIntoView, and a throw here would unmount the step. - alertRef.current?.scrollIntoView?.({ block: "start", behavior: "smooth" }); - }, [error, pending]); - - if (isTesting) { - return ( -

- - Testing the connection. This usually takes a few seconds. -

- ); - } - - if (pending) { - return ( -
-
- ); - } - - if (!error) return null; - - return ( -
- -

- {error} -

-
- ); -} - -function AwsRoleConnectForm({ - formId, - onConnected, - onBusyChange, - onUiStateChange, -}: ConnectFormProps) { - const { data: session } = useSession(); - const externalId = session?.tenantId ?? ""; - - const form = useForm({ - resolver: zodResolver( - awsRoleConnectSchema, - ) as unknown as Resolver, - mode: "onChange", - defaultValues: { - [ProviderCredentialFields.PROVIDER_ID]: "", - [ProviderCredentialFields.PROVIDER_TYPE]: "aws", - [ProviderCredentialFields.PROVIDER_ALIAS]: "", - // The role is assumed with Prowler's own credentials (Cloud's identity or the - // host's AWS SDK chain); static keys are a method of their own, never mixed in. - [ProviderCredentialFields.CREDENTIALS_TYPE]: - ProviderCredentialFields.CREDENTIALS_TYPE_AWS, - [ProviderCredentialFields.ROLE_ARN]: "", - [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", - [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", - [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", - [ProviderCredentialFields.ROLE_SESSION_NAME]: "", - [ProviderCredentialFields.SESSION_DURATION]: "3600", - ...readDraft()?.roleValues, - }, - }); - useDraftValues(form, "roleValues"); - - const roleArn = useWatch({ - control: form.control, - name: ProviderCredentialFields.ROLE_ARN, - }); - const detectedAccountId = parseAwsAccountIdFromRoleArn(roleArn ?? ""); - - const { onSubmit, isTesting, connectionError, connectionPending } = - useAwsConnectSubmit({ - form, - method: AWS_ACCESS_METHOD.ROLE, - accountField: ProviderCredentialFields.ROLE_ARN, - accountResolved: detectedAccountId !== null, - // The external id is the tenant's, never user input, so it joins at submit time. - extraValues: { [ProviderCredentialFields.EXTERNAL_ID]: externalId }, - onConnected, - onBusyChange, - onUiStateChange, - }); - - // One template for every build: self-hosted users set the account that assumes - // the role, so the AccountId parameter must stay editable in the console. - const templateLinks = getAWSCredentialsTemplateLinks(externalId); - const roleControl = form.control as unknown as Control; - - return ( -
- - - -
-

1. Create the IAM role

- -
- -
-

2. Paste the role ARN

-
- - {detectedAccountId && ( -

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

- )} -
- } - /> -
- - - - - - - - - - - - ); -} - -function AwsKeysConnectForm({ - formId, - onConnected, - onBusyChange, - onUiStateChange, -}: ConnectFormProps) { - const form = useForm({ - resolver: zodResolver( - awsKeysConnectSchema, - ) as unknown as Resolver, - mode: "onChange", - defaultValues: { - [ProviderCredentialFields.PROVIDER_ID]: "", - [ProviderCredentialFields.PROVIDER_TYPE]: "aws", - [ProviderCredentialFields.PROVIDER_UID]: "", - [ProviderCredentialFields.PROVIDER_ALIAS]: "", - [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", - [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", - [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", - ...readDraft()?.keysValues, - }, - }); - useDraftValues(form, "keysValues"); - - const { onSubmit, isTesting, connectionError, connectionPending } = - useAwsConnectSubmit({ - form, - method: AWS_ACCESS_METHOD.CREDENTIALS, - accountField: ProviderCredentialFields.PROVIDER_UID, - onConnected, - onBusyChange, - onUiStateChange, - }); - - return ( -
- - - - value.replace(/\D/g, "").slice(0, 12)} - /> - } - /> - } /> - - - ); -} - -function AliasField({ control }: { control: Control }) { - return ( - - ); -} diff --git a/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx b/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx deleted file mode 100644 index 2f24f177d4..0000000000 --- a/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { Badge } from "@/components/shadcn/badge/badge"; -import { Tabs, TabsList, TabsTrigger } from "@/components/shadcn/tabs/tabs"; -import { isCloud } from "@/lib/shared/env"; -import { useCloudUpgradeStore } from "@/store"; -import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; - -export const AWS_ONBOARDING_METHOD = { - SINGLE: "single", - ORGANIZATION: "organization", -} as const; - -export type AwsOnboardingMethod = - (typeof AWS_ONBOARDING_METHOD)[keyof typeof AWS_ONBOARDING_METHOD]; - -interface AwsOnboardingMethodTabsProps { - value: AwsOnboardingMethod; - onSelectSingle?: () => void; - onSelectOrganizations?: () => void; -} - -/** Single account vs. whole organization switch at the top of the AWS connect step. */ -export function AwsOnboardingMethodTabs({ - value, - onSelectSingle, - onSelectOrganizations, -}: AwsOnboardingMethodTabsProps) { - const isCloudEnv = isCloud(); - const openCloudUpgrade = useCloudUpgradeStore( - (state) => state.openCloudUpgrade, - ); - - const handleValueChange = (next: string) => { - if (next === value) return; - if (next === AWS_ONBOARDING_METHOD.SINGLE) { - onSelectSingle?.(); - return; - } - if (isCloudEnv) { - onSelectOrganizations?.(); - return; - } - openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS); - }; - - return ( - - - - Single AWS Account - - - Cloud - - ) - } - > - Full AWS Organization - - - - ); -} diff --git a/ui/components/providers/wizard/steps/aws/connect-aws-account.ts b/ui/components/providers/wizard/steps/aws/connect-aws-account.ts deleted file mode 100644 index 009a28e117..0000000000 --- a/ui/components/providers/wizard/steps/aws/connect-aws-account.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - addCredentialsProvider, - addProvider, - updateCredentialsProvider, - updateProvider, -} from "@/actions/providers/providers"; -import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { useUIStore } from "@/store/ui/store"; -import type { ApiError } from "@/types"; -import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; - -import { - AWS_ROLE_ARN_MESSAGE, - parseAwsAccountIdFromRoleArn, -} from "./aws-role-arn"; -import { AWS_ACCESS_METHOD, type AwsAccessMethod } from "./types"; - -export const AWS_UID_ERROR_POINTER = "/data/attributes/uid"; - -// Account fields travel with the provider, never with its secret. -const ACCOUNT_FIELDS: readonly string[] = [ - ProviderCredentialFields.PROVIDER_UID, - ProviderCredentialFields.PROVIDER_ALIAS, -]; - -export interface AwsConnectInput { - method: AwsAccessMethod; - values: Record; -} - -interface AwsConnectSuccess { - ok: true; - providerId: string; -} - -interface AwsConnectFailure { - ok: false; - errors: ApiError[]; -} - -export type AwsConnectResult = AwsConnectSuccess | AwsConnectFailure; - -const asText = (value: unknown) => (typeof value === "string" ? value : ""); - -// Blank fields are left out, so optional credentials never reach the API as "". -const toFormData = (values: Record) => { - const formData = new FormData(); - Object.entries(values).forEach(([key, value]) => { - const text = asText(value).trim(); - if (text) formData.append(key, text); - }); - return formData; -}; - -interface CreatedResource { - id?: unknown; -} - -interface CreateActionResponse { - data?: CreatedResource; - error?: string; - errors?: ApiError[]; -} - -const UNCONFIRMED_RESPONSE_MESSAGE = - "The API did not confirm the request. Please try again."; - -// Actions resolve { errors } on a refusal and { error } on a crash, never throwing. -// A body with no id is reported too, or the step would stall without feedback. -const readCreatedId = (response: unknown) => { - const body = response as CreateActionResponse | undefined; - if (body?.errors?.length) return { id: null, errors: body.errors }; - if (body?.error) return { id: null, errors: [{ detail: body.error }] }; - const id = body?.data?.id; - if (typeof id !== "string" || !id) { - return { id: null, errors: [{ detail: UNCONFIRMED_RESPONSE_MESSAGE }] }; - } - return { id, errors: null }; -}; - -const resolveAccountId = ({ method, values }: AwsConnectInput) => - method === AWS_ACCESS_METHOD.ROLE - ? parseAwsAccountIdFromRoleArn( - asText(values[ProviderCredentialFields.ROLE_ARN]), - ) - : asText(values[ProviderCredentialFields.PROVIDER_UID]).trim() || null; - -// A retry may carry a new alias; the account registered earlier has to follow it. -const renameProvider = async (providerId: string, alias: string) => { - const store = useProviderWizardStore.getState(); - if ((store.providerAlias ?? "") === alias) - return { providerId, errors: null }; - - const updated = readCreatedId( - await updateProvider( - toFormData({ - [ProviderCredentialFields.PROVIDER_ID]: providerId, - [ProviderCredentialFields.PROVIDER_ALIAS]: alias, - }), - ), - ); - if (!updated.id) return { providerId: null, errors: updated.errors }; - - store.setProvider({ - id: providerId, - type: "aws", - uid: store.providerUid ?? "", - alias: alias || null, - }); - return { providerId, errors: null }; -}; - -// A retry after a refused secret must not register the same account twice. -const ensureProvider = async (uid: string, alias: string) => { - const store = useProviderWizardStore.getState(); - if (store.providerId && store.providerUid === uid) { - return renameProvider(store.providerId, alias); - } - - const created = readCreatedId( - await addProvider( - toFormData({ - [ProviderCredentialFields.PROVIDER_TYPE]: "aws", - [ProviderCredentialFields.PROVIDER_UID]: uid, - [ProviderCredentialFields.PROVIDER_ALIAS]: alias, - }), - ), - ); - if (!created.id) return { providerId: null, errors: created.errors }; - - const providerId = created.id; - store.setProvider({ - id: providerId, - type: "aws", - uid, - alias: alias || null, - }); - store.setSecretId(null); - store.setMode(PROVIDER_WIZARD_MODE.ADD); - // The layout only re-counts providers on a server render; flip the shared flag now. - useUIStore.getState().setHasProviders(true); - return { providerId, errors: null }; -}; - -/** Registers the AWS account and stores its credentials in a single submit. */ -export async function connectAwsAccount( - input: AwsConnectInput, -): Promise { - const uid = resolveAccountId(input); - if (!uid) { - return { - ok: false, - errors: [ - { - detail: AWS_ROLE_ARN_MESSAGE, - source: { pointer: AWS_UID_ERROR_POINTER }, - } as ApiError, - ], - }; - } - - const alias = asText( - input.values[ProviderCredentialFields.PROVIDER_ALIAS], - ).trim(); - const provider = await ensureProvider(uid, alias); - if (!provider.providerId) return { ok: false, errors: provider.errors ?? [] }; - - const secretValues = Object.fromEntries( - Object.entries(input.values).filter( - ([key]) => !ACCOUNT_FIELDS.includes(key), - ), - ); - const secretFormData = toFormData({ - ...secretValues, - [ProviderCredentialFields.PROVIDER_ID]: provider.providerId, - [ProviderCredentialFields.PROVIDER_TYPE]: "aws", - }); - // A provider holds one secret: resubmitting a connected account edits it in place. - const storedSecretId = useProviderWizardStore.getState().secretId; - const secret = readCreatedId( - storedSecretId - ? await updateCredentialsProvider(storedSecretId, secretFormData) - : await addCredentialsProvider(secretFormData), - ); - if (!secret.id) return { ok: false, errors: secret.errors ?? [] }; - - const store = useProviderWizardStore.getState(); - store.setSecretId(secret.id); - store.setVia(input.method); - return { ok: true, providerId: provider.providerId }; -} diff --git a/ui/components/providers/wizard/steps/aws/types.ts b/ui/components/providers/wizard/steps/aws/types.ts index 3eddddd242..633ed98f49 100644 --- a/ui/components/providers/wizard/steps/aws/types.ts +++ b/ui/components/providers/wizard/steps/aws/types.ts @@ -5,23 +5,3 @@ export const AWS_ACCESS_METHOD = { export type AwsAccessMethod = (typeof AWS_ACCESS_METHOD)[keyof typeof AWS_ACCESS_METHOD]; - -/** What the footer's main action does: submit the step's form, or close the wizard. */ -export const AWS_CONNECT_ACTION_KIND = { - SUBMIT: "submit", - CLOSE: "close", -} as const; - -export type AwsConnectActionKind = - (typeof AWS_CONNECT_ACTION_KIND)[keyof typeof AWS_CONNECT_ACTION_KIND]; - -/** What the step publishes so the wizard can draw its footer. */ -export interface AwsConnectUiState { - showBack: boolean; - showAction: boolean; - actionLabel: string; - actionDisabled: boolean; - isLoading: boolean; - /** Absent means submit. */ - actionKind?: AwsConnectActionKind; -} diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx index 119fa7df2f..a92d274f47 100644 --- a/ui/components/providers/wizard/steps/connect-step.tsx +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -2,90 +2,86 @@ import { useEffect, useRef, useState } from "react"; -import { - ConnectAccountForm, - ConnectAccountSuccessData, -} from "@/components/providers/workflow/forms"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -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 { ProviderPicker } from "@/components/providers/workflow/forms"; +import type { OrgFlowType } from "@/types/organizations"; +import type { ProviderType } from "@/types/providers"; -import { AwsConnectStep } from "./aws/aws-connect-step"; -import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "./aws/types"; +import { ProviderConnectStep } from "./connect/provider-connect-step"; +import { CONNECT_ACTION_KIND, type ConnectUiState } from "./connect/types"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, } from "./footer-controls"; +const FORM_ID = "provider-wizard-connect-form"; + +const PICKER_UI_STATE: ConnectUiState = { + showBack: false, + showAction: false, + actionLabel: "", + actionDisabled: true, + isLoading: false, +}; + interface ConnectStepProps { - onNext: () => void; - /** AWS registers, stores and tests the account in this step, so it skips ahead. */ - onCredentialsSaved: () => void; - /** AWS offers it as the footer action once a teammate has been invited instead. */ + /** The provider being connected; null while the user is still picking one. */ + providerType: ProviderType | null; + /** The wizard was opened on an existing account: only its credentials change. */ + locked: boolean; + onProviderTypeChange: (providerType: ProviderType | null) => void; + /** The account is saved and its connection test passed. */ + onConnected: () => void; + /** The footer offers it once a teammate has been invited instead, or to cancel an update. */ onClose: () => 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; } +/** Pick a provider, then connect it: account, credentials and test in one step. */ export function ConnectStep({ - onNext, - onCredentialsSaved, + providerType, + locked, + onProviderTypeChange, + onConnected, onClose, onSelectOrganizations, onFooterChange, - onProviderTypeChange, - initialProviderType = null, }: ConnectStepProps) { - const { setProvider, setVia, setSecretId, setMode } = - useProviderWizardStore(); - const backHandlerRef = useRef<(() => void) | null>(null); - // The modal hands over a fresh `onClose` every render; the footer effect - // keeps one closure and reads the latest through the ref, as LaunchStep does. - const closeHandlerRef = useRef(onClose); - closeHandlerRef.current = onClose; - // 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, - actionLabel: "Next", - actionDisabled: true, - isLoading: false, - }); + // Local state needed: the active form publishes what the footer shows. + const [uiState, setUiState] = useState(PICKER_UI_STATE); + // The modal hands over fresh callbacks every render; the footer effect keeps + // one closure and reads the latest through refs. + const closeRef = useRef(onClose); + const providerChangeRef = useRef(onProviderTypeChange); + // Synced after render: render must not write refs. + useEffect(() => { + closeRef.current = onClose; + providerChangeRef.current = onProviderTypeChange; + }, [onClose, onProviderTypeChange]); - const formId = "provider-wizard-connect-form"; - - const handleSuccess = (data: ConnectAccountSuccessData) => { - setProvider({ - id: data.id, - type: data.providerType, - uid: data.uid, - alias: data.alias, - }); - 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(); - }; + const isPicking = providerType === null; useEffect(() => { + if (isPicking) { + onFooterChange({ + showBack: false, + backLabel: "Back", + showAction: false, + actionLabel: "", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + }); + return; + } // Nothing left to submit once a teammate has been invited: the action closes. - const closes = uiState.actionKind === AWS_CONNECT_ACTION_KIND.CLOSE; + const closes = uiState.actionKind === CONNECT_ACTION_KIND.CLOSE; onFooterChange({ showBack: uiState.showBack, - backLabel: "Back", + // An account opened from the list has nothing to go back to. + backLabel: locked ? "Cancel" : "Back", backDisabled: uiState.isLoading, - // Leaving AWS remounts the generic form on a fresh provider list. - onBack: isAwsFlow - ? () => setIsAwsFlow(false) - : () => backHandlerRef.current?.(), + onBack: locked + ? () => closeRef.current() + : () => providerChangeRef.current(null), showAction: uiState.showAction, actionLabel: uiState.actionLabel, actionLoading: uiState.isLoading, @@ -93,40 +89,25 @@ export function ConnectStep({ actionType: closes ? WIZARD_FOOTER_ACTION_TYPE.BUTTON : WIZARD_FOOTER_ACTION_TYPE.SUBMIT, - actionFormId: closes ? undefined : formId, - onAction: closes ? () => closeHandlerRef.current() : undefined, + actionFormId: closes ? undefined : FORM_ID, + onAction: closes ? () => closeRef.current() : undefined, }); - }, [isAwsFlow, onFooterChange, uiState]); + }, [isPicking, locked, onFooterChange, uiState]); - const handleProviderTypeChange = (providerType: ProviderType | null) => { - onProviderTypeChange(providerType); - if (providerType === "aws") setIsAwsFlow(true); - }; - - if (isAwsFlow) { - return ( - - onSelectOrganizations(ORGANIZATION_TYPE.AWS) - } - onUiStateChange={setUiState} - /> - ); + if (isPicking) { + return ; } return ( - { - backHandlerRef.current = handler; - }} /> ); } diff --git a/ui/components/providers/wizard/steps/connect/alias-field.tsx b/ui/components/providers/wizard/steps/connect/alias-field.tsx new file mode 100644 index 0000000000..51ce29f9fe --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/alias-field.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Control, FieldValues } from "react-hook-form"; + +import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; + +export function AliasField({ control }: { control: Control }) { + return ( + + ); +} diff --git a/ui/components/providers/wizard/steps/connect/connect-methods.ts b/ui/components/providers/wizard/steps/connect/connect-methods.ts new file mode 100644 index 0000000000..16490e392b --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/connect-methods.ts @@ -0,0 +1,211 @@ +import { + Cloud, + FileKey, + FileText, + KeyRound, + type LucideIcon, + ShieldCheck, + Ticket, +} from "lucide-react"; + +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import type { KnownProviderType } from "@/types/providers"; + +export interface ConnectMethod { + /** The `via` the account is stored with; null for a provider with one method. */ + via: string | null; + label: string; + icon: LucideIcon; + recommended?: boolean; +} + +const single = (label: string, icon: LucideIcon): ConnectMethod[] => [ + { via: null, label, icon }, +]; + +/** How each built-in provider can be connected, in the order the step lists them. */ +const PROVIDER_CONNECT_METHODS = { + aws: [ + { via: "role", label: "IAM Role", icon: ShieldCheck, recommended: true }, + { via: "credentials", label: "Static access keys", icon: KeyRound }, + ], + azure: single("Service principal", ShieldCheck), + gcp: [ + { + via: "service-account", + label: "Service account key", + icon: FileKey, + recommended: true, + }, + { + via: "credentials", + label: "Application default credentials", + icon: KeyRound, + }, + ], + kubernetes: single("Kubeconfig", FileText), + m365: [ + { + via: "app_certificate", + label: "App certificate", + icon: FileKey, + recommended: true, + }, + { via: "app_client_secret", label: "App client secret", icon: KeyRound }, + ], + github: [ + { + via: "personal_access_token", + label: "Personal access token", + icon: KeyRound, + }, + { via: "oauth_app", label: "OAuth app token", icon: Ticket }, + { via: "github_app", label: "GitHub App", icon: ShieldCheck }, + ], + googleworkspace: single("Service account", FileKey), + iac: single("Repository access", KeyRound), + image: single("Registry credentials", KeyRound), + oraclecloud: single("API signing key", FileKey), + mongodbatlas: single("API key", KeyRound), + alibabacloud: [ + { via: "role", label: "RAM role", icon: ShieldCheck, recommended: true }, + { via: "credentials", label: "Static access keys", icon: KeyRound }, + ], + cloudflare: [ + { via: "api_token", label: "API token", icon: KeyRound, recommended: true }, + { via: "api_key", label: "API key and email", icon: KeyRound }, + ], + openstack: single("clouds.yaml", Cloud), + vercel: single("API token", KeyRound), + okta: single("API service app", ShieldCheck), +} as const satisfies Record; + +/** Registry providers pick their method inside the dynamic form. */ +const REGISTRY_METHODS: readonly ConnectMethod[] = single( + "Credentials", + KeyRound, +); + +export function getConnectMethods( + providerType: string, +): readonly ConnectMethod[] { + return Object.hasOwn(PROVIDER_CONNECT_METHODS, providerType) + ? PROVIDER_CONNECT_METHODS[providerType as KnownProviderType] + : REGISTRY_METHODS; +} + +/** The method a stored `via` maps to, else the first one listed. */ +export function resolveConnectMethod( + providerType: string, + via: string | null | undefined, +): ConnectMethod { + const methods = getConnectMethods(providerType); + return methods.find((method) => method.via === via) ?? methods[0]; +} + +interface UidFieldDetails { + label: string; + placeholder: string; + /** The footer action while adding: "Connect account", "Connect project", … */ + submitLabel: string; +} + +const UID_FIELD_DETAILS = { + aws: { + label: "Account ID", + placeholder: "e.g. 123456789012", + submitLabel: "Connect account", + }, + gcp: { + label: "Project ID", + placeholder: "e.g. my-gcp-project", + submitLabel: "Connect project", + }, + azure: { + label: "Subscription ID", + placeholder: "e.g. fc94207a-d396-4a14-a7fd-12ab34cd56ef", + submitLabel: "Connect subscription", + }, + kubernetes: { + label: "Kubernetes Context", + placeholder: "e.g. my-cluster-context", + submitLabel: "Connect cluster", + }, + m365: { + label: "Domain ID", + placeholder: "e.g. your-domain.onmicrosoft.com", + submitLabel: "Connect tenant", + }, + github: { + label: "Username/Organization", + placeholder: "e.g. username or organization-name", + submitLabel: "Connect account", + }, + iac: { + label: "Repository URL", + placeholder: "e.g. https://github.com/user/repo", + submitLabel: "Connect repository", + }, + image: { + label: "Registry URL", + placeholder: "e.g. 123456789012.dkr.ecr.us-east-1.amazonaws.com", + submitLabel: "Connect registry", + }, + oraclecloud: { + label: "Tenancy OCID", + placeholder: "e.g. ocid1.tenancy.oc1..aaaaaaa...", + submitLabel: "Connect tenancy", + }, + mongodbatlas: { + label: "Organization ID", + placeholder: "e.g. 5f43a8c4e1234567890abcde", + submitLabel: "Connect organization", + }, + alibabacloud: { + label: "Account ID", + placeholder: "e.g. 1234567890123456", + submitLabel: "Connect account", + }, + cloudflare: { + label: "Account ID", + placeholder: "e.g. a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + submitLabel: "Connect account", + }, + openstack: { + label: "Project ID", + placeholder: "e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890", + submitLabel: "Connect project", + }, + googleworkspace: { + label: "Customer ID", + placeholder: "e.g. C01234abc", + submitLabel: "Connect workspace", + }, + vercel: { + label: "Team ID", + placeholder: "e.g. team_xxxxxxxxxxxxxxxxxxxxxxxx", + submitLabel: "Connect team", + }, + okta: { + label: "Org Domain", + placeholder: "e.g. your-org.okta.com", + submitLabel: "Connect organization", + }, +} as const satisfies Record; + +const REGISTRY_UID_FIELD: UidFieldDetails = { + label: "Provider UID", + placeholder: "Enter the provider UID", + submitLabel: "Connect provider", +}; + +export const UPDATE_SUBMIT_LABEL = "Save and test"; + +export function getUidFieldDetails(providerType: string): UidFieldDetails { + return Object.hasOwn(UID_FIELD_DETAILS, providerType) + ? UID_FIELD_DETAILS[providerType as KnownProviderType] + : REGISTRY_UID_FIELD; +} + +/** The form field an account-level API error lands on. */ +export const UID_FIELD = ProviderCredentialFields.PROVIDER_UID; diff --git a/ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts b/ui/components/providers/wizard/steps/connect/connect-provider-account.test.ts similarity index 53% rename from ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts rename to ui/components/providers/wizard/steps/connect/connect-provider-account.test.ts index 3bbc0f9f26..a86866edaa 100644 --- a/ui/components/providers/wizard/steps/aws/connect-aws-account.test.ts +++ b/ui/components/providers/wizard/steps/connect/connect-provider-account.test.ts @@ -3,34 +3,41 @@ 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"; +import { connectProviderAccount } from "./connect-provider-account"; const { addProvider, addCredentialsProvider, + getProvider, updateProvider, updateCredentialsProvider, + addRegistryProvider, } = vi.hoisted(() => ({ addProvider: vi.fn(), addCredentialsProvider: vi.fn(), + getProvider: vi.fn(), updateProvider: vi.fn(), updateCredentialsProvider: vi.fn(), + addRegistryProvider: vi.fn(), })); vi.mock("@/actions/providers/providers", () => ({ addProvider, addCredentialsProvider, + getProvider, updateProvider, updateCredentialsProvider, })); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider, +})); + const ROLE_ARN = "arn:aws:iam::123456789012:role/ProwlerScan"; -const roleValues = { +const awsRoleSecret = { providerId: "", providerType: "aws", - providerAlias: "Production", role_arn: ROLE_ARN, external_id: "tenant-1", credentials_type: "aws-sdk-default", @@ -41,28 +48,37 @@ const roleValues = { session_duration: "3600", }; +const awsRoleInput = { + providerType: "aws", + method: "role", + uid: "123456789012", + alias: "Production", + secretValues: awsRoleSecret, +}; + const formEntries = (call: number, mock: typeof addProvider) => Object.fromEntries((mock.mock.calls[call][0] as FormData).entries()); -describe("connectAwsAccount", () => { +describe("connectProviderAccount", () => { beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); useProviderWizardStore.getState().reset(); useUIStore.setState({ hasProviders: false, hasProvidersResolved: true }); addProvider.mockResolvedValue({ data: { id: "provider-1" } }); + addRegistryProvider.mockResolvedValue({ data: { id: "provider-r1" } }); addCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); updateProvider.mockResolvedValue({ data: { id: "provider-1" } }); updateCredentialsProvider.mockResolvedValue({ data: { id: "secret-1" } }); + getProvider.mockResolvedValue({ + data: { id: "provider-1", relationships: { secret: { data: null } } }, + }); }); - describe("when connecting through an IAM role", () => { - it("registers the account read from the ARN and stores its credentials in one go", async () => { + describe("when connecting a new account", () => { + it("registers the account and stores its credentials in one go", async () => { // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + const result = await connectProviderAccount(awsRoleInput); // Then expect(result).toEqual({ ok: true, providerId: "provider-1" }); @@ -83,10 +99,7 @@ describe("connectAwsAccount", () => { it("leaves the wizard ready for the connection test", async () => { // When - await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + await connectProviderAccount(awsRoleInput); // Then expect(useProviderWizardStore.getState()).toMatchObject({ @@ -101,11 +114,87 @@ describe("connectAwsAccount", () => { expect(useUIStore.getState().hasProviders).toBe(true); }); - it("rejects a malformed ARN on its field without calling the API", async () => { + it("connects a provider with a single method and no via", async () => { // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: { ...roleValues, role_arn: "arn:aws:s3:::bucket" }, + const result = await connectProviderAccount({ + providerType: "azure", + method: null, + uid: "fc94207a-d396-4a14-a7fd-12ab34cd56ef", + alias: "", + secretValues: { + providerId: "", + providerType: "azure", + client_id: "client", + client_secret: "secret", + tenant_id: "tenant", + }, + }); + + // Then + expect(result).toEqual({ ok: true, providerId: "provider-1" }); + expect(formEntries(0, addProvider)).toEqual({ + providerType: "azure", + providerUid: "fc94207a-d396-4a14-a7fd-12ab34cd56ef", + }); + expect(formEntries(0, addCredentialsProvider)).toEqual({ + providerId: "provider-1", + providerType: "azure", + client_id: "client", + client_secret: "secret", + tenant_id: "tenant", + }); + expect(useProviderWizardStore.getState().via).toBeNull(); + }); + + it("copies the account id into the secret of providers that repeat it", async () => { + // When + await connectProviderAccount({ + providerType: "oraclecloud", + method: null, + uid: "ocid1.tenancy.oc1..aaaa", + alias: "", + secretValues: { + providerId: "", + providerType: "oraclecloud", + tenancy: "", + user: "ocid1.user.oc1..bbbb", + }, + }); + + // Then + expect(formEntries(0, addCredentialsProvider)).toMatchObject({ + tenancy: "ocid1.tenancy.oc1..aaaa", + user: "ocid1.user.oc1..bbbb", + }); + }); + + it("registers a Registry provider through the Registry action", async () => { + // When + const result = await connectProviderAccount({ + providerType: "openai", + method: null, + uid: "org-123", + alias: "", + secretValues: {}, + skipSecret: true, + }); + + // Then + expect(result).toEqual({ ok: true, providerId: "provider-r1" }); + expect(addProvider).not.toHaveBeenCalled(); + expect(formEntries(0, addRegistryProvider)).toEqual({ + providerType: "openai", + providerUid: "org-123", + providerAlias: "", + }); + expect(addCredentialsProvider).not.toHaveBeenCalled(); + }); + + it("rejects a missing account id on the uid field without calling the API", async () => { + // When + const result = await connectProviderAccount({ + ...awsRoleInput, + uid: null, }); // Then @@ -121,38 +210,6 @@ describe("connectAwsAccount", () => { }); }); - 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, providerId: "provider-1" }); - 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 @@ -165,10 +222,7 @@ describe("connectAwsAccount", () => { addProvider.mockResolvedValueOnce({ errors }); // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + const result = await connectProviderAccount(awsRoleInput); // Then expect(result).toEqual({ ok: false, errors }); @@ -183,10 +237,7 @@ describe("connectAwsAccount", () => { addProvider.mockResolvedValueOnce({ error: "Server is unavailable." }); // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + const result = await connectProviderAccount(awsRoleInput); // Then expect(result).toEqual({ @@ -202,10 +253,7 @@ describe("connectAwsAccount", () => { addProvider.mockResolvedValueOnce({ data: {} }); // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + const result = await connectProviderAccount(awsRoleInput); // Then expect(result).toEqual({ @@ -222,10 +270,7 @@ describe("connectAwsAccount", () => { }); // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + const result = await connectProviderAccount(awsRoleInput); // Then expect(result).toEqual({ @@ -251,14 +296,8 @@ describe("connectAwsAccount", () => { 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, - }); + const first = await connectProviderAccount(awsRoleInput); + const second = await connectProviderAccount(awsRoleInput); // Then expect(first).toEqual({ ok: false, errors }); @@ -273,15 +312,12 @@ describe("connectAwsAccount", () => { addCredentialsProvider.mockResolvedValueOnce({ errors: [{ detail: "Invalid role ARN." }], }); - await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: roleValues, - }); + await connectProviderAccount(awsRoleInput); // When - const second = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: { ...roleValues, providerAlias: "Production EU" }, + const second = await connectProviderAccount({ + ...awsRoleInput, + alias: "Production EU", }); // Then @@ -297,22 +333,51 @@ describe("connectAwsAccount", () => { }); }); - 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" }, + describe("when a secret was stored but its response was lost", () => { + it("updates the stored secret on the retry instead of creating a second one", async () => { + // Given: the create committed server-side but the wizard never heard back. + addCredentialsProvider.mockResolvedValueOnce({ error: "Network error" }); + await connectProviderAccount(awsRoleInput); + getProvider.mockResolvedValueOnce({ + data: { + id: "provider-1", + relationships: { + secret: { data: { type: "provider-secrets", id: "secret-lost" } }, + }, + }, }); // When - const result = await connectAwsAccount({ - method: AWS_ACCESS_METHOD.ROLE, - values: { - ...roleValues, + const result = await connectProviderAccount(awsRoleInput); + + // Then + expect(result).toEqual({ ok: true, providerId: "provider-1" }); + expect(addCredentialsProvider).toHaveBeenCalledOnce(); + expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith( + "secret-lost", + expect.any(FormData), + ); + }); + + it("does not read the account back when it was just registered", async () => { + // When + await connectProviderAccount(awsRoleInput); + + // Then: a brand-new account cannot hold a secret yet. + expect(getProvider).not.toHaveBeenCalled(); + }); + }); + + 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 connectProviderAccount(awsRoleInput); + + // When + const result = await connectProviderAccount({ + ...awsRoleInput, + secretValues: { + ...awsRoleSecret, role_arn: "arn:aws:iam::123456789012:role/ProwlerScanV2", }, }); @@ -334,7 +399,59 @@ describe("connectAwsAccount", () => { providerType: "aws", role_arn: "arn:aws:iam::123456789012:role/ProwlerScanV2", }); - expect(useProviderWizardStore.getState().secretId).toBe("secret-1"); + }); + }); + + describe("when the wizard was opened on an existing account", () => { + beforeEach(() => { + useProviderWizardStore.getState().setProvider({ + id: "provider-9", + type: "aws", + uid: "123456789012", + alias: "Legacy", + }); + useProviderWizardStore.getState().setSecretId("secret-9"); + }); + + it("replaces its credentials without touching the account", async () => { + // When + const result = await connectProviderAccount({ + ...awsRoleInput, + alias: undefined, + locked: true, + }); + + // Then + expect(result).toEqual({ ok: true, providerId: "provider-9" }); + expect(addProvider).not.toHaveBeenCalled(); + expect(updateProvider).not.toHaveBeenCalled(); + expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith( + "secret-9", + expect.any(FormData), + ); + }); + + it("refuses credentials that belong to another account", async () => { + // When + const result = await connectProviderAccount({ + ...awsRoleInput, + uid: "999999999999", + alias: undefined, + locked: true, + }); + + // Then + expect(result).toEqual({ + ok: false, + errors: [ + { + detail: expect.stringMatching(/123456789012/), + source: { pointer: "/data/attributes/uid" }, + }, + ], + }); + expect(addProvider).not.toHaveBeenCalled(); + expect(updateCredentialsProvider).not.toHaveBeenCalled(); }); }); }); diff --git a/ui/components/providers/wizard/steps/connect/connect-provider-account.ts b/ui/components/providers/wizard/steps/connect/connect-provider-account.ts new file mode 100644 index 0000000000..6a6efdc6ef --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/connect-provider-account.ts @@ -0,0 +1,279 @@ +import { + addCredentialsProvider, + addProvider, + getProvider, + updateCredentialsProvider, + updateProvider, +} from "@/actions/providers/providers"; +import { addRegistryProvider } from "@/actions/providers/registry-provider"; +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 { isKnownProviderType } from "@/types/providers"; + +export const 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, +]; + +// Secrets that repeat the account id read it from the account, not the form. +export const UID_MIRROR_FIELD: Partial> = { + oraclecloud: ProviderCredentialFields.OCI_TENANCY, + googleworkspace: ProviderCredentialFields.GOOGLEWORKSPACE_CUSTOMER_ID, +}; + +export interface ConnectProviderInput { + providerType: string; + /** The `via` the account is connected with; null for single-method providers. */ + method: string | null; + uid: string | null; + /** Undefined keeps the stored alias (the form does not edit it). */ + alias?: string; + secretValues: Record; + /** The wizard was opened on an existing account: never register another one. */ + locked?: boolean; + /** The caller stores the secret itself (Registry providers). */ + skipSecret?: boolean; + missingUidMessage?: string; +} + +interface ConnectSuccess { + ok: true; + providerId: string; +} + +interface ConnectFailure { + ok: false; + errors: ApiError[]; +} + +export type ConnectProviderResult = ConnectSuccess | ConnectFailure; + +const asText = (value: unknown) => (typeof value === "string" ? value : ""); + +// Blank fields are left out, so optional credentials never reach the API as "". +const toFormData = (values: Record) => { + const formData = new FormData(); + Object.entries(values).forEach(([key, value]) => { + const text = asText(value).trim(); + if (text) formData.append(key, text); + }); + return formData; +}; + +interface CreatedResource { + id?: unknown; +} + +interface CreateActionResponse { + data?: CreatedResource; + error?: string; + errors?: ApiError[]; +} + +const UNCONFIRMED_RESPONSE_MESSAGE = + "The API did not confirm the request. Please try again."; + +type CreatedIdResult = + | { id: string; errors: null } + | { id: null; errors: ApiError[] }; + +// 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): CreatedIdResult => { + 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 } as ApiError] }; + const id = body?.data?.id; + if (typeof id !== "string" || !id) { + return { + id: null, + errors: [{ detail: UNCONFIRMED_RESPONSE_MESSAGE } as ApiError], + }; + } + return { id, errors: null }; +}; + +const uidError = (detail: string): ApiError => + ({ detail, source: { pointer: UID_ERROR_POINTER } }) as ApiError; + +type EnsureResult = + // `reused`: the account existed before this submit, so it may hold a secret. + | { providerId: string; errors: null; reused: boolean } + | { providerId: null; errors: ApiError[] }; + +// A retry may carry a new alias; the account registered earlier has to follow it. +const renameProvider = async ( + providerId: string, + providerType: string, + alias: string | undefined, +): Promise => { + const store = useProviderWizardStore.getState(); + if (alias === undefined || (store.providerAlias ?? "") === alias) + return { providerId, errors: null, reused: true }; + + const updated = readCreatedId( + await updateProvider( + toFormData({ + [ProviderCredentialFields.PROVIDER_ID]: providerId, + [ProviderCredentialFields.PROVIDER_ALIAS]: alias, + }), + ), + ); + if (updated.id === null) return { providerId: null, errors: updated.errors }; + + store.setProvider({ + id: providerId, + type: providerType, + uid: store.providerUid ?? "", + alias: alias || null, + }); + return { providerId, errors: null, reused: true }; +}; + +const createProvider = (providerType: string, uid: string, alias: string) => { + if (isKnownProviderType(providerType)) { + return addProvider( + toFormData({ + [ProviderCredentialFields.PROVIDER_TYPE]: providerType, + [ProviderCredentialFields.PROVIDER_UID]: uid, + [ProviderCredentialFields.PROVIDER_ALIAS]: alias, + }), + ); + } + // The Registry action validates the alias as a present (possibly empty) string. + const formData = new FormData(); + formData.append(ProviderCredentialFields.PROVIDER_TYPE, providerType); + formData.append(ProviderCredentialFields.PROVIDER_UID, uid); + formData.append(ProviderCredentialFields.PROVIDER_ALIAS, alias); + return addRegistryProvider(formData); +}; + +/** Registers the account once per wizard, reusing it on retries. */ +export async function ensureProviderAccount({ + providerType, + uid, + alias, + locked = false, +}: { + providerType: string; + uid: string; + alias?: string; + locked?: boolean; +}): Promise { + const store = useProviderWizardStore.getState(); + const isSameAccount = + store.providerId !== null && + store.providerType === providerType && + store.providerUid === uid; + + if (isSameAccount && store.providerId) { + return renameProvider(store.providerId, providerType, alias); + } + + if (locked) { + return { + providerId: null, + errors: [ + uidError( + `These credentials belong to ${uid}, not to ${store.providerUid ?? "this provider"}. Use credentials for the account you are updating.`, + ), + ], + }; + } + + const created = readCreatedId( + await createProvider(providerType, uid, alias ?? ""), + ); + if (created.id === null) return { providerId: null, errors: created.errors }; + + const providerId = created.id; + store.setProvider({ + id: providerId, + type: providerType, + 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, reused: false }; +} + +interface ProviderSecretRelationship { + data?: { relationships?: { secret?: { data?: { id?: unknown } | null } } }; +} + +// The secret an existing account already holds, read back from the API. +const readStoredSecretId = async (providerId: string) => { + const formData = new FormData(); + formData.append("id", providerId); + try { + const response = (await getProvider( + formData, + )) as ProviderSecretRelationship; + const id = response?.data?.relationships?.secret?.data?.id; + return typeof id === "string" && id ? id : null; + } catch { + return null; + } +}; + +/** Registers the account (or reuses it) and stores its credentials in a single submit. */ +export async function connectProviderAccount( + input: ConnectProviderInput, +): Promise { + const { providerType, method, uid, alias, locked, skipSecret } = input; + if (!uid) { + return { + ok: false, + errors: [uidError(input.missingUidMessage ?? "Account ID is required.")], + }; + } + + const provider = await ensureProviderAccount({ + providerType, + uid, + alias: alias?.trim(), + locked, + }); + if (provider.providerId === null) + return { ok: false, errors: provider.errors }; + if (skipSecret) return { ok: true, providerId: provider.providerId }; + + const secretValues = Object.fromEntries( + Object.entries(input.secretValues).filter( + ([key]) => !ACCOUNT_FIELDS.includes(key), + ), + ); + const mirrorField = UID_MIRROR_FIELD[providerType]; + const secretFormData = toFormData({ + ...secretValues, + ...(mirrorField ? { [mirrorField]: uid } : {}), + [ProviderCredentialFields.PROVIDER_ID]: provider.providerId, + [ProviderCredentialFields.PROVIDER_TYPE]: providerType, + }); + // A provider holds one secret: resubmitting a connected account edits it in + // place, including one whose create committed although its response was lost. + const storedSecretId = + useProviderWizardStore.getState().secretId ?? + (provider.reused ? await readStoredSecretId(provider.providerId) : null); + const secret = readCreatedId( + storedSecretId + ? await updateCredentialsProvider(storedSecretId, secretFormData) + : await addCredentialsProvider(secretFormData), + ); + if (secret.id === null) return { ok: false, errors: secret.errors }; + + const store = useProviderWizardStore.getState(); + store.setSecretId(secret.id); + store.setVia(method); + return { ok: true, providerId: provider.providerId }; +} diff --git a/ui/components/providers/wizard/steps/connect/connection-feedback.tsx b/ui/components/providers/wizard/steps/connect/connection-feedback.tsx new file mode 100644 index 0000000000..95135501b5 --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/connection-feedback.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { CircleAlert, Loader2 } from "lucide-react"; +import { useEffect, useRef } from "react"; + +import { ConnectionPending } from "@/components/icons"; + +interface ConnectionFeedbackProps { + isTesting: boolean; + error: string | null; + pending: string | null; +} + +/** + * Progress line while the test runs, the neutral message once the wait is + * exhausted with no verdict, or the API's reason once it is refused. + */ +export function ConnectionFeedback({ + isTesting, + error, + pending, +}: ConnectionFeedbackProps) { + const alertRef = useRef(null); + + // The form scrolls inside the modal and the action button sits outside it, so + // an error or a pending result raised from the footer can land above the fold. + useEffect(() => { + if (!error && !pending) return; + // Guarded: jsdom has no scrollIntoView, and a throw here would unmount the step. + alertRef.current?.scrollIntoView?.({ block: "start", behavior: "smooth" }); + }, [error, pending]); + + if (isTesting) { + return ( +

+ + Testing the connection. This usually takes a few seconds. +

+ ); + } + + if (pending) { + return ( +
+
+ ); + } + + if (!error) return null; + + return ( +
+ +

+ {error} +

+
+ ); +} diff --git a/ui/components/providers/wizard/steps/connect/credentials-form-config.ts b/ui/components/providers/wizard/steps/connect/credentials-form-config.ts new file mode 100644 index 0000000000..258054444e --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/credentials-form-config.ts @@ -0,0 +1,235 @@ +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { isCloud } from "@/lib/shared/env"; +import { + addCredentialsFormSchema, + addCredentialsRoleFormSchema, + addCredentialsServiceAccountFormSchema, +} from "@/types/formSchemas"; +import type { KnownProviderType } from "@/types/providers"; + +/** The credentials schema for a provider and access method. */ +export function getCredentialsFormSchema( + providerType: KnownProviderType, + via: string | null, +) { + if (providerType === "aws" && via === "role") { + return addCredentialsRoleFormSchema(providerType); + } + if (providerType === "alibabacloud" && via === "role") { + return addCredentialsRoleFormSchema(providerType); + } + if (providerType === "gcp" && via === "service-account") { + return addCredentialsServiceAccountFormSchema(providerType); + } + // GitHub, M365 and Cloudflare require different fields per method. + if ( + providerType === "github" || + providerType === "m365" || + providerType === "cloudflare" + ) { + return addCredentialsFormSchema(providerType, via); + } + return addCredentialsFormSchema(providerType); +} + +interface CredentialsDefaultsContext { + /** Prefills secrets that repeat the account id (OCI tenancy, Workspace customer). */ + providerUid?: string; + externalId: string; +} + +/** Empty values for every credentials field of a provider and access method. */ +export function getCredentialsFormDefaults( + providerType: KnownProviderType, + via: string | null, + { providerUid, externalId }: CredentialsDefaultsContext, +): Record { + const baseDefaults = { + [ProviderCredentialFields.PROVIDER_ID]: "", + [ProviderCredentialFields.PROVIDER_TYPE]: providerType, + }; + + // AWS Role credentials + if (providerType === "aws" && via === "role") { + const isCloudEnv = isCloud(); + const defaultCredentialsType = isCloudEnv + ? "aws-sdk-default" + : "access-secret-key"; + return { + ...baseDefaults, + [ProviderCredentialFields.CREDENTIALS_TYPE]: defaultCredentialsType, + [ProviderCredentialFields.ROLE_ARN]: "", + [ProviderCredentialFields.EXTERNAL_ID]: externalId, + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", + [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", + [ProviderCredentialFields.ROLE_SESSION_NAME]: "", + [ProviderCredentialFields.SESSION_DURATION]: "3600", + }; + } + + // GCP Service Account + if (providerType === "gcp" && via === "service-account") { + return { + ...baseDefaults, + [ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: "", + }; + } + + switch (providerType) { + case "aws": + return { + ...baseDefaults, + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", + [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", + }; + case "azure": + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CLIENT_SECRET]: "", + [ProviderCredentialFields.TENANT_ID]: "", + }; + case "m365": + // M365 credentials based on via parameter + if (via === "app_client_secret") { + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CLIENT_SECRET]: "", + [ProviderCredentialFields.TENANT_ID]: "", + }; + } + if (via === "app_certificate") { + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CERTIFICATE_CONTENT]: "", + [ProviderCredentialFields.TENANT_ID]: "", + }; + } + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.TENANT_ID]: "", + }; + case "gcp": + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CLIENT_SECRET]: "", + [ProviderCredentialFields.REFRESH_TOKEN]: "", + }; + case "kubernetes": + return { + ...baseDefaults, + [ProviderCredentialFields.KUBECONFIG_CONTENT]: "", + }; + case "github": + // GitHub credentials based on via parameter + if (via === "personal_access_token") { + return { + ...baseDefaults, + [ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]: "", + }; + } + if (via === "oauth_app") { + return { + ...baseDefaults, + [ProviderCredentialFields.OAUTH_APP_TOKEN]: "", + }; + } + if (via === "github_app") { + return { + ...baseDefaults, + [ProviderCredentialFields.GITHUB_APP_ID]: "", + [ProviderCredentialFields.GITHUB_APP_KEY]: "", + }; + } + return baseDefaults; + case "oraclecloud": + return { + ...baseDefaults, + [ProviderCredentialFields.OCI_USER]: "", + [ProviderCredentialFields.OCI_FINGERPRINT]: "", + [ProviderCredentialFields.OCI_KEY_CONTENT]: "", + [ProviderCredentialFields.OCI_TENANCY]: providerUid || "", + [ProviderCredentialFields.OCI_REGION]: "", + [ProviderCredentialFields.OCI_PASS_PHRASE]: "", + }; + case "mongodbatlas": + return { + ...baseDefaults, + [ProviderCredentialFields.ATLAS_PUBLIC_KEY]: "", + [ProviderCredentialFields.ATLAS_PRIVATE_KEY]: "", + }; + case "alibabacloud": + if (via === "role") { + return { + ...baseDefaults, + [ProviderCredentialFields.ALIBABACLOUD_ROLE_ARN]: "", + [ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_SECRET]: "", + [ProviderCredentialFields.ALIBABACLOUD_ROLE_SESSION_NAME]: "", + }; + } + return { + ...baseDefaults, + [ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_SECRET]: "", + }; + case "cloudflare": + // Cloudflare credentials based on via parameter + if (via === "api_token") { + return { + ...baseDefaults, + [ProviderCredentialFields.CLOUDFLARE_API_TOKEN]: "", + }; + } + if (via === "api_key") { + return { + ...baseDefaults, + [ProviderCredentialFields.CLOUDFLARE_API_KEY]: "", + [ProviderCredentialFields.CLOUDFLARE_API_EMAIL]: "", + }; + } + return baseDefaults; + case "openstack": + return { + ...baseDefaults, + [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: "", + [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: "", + }; + case "googleworkspace": + return { + ...baseDefaults, + [ProviderCredentialFields.GOOGLEWORKSPACE_CUSTOMER_ID]: + providerUid || "", + [ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT]: "", + [ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER]: "", + }; + case "image": + return { + ...baseDefaults, + [ProviderCredentialFields.REGISTRY_USERNAME]: "", + [ProviderCredentialFields.REGISTRY_PASSWORD]: "", + [ProviderCredentialFields.REGISTRY_TOKEN]: "", + [ProviderCredentialFields.IMAGE_FILTER]: "", + [ProviderCredentialFields.TAG_FILTER]: "", + }; + case "vercel": + return { + ...baseDefaults, + [ProviderCredentialFields.VERCEL_API_TOKEN]: "", + }; + case "okta": + return { + ...baseDefaults, + [ProviderCredentialFields.OKTA_CLIENT_ID]: "", + [ProviderCredentialFields.OKTA_PRIVATE_KEY]: "", + }; + default: + return baseDefaults; + } +} diff --git a/ui/components/providers/wizard/steps/connect/generic-connect-form.tsx b/ui/components/providers/wizard/steps/connect/generic-connect-form.tsx new file mode 100644 index 0000000000..9f3330fdc4 --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/generic-connect-form.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useSession } from "next-auth/react"; +import { + Control, + FieldValues, + Resolver, + useForm, + useWatch, +} from "react-hook-form"; + +import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { Form } from "@/components/shadcn/form"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { addProviderFormSchema } from "@/types/formSchemas"; +import type { KnownProviderType } from "@/types/providers"; + +import { AliasField } from "./alias-field"; +import { getUidFieldDetails } from "./connect-methods"; +import { UID_MIRROR_FIELD } from "./connect-provider-account"; +import { ConnectionFeedback } from "./connection-feedback"; +import { + getCredentialsFormDefaults, + getCredentialsFormSchema, +} from "./credentials-form-config"; +import { ProviderCredentialsFields } from "./provider-credentials-fields"; +import type { ConnectFormProps } from "./types"; +import { readDraftValues, useDraftValues } from "./use-connect-draft"; +import { useConnectSubmit } from "./use-connect-submit"; + +interface GenericConnectFormProps extends ConnectFormProps { + providerType: KnownProviderType; + method: string | null; +} + +/** Account and credentials of a built-in provider in one form. */ +export function GenericConnectForm({ + providerType, + method, + formId, + locked, + ...connectProps +}: GenericConnectFormProps) { + const { data: session } = useSession(); + const storedUid = useProviderWizardStore.getState().providerUid ?? ""; + const uidField = getUidFieldDetails(providerType); + // Secrets that repeat the account id: their hidden field follows the uid above. + const mirrorField = UID_MIRROR_FIELD[providerType]; + + const credentialsSchema = getCredentialsFormSchema(providerType, method); + // Updating keeps the account as it is: only the credentials are validated. + const schema = locked + ? credentialsSchema + : addProviderFormSchema.and(credentialsSchema); + const validate = zodResolver(schema) as unknown as Resolver; + const resolver: Resolver = (values, context, options) => + validate( + mirrorField + ? { + ...values, + [mirrorField]: locked + ? storedUid + : values[ProviderCredentialFields.PROVIDER_UID], + } + : values, + context, + options, + ); + + const form = useForm({ + resolver, + mode: "onChange", + defaultValues: { + ...getCredentialsFormDefaults(providerType, method, { + providerUid: locked ? storedUid : "", + externalId: session?.tenantId ?? "", + }), + [ProviderCredentialFields.PROVIDER_UID]: locked ? storedUid : "", + [ProviderCredentialFields.PROVIDER_ALIAS]: "", + ...readDraftValues(providerType, method), + // The draft never overrides what identifies the account being connected. + [ProviderCredentialFields.PROVIDER_TYPE]: providerType, + }, + }); + useDraftValues(form, providerType, method); + + const typedUid = useWatch({ + control: form.control, + name: ProviderCredentialFields.PROVIDER_UID, + }) as string | undefined; + + const { onSubmit, isTesting, connectionError, connectionPending } = + useConnectSubmit({ + ...connectProps, + form, + locked, + providerType, + method, + accountField: ProviderCredentialFields.PROVIDER_UID, + resolveIdentity: (values) => ({ + uid: locked + ? storedUid || null + : String( + values[ProviderCredentialFields.PROVIDER_UID] ?? "", + ).trim() || null, + alias: locked + ? undefined + : String(values[ProviderCredentialFields.PROVIDER_ALIAS] ?? ""), + }), + }); + + const control = form.control as Control; + + return ( +
+ + + + {!locked && ( +
+ + +
+ )} + + + + + ); +} diff --git a/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx b/ui/components/providers/wizard/steps/connect/onboarding-method-tabs.test.tsx similarity index 57% rename from ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx rename to ui/components/providers/wizard/steps/connect/onboarding-method-tabs.test.tsx index 6a4b7a279f..fc296fec8e 100644 --- a/ui/components/providers/wizard/steps/aws/aws-onboarding-method-tabs.test.tsx +++ b/ui/components/providers/wizard/steps/connect/onboarding-method-tabs.test.tsx @@ -4,13 +4,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { ORGANIZATION_TYPE } from "@/types/organizations"; import { - AWS_ONBOARDING_METHOD, - AwsOnboardingMethodTabs, -} from "./aws-onboarding-method-tabs"; + ONBOARDING_METHOD, + OnboardingMethodTabs, +} from "./onboarding-method-tabs"; -describe("AwsOnboardingMethodTabs", () => { +describe("OnboardingMethodTabs", () => { afterEach(() => { vi.unstubAllEnvs(); useCloudUpgradeStore.getState().closeCloudUpgrade(); @@ -21,8 +22,9 @@ describe("AwsOnboardingMethodTabs", () => { const user = userEvent.setup(); const onSelectOrganizations = vi.fn(); render( - , @@ -41,8 +43,9 @@ describe("AwsOnboardingMethodTabs", () => { const user = userEvent.setup(); const onSelectOrganizations = vi.fn(); render( - , @@ -63,8 +66,9 @@ describe("AwsOnboardingMethodTabs", () => { const user = userEvent.setup(); const onSelectSingle = vi.fn(); render( - , @@ -74,4 +78,36 @@ describe("AwsOnboardingMethodTabs", () => { expect(onSelectSingle).toHaveBeenCalledOnce(); }); + + it.each([ + { + orgType: ORGANIZATION_TYPE.AZURE, + tab: "Azure Management Group", + feature: CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS, + }, + { + orgType: ORGANIZATION_TYPE.GCP, + tab: "Full GCP Organization", + feature: CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS, + }, + ])( + "offers the $orgType organization flow behind its own upgrade outside Cloud", + async ({ orgType, tab, feature }) => { + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const user = userEvent.setup(); + const onSelectOrganizations = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("tab", { name: new RegExp(tab) })); + + expect(onSelectOrganizations).not.toHaveBeenCalled(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe(feature); + }, + ); }); diff --git a/ui/components/providers/wizard/steps/connect/onboarding-method-tabs.tsx b/ui/components/providers/wizard/steps/connect/onboarding-method-tabs.tsx new file mode 100644 index 0000000000..53eaf75931 --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/onboarding-method-tabs.tsx @@ -0,0 +1,107 @@ +"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, + type CloudUpgradeFeature, +} from "@/types/cloud-upgrade"; +import { ORGANIZATION_TYPE, type OrgFlowType } from "@/types/organizations"; + +export const ONBOARDING_METHOD = { + SINGLE: "single", + ORGANIZATION: "organization", +} as const; + +export type OnboardingMethod = + (typeof ONBOARDING_METHOD)[keyof typeof ONBOARDING_METHOD]; + +interface OnboardingTabsCopy { + ariaLabel: string; + single: string; + organization: string; + upgradeFeature: CloudUpgradeFeature; +} + +const ONBOARDING_TABS_COPY = { + [ORGANIZATION_TYPE.AWS]: { + ariaLabel: "AWS onboarding method", + single: "Single AWS Account", + organization: "Full AWS Organization", + upgradeFeature: CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, + }, + [ORGANIZATION_TYPE.AZURE]: { + ariaLabel: "Azure onboarding method", + single: "Single Azure Subscription", + organization: "Azure Management Group", + upgradeFeature: CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS, + }, + [ORGANIZATION_TYPE.GCP]: { + ariaLabel: "GCP onboarding method", + single: "Single GCP Project", + organization: "Full GCP Organization", + upgradeFeature: CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS, + }, +} as const satisfies Record; + +interface OnboardingMethodTabsProps { + orgType: OrgFlowType; + value: OnboardingMethod; + onSelectSingle?: () => void; + onSelectOrganizations?: () => void; +} + +/** Single account vs. whole organization switch at the top of the connect step. */ +export function OnboardingMethodTabs({ + orgType, + value, + onSelectSingle, + onSelectOrganizations, +}: OnboardingMethodTabsProps) { + const isCloudEnv = isCloud(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + const copy = ONBOARDING_TABS_COPY[orgType]; + + const handleValueChange = (next: string) => { + if (next === value) return; + if (next === ONBOARDING_METHOD.SINGLE) { + onSelectSingle?.(); + return; + } + if (isCloudEnv) { + onSelectOrganizations?.(); + return; + } + openCloudUpgrade(copy.upgradeFeature); + }; + + return ( + + + + {copy.single} + + + Cloud + + ) + } + > + {copy.organization} + + + + ); +} diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx b/ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx similarity index 77% rename from ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx rename to ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx index 294bff7597..08f6f36d3f 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx +++ b/ui/components/providers/wizard/steps/connect/provider-connect-step.test.tsx @@ -13,8 +13,8 @@ import { type ConnectionCheckStatus, } from "@/types/providers"; -import { AwsConnectStep } from "./aws-connect-step"; -import type { AwsConnectUiState } from "./types"; +import { ProviderConnectStep } from "./provider-connect-step"; +import type { ConnectUiState } from "./types"; const { addProvider, @@ -55,6 +55,15 @@ vi.mock("@/actions/providers/providers", () => ({ updateProvider, updateCredentialsProvider, })); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/providers/provider-schemas", () => ({ + getProviderSchemas: vi.fn(), +})); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials: vi.fn(), +})); // The real module reaches next-auth through lib/helper -> auth.config. vi.mock("@/lib/provider-helpers", () => ({ testProviderConnection })); vi.mock("@/store", () => ({ @@ -70,16 +79,22 @@ const ROLE_ARN = "arn:aws:iam::123456789012:role/ProwlerScan"; // Stands in for the wizard footer: the step only publishes its UI state. function Harness({ + providerType = "aws", + locked = false, onConnected, onSelectOrganizations, }: { + providerType?: string; + locked?: boolean; onConnected: () => void; onSelectOrganizations: () => void; }) { - const [uiState, setUiState] = useState(null); + const [uiState, setUiState] = useState(null); return ( <> - - {uiState?.actionLabel ?? "Connect account"} + {uiState?.actionLabel ?? "Connect"} ); } -function renderStep() { +function renderStep({ + providerType = "aws", + locked = false, +}: { providerType?: string; locked?: boolean } = {}) { const onConnected = vi.fn(); const onSelectOrganizations = vi.fn(); const { unmount } = render( , @@ -116,7 +136,7 @@ function renderStep() { const connectButton = () => screen.getByRole("button", { name: "Connect account" }); -describe("AwsConnectStep", () => { +describe("ProviderConnectStep", () => { const funnelSignals: ProviderFunnelDetail[] = []; const recordFunnelSignal: EventListener = (event) => { funnelSignals.push((event as CustomEvent).detail); @@ -860,4 +880,197 @@ describe("AwsConnectStep", () => { ).toBeInTheDocument(); }); }); + + describe("connecting a provider other than AWS", () => { + const AZURE_SUBSCRIPTION = "fc94207a-d396-4a14-a7fd-12ab34cd56ef"; + + const fillAzure = async (user: ReturnType) => { + await user.type( + screen.getByRole("textbox", { name: /Subscription ID/ }), + AZURE_SUBSCRIPTION, + ); + await user.type( + screen.getByRole("textbox", { name: /Client ID/ }), + "app", + ); + await user.type(screen.getByLabelText(/Client Secret/), "secret"); + await user.type( + screen.getByRole("textbox", { name: /Tenant ID/ }), + "tid", + ); + }; + + it("registers, stores and tests a single-method provider in one submit", async () => { + // Given: a provider with one method and a user who cannot invite. + const { user, onConnected } = renderStep({ providerType: "azure" }); + expect(screen.queryByRole("radiogroup")).not.toBeInTheDocument(); + await fillAzure(user); + + // When + await user.click( + screen.getByRole("button", { name: "Connect subscription" }), + ); + + // Then + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + expect( + Object.fromEntries(addProvider.mock.calls[0][0] as FormData), + ).toEqual({ providerType: "azure", providerUid: AZURE_SUBSCRIPTION }); + expect( + Object.fromEntries(addCredentialsProvider.mock.calls[0][0] as FormData), + ).toEqual({ + providerId: "provider-1", + providerType: "azure", + client_id: "app", + client_secret: "secret", + tenant_id: "tid", + }); + expect(testProviderConnection).toHaveBeenCalledWith("provider-1"); + expect(funnelSignals).toContainEqual({ + step: "account_submitted", + providerType: "azure", + via: "default", + outcome: "success", + }); + }); + + it("stays on the form with the refusal when the connection fails", async () => { + // Given + testProviderConnection.mockResolvedValueOnce({ + status: CONNECTION_CHECK_STATUS.FAILED, + error: "AADSTS7000215: Invalid client secret provided.", + }); + const { user, onConnected } = renderStep({ providerType: "azure" }); + await fillAzure(user); + + // When + await user.click( + screen.getByRole("button", { name: "Connect subscription" }), + ); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent( + "Invalid client secret provided", + ); + expect(onConnected).not.toHaveBeenCalled(); + expect( + screen.getByRole("textbox", { name: /Subscription ID/ }), + ).toHaveValue(AZURE_SUBSCRIPTION); + expect( + screen.getByRole("button", { name: "Retry connection" }), + ).toBeEnabled(); + }); + + it("lists every method of a provider that has several and connects with the picked one", async () => { + // Given + const { user, onConnected } = renderStep({ providerType: "github" }); + expect( + screen.getAllByRole("radio").map((radio) => radio.textContent), + ).toEqual(["Personal access token", "OAuth app token", "GitHub App"]); + + // When + await user.click(screen.getByRole("radio", { name: /OAuth app token/ })); + await user.type( + screen.getByRole("textbox", { name: /Username\/Organization/ }), + "prowler-cloud", + ); + await user.type(screen.getByLabelText(/OAuth App Token/), "gho_token"); + await user.click(screen.getByRole("button", { name: "Connect account" })); + + // Then + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + expect( + Object.fromEntries(addCredentialsProvider.mock.calls[0][0] as FormData), + ).toMatchObject({ providerType: "github", oauth_app_token: "gho_token" }); + expect(useProviderWizardStore.getState().via).toBe("oauth_app"); + }); + + it("offers the organization tabs to Azure and GCP but not to other providers", () => { + // When + const { unmount } = renderStep({ providerType: "gcp" }); + + // Then + expect( + screen.getByRole("tab", { name: /Full GCP Organization/ }), + ).toBeInTheDocument(); + unmount(); + renderStep({ providerType: "github" }); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); + + it("lets a user who cannot connect invite a teammate for any provider", async () => { + // Given + session.data = { + tenantId: "tenant-abc", + user: { permissions: { manage_account: true } }, + }; + getInvitationRoles.mockResolvedValue([ + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, + ]); + const { user } = renderStep({ providerType: "gcp" }); + + // When + await user.click( + screen.getByRole("radio", { name: /invite a teammate/i }), + ); + + // Then + expect( + await screen.findByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("textbox", { name: /Project ID/ }), + ).not.toBeInTheDocument(); + expect(funnelSignals).toContainEqual({ + step: "method_selected", + providerType: "gcp", + method: "invite_teammate", + }); + }); + }); + + describe("updating the credentials of an existing account", () => { + beforeEach(() => { + useProviderWizardStore.getState().setProvider({ + id: "provider-9", + type: "azure", + uid: "fc94207a-d396-4a14-a7fd-12ab34cd56ef", + alias: "Production", + }); + useProviderWizardStore.getState().setSecretId("secret-9"); + }); + + it("asks only for the credentials and replaces the stored secret", async () => { + // Given + const { user, onConnected } = renderStep({ + providerType: "azure", + locked: true, + }); + expect( + screen.queryByRole("textbox", { name: /Subscription ID/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + await user.type( + screen.getByRole("textbox", { name: /Client ID/ }), + "app", + ); + await user.type(screen.getByLabelText(/Client Secret/), "rotated"); + await user.type( + screen.getByRole("textbox", { name: /Tenant ID/ }), + "tid", + ); + + // When + await user.click(screen.getByRole("button", { name: "Save and test" })); + + // Then + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + expect(addProvider).not.toHaveBeenCalled(); + expect(updateCredentialsProvider).toHaveBeenCalledExactlyOnceWith( + "secret-9", + expect.any(FormData), + ); + expect(testProviderConnection).toHaveBeenCalledWith("provider-9"); + }); + }); }); diff --git a/ui/components/providers/wizard/steps/connect/provider-connect-step.tsx b/ui/components/providers/wizard/steps/connect/provider-connect-step.tsx new file mode 100644 index 0000000000..42d9d4c576 --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/provider-connect-step.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { UserPlus } from "lucide-react"; +import { useState } from "react"; + +import { RadioCard } from "@/components/providers/radio-card"; +import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { useAuth } from "@/hooks/use-auth"; +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_METHOD, + PROVIDER_FUNNEL_STEP, +} from "@/lib/provider-funnel/provider-funnel-events"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { type OrgFlowType, toOrgFlowType } from "@/types/organizations"; +import { CONNECT_PANEL, type ConnectPanel } from "@/types/provider-wizard"; +import { getProviderDisplayName, isKnownProviderType } from "@/types/providers"; + +import { + AwsKeysConnectForm, + AwsRoleConnectForm, +} from "../aws/aws-connect-forms"; +import { AWS_ACCESS_METHOD } from "../aws/types"; +import { InviteTeammatePanel } from "../invite-teammate/invite-teammate-panel"; + +import { + type ConnectMethod, + getConnectMethods, + getUidFieldDetails, + resolveConnectMethod, + UPDATE_SUBMIT_LABEL, +} from "./connect-methods"; +import { GenericConnectForm } from "./generic-connect-form"; +import { + ONBOARDING_METHOD, + OnboardingMethodTabs, +} from "./onboarding-method-tabs"; +import { RegistryConnectForm } from "./registry-connect-form"; +import type { ConnectFormProps, ConnectUiState } from "./types"; + +interface ProviderConnectStepProps { + providerType: string; + formId: string; + /** The wizard was opened on an existing account: only its credentials change. */ + locked?: boolean; + onConnected: () => void; + onSelectOrganizations: (orgType: OrgFlowType) => void; + onUiStateChange: (state: ConnectUiState) => void; +} + +const readDraft = (providerType: string) => { + const draft = useProviderWizardStore.getState().connectDraft; + return draft?.providerType === providerType ? draft : null; +}; + +const initialMethod = (providerType: string, locked: boolean) => { + const draft = readDraft(providerType); + if (draft?.method !== undefined && draft.method !== null) + return resolveConnectMethod(providerType, draft.method); + // Updating an account starts on the method its secret was stored with. + const storedVia = locked ? useProviderWizardStore.getState().via : null; + return resolveConnectMethod(providerType, storedVia); +}; + +const initialPanel = (providerType: string): ConnectPanel => + readDraft(providerType)?.panel === CONNECT_PANEL.INVITE + ? CONNECT_PANEL.INVITE + : CONNECT_PANEL.ACCESS; + +/** One step to register a provider account, store its credentials and test them. */ +export function ProviderConnectStep({ + providerType, + formId, + locked = false, + onConnected, + onSelectOrganizations, + onUiStateChange, +}: ProviderConnectStepProps) { + const methods = getConnectMethods(providerType); + // Local state needed: the access method only matters until the account is connected. + const [method, setMethod] = useState(() => + initialMethod(providerType, locked), + ); + // Local state needed: whether the step shows the access forms or hands the + // account over to a teammate. Separate from the method, which is the `via` + // an account gets connected with. + const [panel, setPanel] = useState(() => + initialPanel(providerType), + ); + // Local state needed: the active form reports it so the method cannot change mid-submit. + const [isBusy, setIsBusy] = useState(false); + const { permissions } = useAuth(); + // Inviting takes `manage_account`, which the API also asks of the roles list. + const canInvite = permissions.manage_account === true; + const orgType = locked ? undefined : toOrgFlowType(providerType); + + const isInvite = canInvite && panel === CONNECT_PANEL.INVITE; + const showMethodCards = methods.length > 1 || canInvite; + + const chooseMethod = (next: ConnectMethod) => { + setPanel(CONNECT_PANEL.ACCESS); + setMethod(next); + useProviderWizardStore.getState().setConnectDraft(providerType, { + method: next.via, + panel: CONNECT_PANEL.ACCESS, + }); + }; + + const chooseInvite = () => { + if (isInvite) return; + setPanel(CONNECT_PANEL.INVITE); + useProviderWizardStore + .getState() + .setConnectDraft(providerType, { panel: CONNECT_PANEL.INVITE }); + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED, + providerType, + method: PROVIDER_FUNNEL_METHOD.INVITE_TEAMMATE, + }); + }; + + const formProps: ConnectFormProps = { + formId, + locked, + submitLabel: locked + ? UPDATE_SUBMIT_LABEL + : getUidFieldDetails(providerType).submitLabel, + onConnected, + onBusyChange: setIsBusy, + onUiStateChange, + }; + + return ( +
+ + + {orgType && ( + onSelectOrganizations(orgType)} + /> + )} + + {showMethodCards && ( +
+

+ {locked + ? "Choose how Prowler should connect from now on." + : "Choose how to connect it."} +

+
+ {methods.map((option) => ( + chooseMethod(option)} + > + {option.recommended && ( + + Recommended + + )} + + ))} + {canInvite && ( + + )} +
+
+ )} + + {isInvite ? ( + + ) : ( + + )} +
+ ); +} + +interface ConnectFormSwitchProps extends ConnectFormProps { + providerType: string; + method: string | null; +} + +function ConnectForm({ + providerType, + method, + ...props +}: ConnectFormSwitchProps) { + if (providerType === "aws") { + return method === AWS_ACCESS_METHOD.CREDENTIALS ? ( + + ) : ( + + ); + } + if (isKnownProviderType(providerType)) { + return ( + + ); + } + return ; +} diff --git a/ui/components/providers/wizard/steps/connect/provider-credentials-fields.tsx b/ui/components/providers/wizard/steps/connect/provider-credentials-fields.tsx new file mode 100644 index 0000000000..1a2a25a7fc --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/provider-credentials-fields.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { Control, FieldValues } from "react-hook-form"; + +import { + AlibabaCloudRoleCredentialsForm, + AlibabaCloudStaticCredentialsForm, +} from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud/credentials-type"; +import { + CloudflareApiKeyCredentialsForm, + CloudflareApiTokenCredentialsForm, +} from "@/components/providers/workflow/forms/select-credentials-type/cloudflare"; +import { GCPDefaultCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type"; +import { GCPServiceAccountKeyForm } from "@/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/gcp-service-account-key-form"; +import { + M365CertificateCredentialsForm, + M365ClientSecretCredentialsForm, +} from "@/components/providers/workflow/forms/select-credentials-type/m365"; +import { AzureCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/azure-credentials-form"; +import { GitHubCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/github-credentials-form"; +import { GoogleWorkspaceCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/googleworkspace-credentials-form"; +import { IacCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/iac-credentials-form"; +import { ImageCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/image-credentials-form"; +import { KubernetesCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/k8s-credentials-form"; +import { MongoDBAtlasCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/mongodbatlas-credentials-form"; +import { OktaCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/okta-credentials-form"; +import { OpenStackCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/openstack-credentials-form"; +import { OracleCloudCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form"; +import { VercelCredentialsForm } from "@/components/providers/workflow/forms/via-credentials/vercel-credentials-form"; +import type { + AlibabaCloudCredentials, + AlibabaCloudCredentialsRole, + AzureCredentials, + CloudflareApiKeyCredentials, + CloudflareTokenCredentials, + GCPDefaultCredentials, + GCPServiceAccountKey, + GoogleWorkspaceCredentials, + IacCredentials, + ImageCredentials, + KubernetesCredentials, + KnownProviderType, + M365CertificateCredentials, + M365ClientSecretCredentials, + MongoDBAtlasCredentials, + OCICredentials, + OktaCredentials, + OpenStackCredentials, + VercelCredentials, +} from "@/types"; + +interface ProviderCredentialsFieldsProps { + providerType: KnownProviderType; + via: string | null; + control: Control; + /** The account id typed above, for providers whose help links need it. */ + providerUid?: string; +} + +/** The credentials fields of a built-in provider (AWS has forms of its own). */ +export function ProviderCredentialsFields({ + providerType, + via, + control, + providerUid, +}: ProviderCredentialsFieldsProps) { + return ( + <> + {providerType === "azure" && ( + } + /> + )} + {providerType === "m365" && via === "app_client_secret" && ( + } + /> + )} + {providerType === "m365" && via === "app_certificate" && ( + } + /> + )} + {providerType === "gcp" && via === "service-account" && ( + } + /> + )} + {providerType === "gcp" && via !== "service-account" && ( + } + /> + )} + {providerType === "kubernetes" && ( + } + /> + )} + {providerType === "github" && ( + + )} + {providerType === "iac" && ( + } + /> + )} + {providerType === "image" && ( + } + /> + )} + {providerType === "oraclecloud" && ( + } + /> + )} + {providerType === "mongodbatlas" && ( + } + /> + )} + {providerType === "alibabacloud" && via === "role" && ( + } + /> + )} + {providerType === "alibabacloud" && via !== "role" && ( + } + /> + )} + {providerType === "cloudflare" && via === "api_token" && ( + } + providerUid={providerUid} + /> + )} + {providerType === "cloudflare" && via === "api_key" && ( + } + /> + )} + {providerType === "openstack" && ( + } + /> + )} + {providerType === "googleworkspace" && ( + } + /> + )} + {providerType === "vercel" && ( + } + /> + )} + {providerType === "okta" && ( + } + /> + )} + + ); +} diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx b/ui/components/providers/wizard/steps/connect/registry-connect-form.test.tsx similarity index 69% rename from ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx rename to ui/components/providers/wizard/steps/connect/registry-connect-form.test.tsx index 710e562e17..69ffa9b84d 100644 --- a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx +++ b/ui/components/providers/wizard/steps/connect/registry-connect-form.test.tsx @@ -14,19 +14,39 @@ import unionSchema from "@/lib/provider-credentials/fixtures/union-credential-sc import { useProviderWizardStore } from "@/store/provider-wizard/store"; import type { ProviderSchemasResult } from "@/types/provider-schema"; -const { getProviderSchemas, saveDynamicProviderCredentials, toast } = - vi.hoisted(() => ({ - getProviderSchemas: vi.fn(), - saveDynamicProviderCredentials: vi.fn(), - toast: vi.fn(), - })); +const { + getProviderSchemas, + saveDynamicProviderCredentials, + toast, + addRegistryProvider, + testProviderConnection, + updateProvider, +} = vi.hoisted(() => ({ + getProviderSchemas: vi.fn(), + saveDynamicProviderCredentials: vi.fn(), + toast: vi.fn(), + addRegistryProvider: vi.fn(), + testProviderConnection: vi.fn(), + updateProvider: vi.fn(), +})); vi.mock("@/actions/providers/provider-schemas", () => ({ getProviderSchemas })); vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ saveDynamicProviderCredentials, })); vi.mock("@/components/shadcn/toast", () => ({ useToast: () => ({ toast }) })); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider, +})); +vi.mock("@/actions/providers/providers", () => ({ + addProvider: vi.fn(), + addCredentialsProvider: vi.fn(), + updateProvider, + updateCredentialsProvider: vi.fn(), +})); +// The real module reaches next-auth through lib/helper -> auth.config. +vi.mock("@/lib/provider-helpers", () => ({ testProviderConnection })); -import { DynamicCredentialsStep } from "./dynamic-credentials-step"; +import { RegistryConnectForm } from "./registry-connect-form"; beforeAll(() => { for (const method of [ @@ -42,12 +62,15 @@ beforeAll(() => { } }); +// Credentials added to an account the wizard was opened on. const props = { - providerId: "account", providerType: "acme", - onNext: vi.fn(), - onBack: vi.fn(), - onFooterChange: vi.fn(), + formId: "registry-connect-test-form", + locked: true, + submitLabel: "Save and test", + onConnected: vi.fn(), + onBusyChange: vi.fn(), + onUiStateChange: vi.fn(), }; const schema = { type: "object", @@ -69,6 +92,17 @@ describe("dynamic credentials in the provider wizard", () => { sessionStorage.clear(); localStorage.clear(); useProviderWizardStore.getState().reset(); + useProviderWizardStore.getState().setProvider({ + id: "account", + type: "acme", + uid: "acme-uid", + alias: null, + }); + testProviderConnection.mockResolvedValue({ + status: "success", + error: null, + }); + addRegistryProvider.mockResolvedValue({ data: { id: "registered" } }); getProviderSchemas.mockResolvedValue({ status: "success", providerType: "acme", @@ -80,20 +114,20 @@ describe("dynamic credentials in the provider wizard", () => { }); }); it("saves through the dynamic action and never persists entered secrets", async () => { - render(); + render(); const field = await screen.findByLabelText(/API token/); fireEvent.change(field, { target: { value: "only-in-memory" } }); expect(JSON.stringify(sessionStorage)).not.toContain("only-in-memory"); expect(JSON.stringify(localStorage)).not.toContain("only-in-memory"); fireEvent.submit(field.closest("form")!); - await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ providerId: "account", secretType: "api_key", secret: { token: "only-in-memory" }, }); expect(useProviderWizardStore.getState().secretId).toBe("secret"); - expect(field).toHaveValue(""); + expect(testProviderConnection).toHaveBeenCalledWith("account"); }); it("masks the OpenAI API key and submits the original credential values", async () => { // Given @@ -103,7 +137,13 @@ describe("dynamic credentials in the provider wizard", () => { providerType: "openai", secretTypes: { api_key: openaiSchema }, }); - render(); + useProviderWizardStore.getState().setProvider({ + id: "account", + type: "openai", + uid: "openai-uid", + alias: null, + }); + render(); const apiKey = await screen.findByLabelText(/Platform Api Key/); const organization = screen.getByLabelText(/Organization Id/); const baseUrl = screen.getByLabelText(/Base Url/); @@ -123,7 +163,7 @@ describe("dynamic credentials in the provider wizard", () => { // When / Then: this form submits from the wizard's external footer. act(() => apiKey.closest("form")!.requestSubmit()); - await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ providerId: "account", secretType: "api_key", @@ -142,7 +182,13 @@ describe("dynamic credentials in the provider wizard", () => { providerType: "template", secretTypes: { static: templateSchema }, }); - render(); + useProviderWizardStore.getState().setProvider({ + id: "account", + type: "template", + uid: "template-uid", + alias: null, + }); + render(); const apiUrl = await screen.findByLabelText(/API URL/); const apiKey = screen.getByLabelText(/API Key/); const verifyTls = screen.getByRole("checkbox", { name: "Verify TLS" }); @@ -175,7 +221,7 @@ describe("dynamic credentials in the provider wizard", () => { act(() => apiKey.closest("form")!.requestSubmit()); // Then - await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ providerId: "account", secretType: "static", @@ -196,7 +242,7 @@ describe("dynamic credentials in the provider wizard", () => { providerType: "acme", secretTypes: { static: unionSchema }, }); - render(); + render(); await screen.findByLabelText(/API Host/); const method = screen.getByRole("combobox", { name: "Authentication method", @@ -247,7 +293,7 @@ describe("dynamic credentials in the provider wizard", () => { ); // Then: the variant's fixed discriminator travels with its own fields only. - await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ providerId: "account", secretType: "static", @@ -289,7 +335,7 @@ describe("dynamic credentials in the provider wizard", () => { "explains $title without allowing credential submission", async ({ result, title }) => { getProviderSchemas.mockResolvedValue(result); - render(); + render(); expect( await screen.findByRole("button", { name: "Try again" }), ).toBeVisible(); @@ -302,7 +348,7 @@ describe("dynamic credentials in the provider wizard", () => { // Given getProviderSchemas.mockRejectedValueOnce(new Error("Network unavailable")); const user = userEvent.setup(); - render(); + render(); expect(await screen.findByRole("alert")).toHaveTextContent( "Could not load credential form", ); @@ -322,17 +368,11 @@ describe("dynamic credentials in the provider wizard", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("clears credentials when changing providers", async () => { - const view = render(); + const view = render(); fireEvent.change(await screen.findByLabelText(/API token/), { target: { value: "previous-secret" }, }); - view.rerender( - , - ); + view.rerender(); await waitFor(() => expect(screen.getByLabelText(/API token/)).toHaveValue(""), ); @@ -343,7 +383,7 @@ describe("dynamic credentials in the provider wizard", () => { providerType: "acme", secretTypes: { api_key: schema, personal_token: schema }, }); - render(); + render(); fireEvent.change(await screen.findByLabelText(/API token/), { target: { value: "previous-method-secret" }, }); @@ -368,19 +408,21 @@ describe("dynamic credentials in the provider wizard", () => { rejectSave = reject; }), ); - render(); + render(); const field = await screen.findByLabelText(/API token/); fireEvent.change(field, { target: { value: "retry-secret" } }); fireEvent.submit(field.closest("form")!); fireEvent.submit(field.closest("form")!); - expect(saveDynamicProviderCredentials).toHaveBeenCalledOnce(); + await waitFor(() => + expect(saveDynamicProviderCredentials).toHaveBeenCalledOnce(), + ); rejectSave(new Error("Network unavailable")); await screen.findByText( "Could not save the credentials. Check your connection and retry.", ); - expect(props.onNext).not.toHaveBeenCalled(); + expect(props.onConnected).not.toHaveBeenCalled(); fireEvent.submit(field.closest("form")!); - await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); expect(saveDynamicProviderCredentials).toHaveBeenCalledTimes(2); expect(saveDynamicProviderCredentials).toHaveBeenLastCalledWith({ providerId: "account", @@ -413,7 +455,7 @@ describe("dynamic credentials in the provider wizard", () => { _form: "Review the credential fields", }, }); - render(); + render(); const token = await screen.findByLabelText(/API token/); await user.type(token, "fixture-token"); await user.type(screen.getByLabelText(/Project/), "fixture-project"); @@ -430,7 +472,7 @@ describe("dynamic credentials in the provider wizard", () => { // When / Then: submitting again replaces the earlier validation errors. act(() => token.closest("form")!.requestSubmit()); - await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); expect( screen.queryByText("Project is unavailable"), ).not.toBeInTheDocument(); @@ -438,4 +480,98 @@ describe("dynamic credentials in the provider wizard", () => { screen.queryByText("Review the credential fields"), ).not.toBeInTheDocument(); }); + + it("registers a new account with its id before saving its credentials", async () => { + // Given: a fresh wizard, no account yet. + useProviderWizardStore.getState().reset(); + const user = userEvent.setup(); + render(); + const token = await screen.findByLabelText(/API token/); + await user.type( + screen.getByRole("textbox", { name: "Provider UID" }), + "acme-tenant", + ); + await user.type(token, "fixture-token"); + + // When + act(() => token.closest("form")!.requestSubmit()); + + // Then + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); + expect( + Object.fromEntries(addRegistryProvider.mock.calls[0][0] as FormData), + ).toEqual({ + providerType: "acme", + providerUid: "acme-tenant", + providerAlias: "", + }); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "registered", + secretType: "api_key", + secret: { token: "fixture-token" }, + }); + }); + + it("asks for the account id before calling the API", async () => { + // Given + useProviderWizardStore.getState().reset(); + render(); + const token = await screen.findByLabelText(/API token/); + fireEvent.change(token, { target: { value: "fixture-token" } }); + + // When + act(() => token.closest("form")!.requestSubmit()); + + // Then + expect(await screen.findByText("Provider UID is required")).toBeVisible(); + expect(addRegistryProvider).not.toHaveBeenCalled(); + expect(saveDynamicProviderCredentials).not.toHaveBeenCalled(); + }); + + it("keeps the saved alias when the form comes back from the launch step", async () => { + // Given: the account was registered with an alias, then the step remounted. + useProviderWizardStore.getState().setProvider({ + id: "account", + type: "acme", + uid: "acme-uid", + alias: "Production", + }); + render(); + const token = await screen.findByLabelText(/API token/); + + // Then + expect( + screen.getByRole("textbox", { name: "Provider alias (optional)" }), + ).toHaveValue("Production"); + + // When + fireEvent.change(token, { target: { value: "fixture-token" } }); + act(() => token.closest("form")!.requestSubmit()); + + // Then: resubmitting does not rename the account to an empty alias. + await waitFor(() => expect(props.onConnected).toHaveBeenCalledOnce()); + expect(updateProvider).not.toHaveBeenCalled(); + }); + + it("starts blank when the saved account belongs to another provider", async () => { + // Given: an account of another Registry provider was registered earlier. + useProviderWizardStore.getState().setProvider({ + id: "other-account", + type: "other", + uid: "other-uid", + alias: "Other alias", + }); + + // When + render(); + await screen.findByLabelText(/API token/); + + // Then + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "", + ); + expect( + screen.getByRole("textbox", { name: "Provider alias (optional)" }), + ).toHaveValue(""); + }); }); diff --git a/ui/components/providers/wizard/steps/connect/registry-connect-form.tsx b/ui/components/providers/wizard/steps/connect/registry-connect-form.tsx new file mode 100644 index 0000000000..15d0f186bf --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/registry-connect-form.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +import { saveDynamicProviderCredentials } from "@/actions/providers/dynamic-provider-credentials"; +import { getProviderSchemas } from "@/actions/providers/provider-schemas"; +import { RegistryCredentialFields } from "@/components/providers/workflow/provider-credential-fields"; +import { Button } from "@/components/shadcn/button/button"; +import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field"; +import { Input } from "@/components/shadcn/input/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { StatusAlert } from "@/components/shared/status-alert"; +import { + parseRegistryCredentialVariants, + type RegistryCredentialSchema, +} from "@/lib/provider-credentials/provider-credential-schema"; +import { + getCredentialDefaults, + validateCredentialValues, +} from "@/lib/provider-credentials/provider-credential-values"; +import { + ACCOUNT_SUBMIT_OUTCOME, + dispatchProviderFunnel, + PROVIDER_FUNNEL_STEP, +} from "@/lib/provider-funnel/provider-funnel-events"; +import { formatRegistryLabel } from "@/lib/registry/labels"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { + ProviderSchemasResult, + ProviderSecretTypes, +} from "@/types/provider-schema"; + +import { getUidFieldDetails } from "./connect-methods"; +import { + connectProviderAccount, + UID_ERROR_POINTER, +} from "./connect-provider-account"; +import { ConnectionFeedback } from "./connection-feedback"; +import type { ConnectFormProps } from "./types"; +import { + resolveConnectActionLabel, + useConnectionCheck, +} from "./use-connection-check"; + +const UID_FIELD = "_uid"; +const ALIAS_FIELD = "_alias"; +const FORM_ERROR = "_form"; + +interface CredentialMethod { + id: string; + secretType: string; + label: string; + schema: RegistryCredentialSchema | null; +} + +// Each variant of a secret type's schema is a method of its own. +function listCredentialMethods( + secretTypes: ProviderSecretTypes, +): CredentialMethod[] { + return Object.entries(secretTypes).flatMap(([secretType, value]) => { + const label = formatRegistryLabel(secretType); + const variants = parseRegistryCredentialVariants(value); + if (!variants || variants.length === 1) { + return [ + { + id: secretType, + secretType, + label, + schema: variants?.[0].schema ?? null, + }, + ]; + } + return variants.map((variant, index) => ({ + id: `${secretType}/${index}`, + secretType, + label: variant.label ?? `${label} ${index + 1}`, + schema: variant.schema, + })); + }); +} + +function credentialFormError(status: ProviderSchemasResult["status"]) { + switch (status) { + case "access_denied": + return { + title: "Access required", + description: + "Your session may have expired or you may not have permission. Sign in again or contact your administrator.", + }; + case "unavailable": + return { + title: "Provider installation unavailable", + description: + "Install this provider's artifact again in Registry, then try again.", + }; + case "not_found": + return { + title: "Credential form unavailable", + description: + "This provider does not provide a credential form. Contact its publisher or your administrator.", + }; + case "success": + case "malformed": + return { + title: "Credential form not supported", + description: + "We could not display this provider's credential form. Contact its publisher or your administrator.", + }; + default: + return { + title: "Could not load credential form", + description: "Check your connection and try again.", + }; + } +} + +const saveFailureMessage = (status: string) => + status === "schema_unavailable" + ? "The credential schema is unavailable. Check the installed artifact in Registry and reload the form." + : status === "access_denied" + ? "You no longer have permission to update these credentials. Contact an administrator." + : "Check your credentials and try again. Your provider account is already created."; + +const savedAccount = (providerType: string) => { + const store = useProviderWizardStore.getState(); + return store.providerType === providerType + ? { uid: store.providerUid ?? "", alias: store.providerAlias ?? "" } + : { uid: "", alias: "" }; +}; + +interface RegistryCredentialFormProps extends ConnectFormProps { + providerType: string; + secretType: string; + schema: RegistryCredentialSchema; +} + +function RegistryCredentialForm({ + providerType, + secretType, + schema, + formId, + locked, + submitLabel, + onConnected, + onBusyChange, + onUiStateChange, +}: RegistryCredentialFormProps) { + // Credentials belong only to this form. A new account or authentication + // method mounts a fresh instance; no values enter the persisted wizard store. + const [values, setValues] = useState(() => getCredentialDefaults(schema)); + // A remount (back from the launch step) keeps the account already saved for + // this provider; another provider's account never leaks into the form. + const [uid, setUid] = useState(() => savedAccount(providerType).uid); + const [alias, setAlias] = useState(() => savedAccount(providerType).alias); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const inFlight = useRef(false); + const connection = useConnectionCheck(onConnected); + const uidField = getUidFieldDetails(providerType); + const credentialsValid = validateCredentialValues(schema, values).valid; + const canSubmit = credentialsValid && (locked || uid.trim().length > 0); + const isBusy = saving || connection.isTesting; + + useEffect(() => { + onBusyChange(isBusy); + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: resolveConnectActionLabel({ + submitLabel, + isTesting: connection.isTesting, + isSubmitting: saving, + hasFailed: connection.error !== null, + hasPending: connection.pending !== null, + }), + actionDisabled: !canSubmit || isBusy, + isLoading: isBusy, + }); + }, [ + canSubmit, + connection.error, + connection.isTesting, + connection.pending, + isBusy, + onBusyChange, + onUiStateChange, + saving, + submitLabel, + ]); + + const clearError = (name: string) => { + connection.clear(); + setErrors((current) => { + const next = { ...current }; + delete next[name]; + return next; + }); + }; + + const submit = async () => { + const validation = validateCredentialValues(schema, values); + const identityErrors: Record = + locked || uid.trim() + ? {} + : { [UID_FIELD]: `${uidField.label} is required` }; + setErrors({ ...validation.errors, ...identityErrors }); + if (!validation.valid || Object.keys(identityErrors).length > 0) return; + + connection.clear(); + const account = await connectProviderAccount({ + providerType, + method: secretType, + uid: uid.trim(), + alias: locked ? undefined : alias, + secretValues: {}, + locked, + skipSecret: true, + }); + if (!account.ok) { + const next: Record = {}; + account.errors.forEach((error) => { + const pointer = error.source?.pointer; + const field = + pointer === UID_ERROR_POINTER || + pointer === "/data/attributes/__all__" + ? UID_FIELD + : pointer === "/data/attributes/alias" + ? ALIAS_FIELD + : FORM_ERROR; + next[field] = error.detail; + }); + setErrors(next); + return; + } + + const result = await saveDynamicProviderCredentials({ + providerId: account.providerId, + secretType, + secret: validation.secret, + }); + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.ACCOUNT_SUBMITTED, + providerType, + via: secretType, + outcome: + result.status === "saved" + ? ACCOUNT_SUBMIT_OUTCOME.SUCCESS + : ACCOUNT_SUBMIT_OUTCOME.ERROR, + }); + if (result.status === "saved") { + useProviderWizardStore.getState().setSecretId(result.secretId); + useProviderWizardStore.getState().setVia(secretType); + await connection.check(account.providerId); + } else if (result.status === "invalid") { + setErrors(result.errors); + } else { + setErrors({ [FORM_ERROR]: saveFailureMessage(result.status) }); + } + }; + + return ( +
{ + event.preventDefault(); + if (inFlight.current) return; + inFlight.current = true; + setSaving(true); + try { + await submit(); + } catch { + setErrors({ + [FORM_ERROR]: + "Could not save the credentials. Check your connection and retry.", + }); + } finally { + inFlight.current = false; + setSaving(false); + } + }} + > +
+ + {errors[FORM_ERROR] && ( + + {errors[FORM_ERROR]} + + )} + {!locked && ( +
+ + + {uidField.label} + + { + setUid(event.target.value); + clearError(UID_FIELD); + }} + /> + {errors[UID_FIELD] && ( + {errors[UID_FIELD]} + )} + + + + Provider alias (optional) + + { + setAlias(event.target.value); + clearError(ALIAS_FIELD); + }} + /> + {errors[ALIAS_FIELD] && ( + {errors[ALIAS_FIELD]} + )} + +
+ )} + { + setValues((current) => ({ ...current, [name]: value })); + clearError(name); + }} + /> +
+
+ ); +} + +interface RegistryConnectFormProps extends ConnectFormProps { + providerType: string; +} + +/** Account and credentials of a Registry provider, from its published schema. */ +function RegistryConnectContent(props: RegistryConnectFormProps) { + const { providerType, onUiStateChange, onBusyChange } = props; + const [schemas, setSchemas] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(""); + const [attempt, setAttempt] = useState(0); + const [busy, setBusy] = useState(false); + // The method picker locks with the form; the step hears the same flag. + useEffect(() => { + onBusyChange(busy); + }, [busy, onBusyChange]); + useEffect(() => { + let active = true; + setSchemas(null); + setSelectedMethod(""); + getProviderSchemas(providerType) + .then((result) => { + if (active) setSchemas(result); + }) + .catch(() => { + if (active) setSchemas({ status: "error" }); + }); + return () => { + active = false; + }; + }, [providerType, attempt]); + + const methods = + schemas?.status === "success" + ? listCredentialMethods(schemas.secretTypes) + : []; + const method = methods.find(({ id }) => id === selectedMethod) ?? methods[0]; + const schema = method?.schema ?? null; + useEffect(() => { + if (!schema) + onUiStateChange({ + showBack: true, + showAction: false, + actionLabel: props.submitLabel, + actionDisabled: true, + isLoading: false, + }); + }, [schema, onUiStateChange, props.submitLabel]); + + if (!schemas) + return ( +
+ + +
+ ); + + const error = credentialFormError( + schemas.status === "success" && methods.length === 0 + ? "not_found" + : schemas.status, + ); + + return ( +
+ {methods.length > 1 && ( + + + Authentication method + + + + )} + {method && schema ? ( + + ) : ( +
+ + {error.description} + +
+ + +
+
+ )} +
+ ); +} + +export function RegistryConnectForm(props: RegistryConnectFormProps) { + return ; +} diff --git a/ui/components/providers/wizard/steps/connect/types.ts b/ui/components/providers/wizard/steps/connect/types.ts new file mode 100644 index 0000000000..a64c48233d --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/types.ts @@ -0,0 +1,30 @@ +/** What the footer's main action does: submit the step's form, or close the wizard. */ +export const CONNECT_ACTION_KIND = { + SUBMIT: "submit", + CLOSE: "close", +} as const; + +export type ConnectActionKind = + (typeof CONNECT_ACTION_KIND)[keyof typeof CONNECT_ACTION_KIND]; + +/** What the connect step publishes so the wizard can draw its footer. */ +export interface ConnectUiState { + showBack: boolean; + showAction: boolean; + actionLabel: string; + actionDisabled: boolean; + isLoading: boolean; + /** Absent means submit. */ + actionKind?: ConnectActionKind; +} + +/** Callbacks every access form of the connect step receives. */ +export interface ConnectFormProps { + formId: string; + /** The wizard was opened on an existing account: its identity is fixed. */ + locked: boolean; + submitLabel: string; + onConnected: () => void; + onBusyChange: (isBusy: boolean) => void; + onUiStateChange: (state: ConnectUiState) => void; +} diff --git a/ui/components/providers/wizard/steps/connect/use-connect-draft.ts b/ui/components/providers/wizard/steps/connect/use-connect-draft.ts new file mode 100644 index 0000000000..e7964df05b --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/use-connect-draft.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { FieldValues, UseFormReturn, useWatch } from "react-hook-form"; + +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ConnectDraftValues } from "@/types/provider-wizard"; + +const DEFAULT_METHOD_KEY = "default"; + +export const draftMethodKey = (method: string | null) => + method ?? DEFAULT_METHOD_KEY; + +/** What was typed for this provider and method earlier in the same wizard. */ +export const readDraftValues = ( + providerType: string, + method: string | null, +): ConnectDraftValues | undefined => { + const draft = useProviderWizardStore.getState().connectDraft; + if (draft?.providerType !== providerType) return undefined; + return draft.values[draftMethodKey(method)]; +}; + +// What the user typed survives the step unmounting (organizations tab, a step +// back from the launch step) until the wizard closes. +export function useDraftValues( + form: UseFormReturn, + providerType: string, + method: string | null, +) { + const values = useWatch({ control: form.control }); + useEffect(() => { + const store = useProviderWizardStore.getState(); + const current = + store.connectDraft?.providerType === providerType + ? store.connectDraft.values + : {}; + store.setConnectDraft(providerType, { + values: { + ...current, + [draftMethodKey(method)]: values as ConnectDraftValues, + }, + }); + }, [method, providerType, values]); +} diff --git a/ui/components/providers/wizard/steps/connect/use-connect-submit.ts b/ui/components/providers/wizard/steps/connect/use-connect-submit.ts new file mode 100644 index 0000000000..1127114eeb --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/use-connect-submit.ts @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect } from "react"; +import { FieldValues, UseFormReturn, useFormState } from "react-hook-form"; + +import { useFormServerErrors } from "@/hooks/use-form-server-errors"; +import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings"; +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 { + connectProviderAccount, + UID_ERROR_POINTER, +} from "./connect-provider-account"; +import type { ConnectFormProps } from "./types"; +import { + resolveConnectActionLabel, + useConnectionCheck, +} from "./use-connection-check"; + +const ALIAS_ERROR_POINTER = "/data/attributes/alias"; +const UNIQUE_TOGETHER_ERROR_POINTER = "/data/attributes/__all__"; + +export interface ConnectIdentity { + uid: string | null; + /** Undefined keeps the stored alias. */ + alias?: string; +} + +interface UseConnectSubmitOptions + extends Omit { + form: UseFormReturn; + providerType: string; + method: string | null; + // The field an account-level API error belongs to for this form. + accountField: string; + // Beyond form validity: e.g. the AWS role form needs an account read from the ARN. + accountResolved?: boolean; + resolveIdentity: (values: T) => ConnectIdentity; + extraValues?: Record; +} + +/** Registers the account, stores its credentials and tests the connection in one submit. */ +export function useConnectSubmit({ + form, + providerType, + method, + accountField, + accountResolved = true, + resolveIdentity, + extraValues, + locked, + submitLabel, + onConnected, + onBusyChange, + onUiStateChange, +}: UseConnectSubmitOptions) { + const { handleServerResponse } = useFormServerErrors(form, { + ...PROVIDER_CREDENTIALS_ERROR_MAPPING, + [UID_ERROR_POINTER]: accountField, + [UNIQUE_TOGETHER_ERROR_POINTER]: accountField, + [ALIAS_ERROR_POINTER]: ProviderCredentialFields.PROVIDER_ALIAS, + }); + const connection = useConnectionCheck(onConnected); + // 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; + const isBusy = isSubmitting || connection.isTesting; + const hasFailed = connection.error !== null; + const hasPending = connection.pending !== null; + + // Same contract as every connect form: the wizard footer lives outside the step. + // Both callbacks must be stable setters, or this effect would loop. + useEffect(() => { + onBusyChange(isBusy); + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: resolveConnectActionLabel({ + submitLabel, + isTesting: connection.isTesting, + isSubmitting, + hasFailed, + hasPending, + }), + actionDisabled: !canSubmit || isBusy, + isLoading: isBusy, + }); + }, [ + canSubmit, + connection.isTesting, + hasFailed, + hasPending, + isBusy, + isSubmitting, + onBusyChange, + onUiStateChange, + submitLabel, + ]); + + // A past failure or a still-pending result must not sit above the field the + // user is already correcting. + const { clear } = connection; + useEffect(() => { + if (!hasFailed && !hasPending) return; + const subscription = form.watch(() => clear()); + return () => subscription.unsubscribe(); + }, [clear, form, hasFailed, hasPending]); + + const onSubmit = form.handleSubmit(async (values) => { + connection.clear(); + const identity = resolveIdentity(values); + const result = await connectProviderAccount({ + providerType, + method, + uid: identity.uid, + alias: identity.alias, + secretValues: { ...values, ...extraValues }, + locked, + }); + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.ACCOUNT_SUBMITTED, + providerType, + via: method ?? "default", + 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; + } + + // The account stays registered whatever the test says; resubmitting edits it. + await connection.check(result.providerId); + }); + + return { + onSubmit, + isTesting: connection.isTesting, + connectionError: connection.error, + connectionPending: connection.pending, + }; +} diff --git a/ui/components/providers/wizard/steps/connect/use-connection-check.ts b/ui/components/providers/wizard/steps/connect/use-connection-check.ts new file mode 100644 index 0000000000..6ba74cdd3b --- /dev/null +++ b/ui/components/providers/wizard/steps/connect/use-connection-check.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useRef, useState } from "react"; + +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { testProviderConnection } from "@/lib/provider-helpers"; +import { CONNECTION_CHECK_STATUS } from "@/types/providers"; + +const CONNECTION_FAILED_MESSAGE = + "Prowler could not connect with these credentials. Review them and try again."; + +const CONNECTION_UNREACHABLE_MESSAGE = + "The connection test could not be completed. The account is saved, so you can try again."; + +// Fallback only: `testProviderConnection` already carries this same message on +// `error` for a pending result (see `resolveProviderConnectionState`). +const CONNECTION_PENDING_MESSAGE = + "The connection test is still running. Refresh in a moment to see the result."; + +/** Footer label for the one-step form: the test and the retry share the submit. */ +export const resolveConnectActionLabel = ({ + submitLabel, + isTesting, + isSubmitting, + hasFailed, + hasPending, +}: { + submitLabel: string; + isTesting: boolean; + isSubmitting: boolean; + hasFailed: boolean; + hasPending: boolean; +}) => { + if (isTesting) return "Testing connection..."; + if (isSubmitting) return "Connecting..."; + if (hasFailed) return "Retry connection"; + return hasPending ? "Check again" : submitLabel; +}; + +/** Tests a saved account and keeps the verdict for the step to show. */ +export function useConnectionCheck(onConnected: () => void) { + // Local state needed: the test outcome belongs to the step, not to any field. + const [isTesting, setIsTesting] = useState(false); + const [error, setError] = useState(null); + // Still running past the wait: neither a pass nor a fail, so it never takes + // the destructive styling a confirmed failure gets. + const [pending, setPending] = useState(null); + // Closing the wizard (or switching to organizations) unmounts the step while a + // test may still be running; its result must not advance a wizard already reset. + const isActiveRef = useRef(true); + useMountEffect(() => { + isActiveRef.current = true; + return () => { + isActiveRef.current = false; + }; + }); + + const clear = () => { + setError(null); + setPending(null); + }; + + // Task polling rejects on a 5xx, so the flag is cleared in a finally or the + // step would stay stuck on "Testing connection...". + const check = async (providerId: string) => { + let connected = false; + setIsTesting(true); + try { + const connection = await testProviderConnection(providerId); + connected = connection.status === CONNECTION_CHECK_STATUS.SUCCESS; + if (connection.status === CONNECTION_CHECK_STATUS.FAILED) { + setError(connection.error || CONNECTION_FAILED_MESSAGE); + } else if (connection.status === CONNECTION_CHECK_STATUS.PENDING) { + setPending(connection.error || CONNECTION_PENDING_MESSAGE); + } + } catch { + setError(CONNECTION_UNREACHABLE_MESSAGE); + } finally { + setIsTesting(false); + } + + if (connected && isActiveRef.current) onConnected(); + }; + + return { check, clear, isTesting, error, pending }; +} diff --git a/ui/components/providers/wizard/steps/credentials-step.test.tsx b/ui/components/providers/wizard/steps/credentials-step.test.tsx deleted file mode 100644 index 8fa035ea33..0000000000 --- a/ui/components/providers/wizard/steps/credentials-step.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -vi.mock("./dynamic-credentials-step", () => ({ - DynamicCredentialsStep: () =>
dynamic-credentials-form
, -})); -import { render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; - -import { CredentialsStep } from "./credentials-step"; - -vi.mock("../../workflow/forms", () => ({ - AddViaCredentialsForm: () =>
add-via-credentials-form
, - AddViaRoleForm: () =>
add-via-role-form
, - UpdateViaCredentialsForm: () =>
update-via-credentials-form
, - UpdateViaRoleForm: () =>
update-via-role-form
, -})); - -vi.mock("../../workflow/forms/select-credentials-type/aws", () => ({ - SelectViaAWS: () =>
select-via-aws
, -})); - -vi.mock("../../workflow/forms/select-credentials-type/alibabacloud", () => ({ - SelectViaAlibabaCloud: () =>
select-via-alibabacloud
, -})); - -vi.mock("../../workflow/forms/select-credentials-type/cloudflare", () => ({ - SelectViaCloudflare: () =>
select-via-cloudflare
, -})); - -vi.mock("../../workflow/forms/select-credentials-type/gcp", () => ({ - AddViaServiceAccountForm: () =>
add-via-service-account-form
, - SelectViaGCP: () =>
select-via-gcp
, -})); - -vi.mock("../../workflow/forms/select-credentials-type/github", () => ({ - SelectViaGitHub: () =>
select-via-github
, -})); - -vi.mock("../../workflow/forms/select-credentials-type/m365", () => ({ - SelectViaM365: () =>
select-via-m365
, -})); - -vi.mock("../../workflow/forms/update-via-service-account-key-form", () => ({ - UpdateViaServiceAccountForm: () =>
update-via-service-account-form
, -})); - -vi.mock("@/actions/providers/providers", () => ({ - checkConnectionProvider: vi.fn(), -})); - -vi.mock("@/actions/task/tasks", () => ({ - getTask: vi.fn(), -})); - -vi.mock("@/lib/helper", () => ({ - checkTaskStatus: vi.fn(), - getAuthHeaders: vi.fn(), -})); - -describe("CredentialsStep", () => { - beforeEach(() => { - sessionStorage.clear(); - localStorage.clear(); - useProviderWizardStore.getState().reset(); - }); - - it("renders update role form when secret already exists in add mode", () => { - // Given - useProviderWizardStore.setState({ - providerId: "provider-1", - providerType: "aws", - providerUid: "111111111111", - providerAlias: "Production", - via: "role", - secretId: "secret-1", - mode: PROVIDER_WIZARD_MODE.ADD, - }); - - // When - render( - , - ); - - // Then - expect(screen.getByText("update-via-role-form")).toBeInTheDocument(); - expect(screen.queryByText("add-via-role-form")).not.toBeInTheDocument(); - }); -}); diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx deleted file mode 100644 index 75836a25e3..0000000000 --- a/ui/components/providers/wizard/steps/credentials-step.tsx +++ /dev/null @@ -1,278 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -import { getProviderFormType } from "@/lib/provider-helpers"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { isKnownProviderType, ProviderType } from "@/types/providers"; - -import { - AddViaCredentialsForm, - AddViaRoleForm, - UpdateViaCredentialsForm, - UpdateViaRoleForm, -} from "../../workflow/forms"; -import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud"; -import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws"; -import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare"; -import { - AddViaServiceAccountForm, - SelectViaGCP, -} from "../../workflow/forms/select-credentials-type/gcp"; -import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/github"; -import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365"; -import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form"; - -import { DynamicCredentialsStep } from "./dynamic-credentials-step"; -import { - WIZARD_FOOTER_ACTION_TYPE, - WizardFooterConfig, -} from "./footer-controls"; - -interface CredentialsStepProps { - onNext: () => void; - onBack: () => void; - onFooterChange: (config: WizardFooterConfig) => void; -} - -export function CredentialsStep(props: CredentialsStepProps) { - const providerId = useProviderWizardStore((state) => state.providerId); - const providerType = useProviderWizardStore((state) => state.providerType); - if (providerId && providerType && !isKnownProviderType(providerType)) { - return ( - - ); - } - return ; -} - -function BuiltinCredentialsStep({ - onNext, - onBack, - onFooterChange, -}: CredentialsStepProps) { - const { providerId, providerType, providerUid, via, secretId, setVia } = - useProviderWizardStore(); - const [isFormLoading, setIsFormLoading] = useState(false); - const [isFormValid, setIsFormValid] = useState(false); - - const formId = "provider-wizard-credentials-form"; - const hasProviderContext = Boolean(providerType && providerId); - const formType = - providerType && providerId - ? getProviderFormType(providerType, via || undefined) - : null; - const shouldUseUpdateForms = Boolean(secretId); - - const handleBack = () => { - if (via) { - setVia(null); - return; - } - onBack(); - }; - - const handleViaChange = (value: string) => { - setVia(value); - }; - - useEffect(() => { - setIsFormValid(false); - }, [formType, via]); - - useEffect(() => { - if (!hasProviderContext) { - onFooterChange({ - showBack: true, - backLabel: "Back", - onBack, - showAction: false, - actionLabel: "Authenticate", - actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - }); - return; - } - - const isSelector = formType === "selector"; - - onFooterChange({ - showBack: true, - backLabel: "Back", - backDisabled: isFormLoading, - onBack: () => { - if (via) { - setVia(null); - return; - } - onBack(); - }, - showAction: !isSelector, - actionLabel: "Authenticate", - actionDisabled: isFormLoading || !isFormValid, - actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, - actionFormId: formId, - }); - }, [ - hasProviderContext, - formType, - formId, - isFormLoading, - isFormValid, - onBack, - onFooterChange, - setVia, - via, - ]); - - if (!providerType || !providerId) { - return ( -
-

- Provider details are missing. Go back and select a provider. -

-
- ); - } - - if (formType === "selector") { - if (providerType === "aws") { - return ( - - ); - } - if (providerType === "gcp") { - return ( - - ); - } - if (providerType === "github") { - return ( - - ); - } - if (providerType === "m365") { - return ( - - ); - } - if (providerType === "alibabacloud") { - return ( - - ); - } - if (providerType === "cloudflare") { - return ( - - ); - } - return null; - } - - const commonFormProps = { - via, - onSuccess: onNext, - onBack: handleBack, - providerUid: providerUid || undefined, - formId, - hideActions: true, - onLoadingChange: setIsFormLoading, - onValidityChange: setIsFormValid, - validationMode: "onChange" as const, - }; - - if (formType === "credentials") { - if (shouldUseUpdateForms) { - return ( - - ); - } - - return ( - - ); - } - - if (formType === "role") { - if (shouldUseUpdateForms) { - return ( - - ); - } - - return ( - - ); - } - - if (formType === "service-account") { - if (shouldUseUpdateForms) { - return ( - - ); - } - - return ( - - ); - } - - return ( -
-

- Select a credential type to continue. -

-
- ); -} diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx deleted file mode 100644 index f8732b2853..0000000000 --- a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx +++ /dev/null @@ -1,372 +0,0 @@ -"use client"; - -import { RotateCcw } from "lucide-react"; -import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; - -import { saveDynamicProviderCredentials } from "@/actions/providers/dynamic-provider-credentials"; -import { getProviderSchemas } from "@/actions/providers/provider-schemas"; -import { RegistryCredentialFields } from "@/components/providers/workflow/provider-credential-fields"; -import { Button } from "@/components/shadcn/button/button"; -import { Field, FieldLabel } from "@/components/shadcn/field/field"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/shadcn/select/select"; -import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -import { useToast } from "@/components/shadcn/toast"; -import { StatusAlert } from "@/components/shared/status-alert"; -import { - parseRegistryCredentialVariants, - type RegistryCredentialSchema, -} from "@/lib/provider-credentials/provider-credential-schema"; -import { - getCredentialDefaults, - validateCredentialValues, -} from "@/lib/provider-credentials/provider-credential-values"; -import { formatRegistryLabel } from "@/lib/registry/labels"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import type { - ProviderSchemasResult, - ProviderSecretTypes, -} from "@/types/provider-schema"; - -import { - WIZARD_FOOTER_ACTION_TYPE, - type WizardFooterConfig, -} from "./footer-controls"; - -interface DynamicCredentialsStepProps { - providerId: string; - providerType: string; - onNext: () => void; - onBack: () => void; - onFooterChange: (config: WizardFooterConfig) => void; -} - -interface CredentialMethod { - id: string; - secretType: string; - label: string; - schema: RegistryCredentialSchema | null; -} - -// Each variant of a secret type's schema is a method of its own. -function listCredentialMethods( - secretTypes: ProviderSecretTypes, -): CredentialMethod[] { - return Object.entries(secretTypes).flatMap(([secretType, value]) => { - const label = formatRegistryLabel(secretType); - const variants = parseRegistryCredentialVariants(value); - if (!variants || variants.length === 1) { - return [ - { - id: secretType, - secretType, - label, - schema: variants?.[0].schema ?? null, - }, - ]; - } - return variants.map((variant, index) => ({ - id: `${secretType}/${index}`, - secretType, - label: variant.label ?? `${label} ${index + 1}`, - schema: variant.schema, - })); - }); -} - -function credentialFormError(status: ProviderSchemasResult["status"]) { - switch (status) { - case "access_denied": - return { - title: "Access required", - description: - "Your session may have expired or you may not have permission. Sign in again or contact your administrator.", - }; - case "unavailable": - return { - title: "Provider installation unavailable", - description: - "Install this provider's artifact again in Registry, then try again.", - }; - case "not_found": - return { - title: "Credential form unavailable", - description: - "This provider does not provide a credential form. Contact its publisher or your administrator.", - }; - case "success": - case "malformed": - return { - title: "Credential form not supported", - description: - "We could not display this provider's credential form. Contact its publisher or your administrator.", - }; - default: - return { - title: "Could not load credential form", - description: "Check your connection and try again.", - }; - } -} - -function DynamicCredentialForm({ - providerId, - secretType, - schema, - onNext, - onBack, - onFooterChange, - onLoadingChange, -}: Omit & { - secretType: string; - schema: RegistryCredentialSchema; - onLoadingChange: (value: boolean) => void; -}) { - const { toast } = useToast(); - const setSecretId = useProviderWizardStore((state) => state.setSecretId); - // Credentials belong only to this form. A new account or authentication - // method mounts a fresh instance; no values enter the persisted wizard store. - const [values, setValues] = useState(() => getCredentialDefaults(schema)); - const [errors, setErrors] = useState>({}); - const [saving, setSaving] = useState(false); - const inFlight = useRef(false); - const mounted = useRef(true); - const formId = "provider-wizard-dynamic-credentials-form"; - const valid = validateCredentialValues(schema, values).valid; - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - - useEffect(() => { - onFooterChange({ - showBack: true, - backLabel: "Back", - backDisabled: saving, - onBack, - showAction: true, - actionLabel: "Authenticate", - actionDisabled: saving || !valid, - actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, - actionFormId: formId, - }); - }, [onBack, onFooterChange, saving, valid]); - - return ( -
{ - event.preventDefault(); - if (inFlight.current) return; - const validation = validateCredentialValues(schema, values); - setErrors(validation.errors); - if (!validation.valid) return; - inFlight.current = true; - setSaving(true); - onLoadingChange(true); - try { - const result = await saveDynamicProviderCredentials({ - providerId, - secretType, - secret: validation.secret, - }); - if (!mounted.current) return; - if (result.status === "saved") { - setValues({}); - setSecretId(result.secretId); - toast({ - title: "Credentials saved", - description: "Test the provider connection to continue.", - }); - onNext(); - } else if (result.status === "invalid") { - setErrors(result.errors); - } else { - const description = - result.status === "schema_unavailable" - ? "The credential schema is unavailable. Check the installed artifact in Registry and reload the form." - : result.status === "access_denied" - ? "You no longer have permission to update these credentials. Contact an administrator." - : "Check your credentials and try again. Your provider account is already created."; - setErrors({ _form: description }); - toast({ - variant: "destructive", - title: "Credentials could not be saved", - description, - }); - } - } catch { - if (mounted.current) { - const description = - "Could not save the credentials. Check your connection and retry."; - setErrors({ _form: description }); - toast({ - variant: "destructive", - title: "Credentials could not be saved", - description, - }); - } - } finally { - inFlight.current = false; - if (mounted.current) { - setSaving(false); - onLoadingChange(false); - } - } - }} - > -
- {errors._form && ( - - {errors._form} - - )} - { - setValues((current) => ({ ...current, [name]: value })); - setErrors((current) => { - const next = { ...current }; - delete next[name]; - return next; - }); - }} - /> -
-
- ); -} - -function DynamicCredentialsContent(props: DynamicCredentialsStepProps) { - const { providerType, onBack, onFooterChange } = props; - const [schemas, setSchemas] = useState(null); - const [selectedMethod, setSelectedMethod] = useState(""); - const [attempt, setAttempt] = useState(0); - const [saving, setSaving] = useState(false); - useEffect(() => { - let active = true; - setSchemas(null); - setSelectedMethod(""); - getProviderSchemas(providerType) - .then((result) => { - if (active) setSchemas(result); - }) - .catch(() => { - if (active) setSchemas({ status: "error" }); - }); - return () => { - active = false; - }; - }, [providerType, attempt]); - - const methods = - schemas?.status === "success" - ? listCredentialMethods(schemas.secretTypes) - : []; - const method = methods.find(({ id }) => id === selectedMethod) ?? methods[0]; - const schema = method?.schema ?? null; - useEffect(() => { - if (!schema) - onFooterChange({ - showBack: true, - backLabel: "Back", - onBack, - showAction: false, - actionLabel: "Authenticate", - actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - }); - }, [schema, onBack, onFooterChange]); - - if (!schemas) - return ( -
- - -
- ); - - const error = credentialFormError( - schemas.status === "success" && methods.length === 0 - ? "not_found" - : schemas.status, - ); - - return ( -
- {methods.length > 1 && ( - - - Authentication method - - - - )} - {method && schema ? ( - - ) : ( -
- - {error.description} - -
- - -
-
- )} -
- ); -} - -export function DynamicCredentialsStep(props: DynamicCredentialsStepProps) { - return ( - - ); -} diff --git a/ui/components/providers/wizard/steps/index.ts b/ui/components/providers/wizard/steps/index.ts index f2028f9dfa..a616330d90 100644 --- a/ui/components/providers/wizard/steps/index.ts +++ b/ui/components/providers/wizard/steps/index.ts @@ -1,5 +1,3 @@ export * from "./connect-step"; -export * from "./credentials-step"; export * from "./footer-controls"; export * from "./launch-step"; -export * from "./test-connection-step"; diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx index b35719af29..88ce68569c 100644 --- a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx @@ -21,14 +21,14 @@ import { } from "@/types/invitations"; import { getProviderDisplayName, type ProviderType } from "@/types/providers"; -import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; +import { CONNECT_ACTION_KIND, type ConnectUiState } from "../connect/types"; interface InviteTeammateFormProps { roles: InvitationRoleOption[]; providerType: ProviderType; formId: string; onSent: (invitation: SentInvitation) => void; - onUiStateChange: (state: AwsConnectUiState) => void; + onUiStateChange: (state: ConnectUiState) => void; onBusyChange: (isBusy: boolean) => void; } @@ -59,7 +59,7 @@ export function InviteTeammateForm({ actionLabel: isSubmitting ? "Sending invitation..." : "Send invitation", actionDisabled: !isValid || isSubmitting, isLoading: isSubmitting, - actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + actionKind: CONNECT_ACTION_KIND.SUBMIT, }); }, [isSubmitting, isValid, onBusyChange, onUiStateChange]); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx index 3b5ea44c04..47e32a41b8 100644 --- a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getProviderDisplayName } from "@/types/providers"; -import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; +import { CONNECT_ACTION_KIND, type ConnectUiState } from "../connect/types"; import { InviteTeammatePanel } from "./invite-teammate-panel"; @@ -77,12 +77,12 @@ function Harness({ onUiState, onBusyChange, }: { - onUiState: (state: AwsConnectUiState) => void; + onUiState: (state: ConnectUiState) => void; onBusyChange: (isBusy: boolean) => void; }) { - const [uiState, setUiState] = useState(null); + const [uiState, setUiState] = useState(null); // Stable like the wizard's own setter: the panel keys an effect on it. - const handleUiState = useRef((state: AwsConnectUiState) => { + const handleUiState = useRef((state: ConnectUiState) => { setUiState(state); onUiState(state); }).current; @@ -115,7 +115,7 @@ function renderPanel() { } const lastUiState = (onUiState: ReturnType) => - onUiState.mock.calls.at(-1)?.[0] as AwsConnectUiState; + onUiState.mock.calls.at(-1)?.[0] as ConnectUiState; async function fillAndSend(user: ReturnType) { await user.type( @@ -150,7 +150,7 @@ describe("InviteTeammatePanel", () => { showAction: true, actionLabel: "Send invitation", actionDisabled: true, - actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + actionKind: CONNECT_ACTION_KIND.SUBMIT, }); expect(screen.getByRole("status")).toHaveTextContent(/Loading roles/); }); @@ -206,7 +206,7 @@ describe("InviteTeammatePanel", () => { expect(lastUiState(onUiState)).toMatchObject({ actionLabel: "Done", actionDisabled: false, - actionKind: AWS_CONNECT_ACTION_KIND.CLOSE, + actionKind: CONNECT_ACTION_KIND.CLOSE, }); expect(onBusyChange).toHaveBeenLastCalledWith(false); expect(onBusyChange).toHaveBeenCalledWith(true); @@ -251,7 +251,7 @@ describe("InviteTeammatePanel", () => { expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument(); expect(lastUiState(onUiState)).toMatchObject({ actionLabel: "Send invitation", - actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + actionKind: CONNECT_ACTION_KIND.SUBMIT, }); }); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx index 972b173147..fe010ec31b 100644 --- a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx @@ -9,7 +9,7 @@ import { orderRolesAdminFirst } from "@/lib/invitations/order-roles"; import type { SentInvitation } from "@/types/invitations"; import type { ProviderType } from "@/types/providers"; -import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; +import { CONNECT_ACTION_KIND, type ConnectUiState } from "../connect/types"; import { InviteTeammateForm } from "./invite-teammate-form"; import { InviteTeammateSent } from "./invite-teammate-sent"; @@ -19,14 +19,14 @@ const SEND_LABEL = "Send invitation"; interface InviteTeammatePanelProps { providerType: ProviderType; formId: string; - onUiStateChange: (state: AwsConnectUiState) => void; + onUiStateChange: (state: ConnectUiState) => void; onBusyChange: (isBusy: boolean) => void; } /** * The connect step's way out for a user who cannot connect the account: invite * a teammate who can. Loads the roles, sends the invitation through the wizard - * footer and then shows the link to share. Provider-agnostic; AWS mounts it. + * footer and then shows the link to share. Provider-agnostic: every connect step offers it. */ export function InviteTeammatePanel({ providerType, @@ -74,7 +74,7 @@ export function InviteTeammatePanel({ } interface StaticStateProps { - onUiStateChange: (state: AwsConnectUiState) => void; + onUiStateChange: (state: ConnectUiState) => void; } function RolesLoading({ onUiStateChange }: StaticStateProps) { @@ -85,7 +85,7 @@ function RolesLoading({ onUiStateChange }: StaticStateProps) { actionLabel: SEND_LABEL, actionDisabled: true, isLoading: false, - actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + actionKind: CONNECT_ACTION_KIND.SUBMIT, }); }); @@ -108,7 +108,7 @@ function RolesUnavailable({ onUiStateChange }: StaticStateProps) { actionLabel: SEND_LABEL, actionDisabled: true, isLoading: false, - actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + actionKind: CONNECT_ACTION_KIND.SUBMIT, }); }); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx index 75c7f820d2..e1188fb1f6 100644 --- a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx @@ -9,12 +9,12 @@ import { isCloud } from "@/lib/shared/env"; import type { SentInvitation } from "@/types/invitations"; import { getProviderDisplayName, type ProviderType } from "@/types/providers"; -import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; +import { CONNECT_ACTION_KIND, type ConnectUiState } from "../connect/types"; interface InviteTeammateSentProps { invitation: SentInvitation; providerType: ProviderType; - onUiStateChange: (state: AwsConnectUiState) => void; + onUiStateChange: (state: ConnectUiState) => void; } /** The link to share once the invitation exists; the footer's "Done" closes the wizard. */ @@ -30,7 +30,7 @@ export function InviteTeammateSent({ actionLabel: "Done", actionDisabled: false, isLoading: false, - actionKind: AWS_CONNECT_ACTION_KIND.CLOSE, + actionKind: CONNECT_ACTION_KIND.CLOSE, }); }); diff --git a/ui/components/providers/wizard/steps/test-connection-step.test.tsx b/ui/components/providers/wizard/steps/test-connection-step.test.tsx deleted file mode 100644 index 75d564aa54..0000000000 --- a/ui/components/providers/wizard/steps/test-connection-step.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { useEffect } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; - -import { TestConnectionStep } from "./test-connection-step"; - -const { getProviderMock, loadingFromFormMock } = vi.hoisted(() => ({ - getProviderMock: vi.fn(), - loadingFromFormMock: { current: false }, -})); - -vi.mock("@/actions/providers", () => ({ - getProvider: getProviderMock, -})); - -vi.mock("../../workflow/forms/test-connection-form", () => ({ - TestConnectionForm: ({ - onLoadingChange, - }: { - onLoadingChange?: (isLoading: boolean) => void; - }) => { - useEffect(() => { - if (loadingFromFormMock.current) { - onLoadingChange?.(true); - } - }, [onLoadingChange]); - - return
; - }, -})); - -describe("TestConnectionStep", () => { - beforeEach(() => { - sessionStorage.clear(); - localStorage.clear(); - getProviderMock.mockReset(); - loadingFromFormMock.current = false; - useProviderWizardStore.getState().reset(); - }); - - it("stores provider secret id after loading provider data", async () => { - // Given - useProviderWizardStore.setState({ - providerId: "provider-1", - providerType: "aws", - mode: PROVIDER_WIZARD_MODE.ADD, - }); - getProviderMock.mockResolvedValue({ - data: { - id: "provider-1", - attributes: { - uid: "111111111111", - provider: "aws", - alias: "Production", - connection: { connected: false, last_checked_at: null }, - scanner_args: {}, - }, - relationships: { - secret: { data: { type: "provider-secrets", id: "secret-1" } }, - }, - }, - }); - - // When - render( - , - ); - - // Then - await waitFor(() => { - expect(screen.getByTestId("test-connection-form")).toBeInTheDocument(); - }); - expect(useProviderWizardStore.getState().secretId).toBe("secret-1"); - }); - - it("updates footer action label to checking while connection test is in progress", async () => { - // Given - loadingFromFormMock.current = true; - useProviderWizardStore.setState({ - providerId: "provider-1", - providerType: "gcp", - mode: PROVIDER_WIZARD_MODE.ADD, - }); - getProviderMock.mockResolvedValue({ - data: { - id: "provider-1", - attributes: { - uid: "project-123", - provider: "gcp", - alias: "Main", - connection: { connected: false, last_checked_at: null }, - scanner_args: {}, - }, - relationships: { - secret: { data: { type: "provider-secrets", id: "secret-1" } }, - }, - }, - }); - const onFooterChange = vi.fn(); - - // When - render( - , - ); - - // Then - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); - }); - - await waitFor(() => { - const footerConfigs = onFooterChange.mock.calls.map((call) => call[0]); - const hasCheckingState = footerConfigs.some( - (config) => - config.actionLabel === "Checking connection..." && - config.actionDisabled === true, - ); - expect(hasCheckingState).toBe(true); - }); - }); -}); diff --git a/ui/components/providers/wizard/steps/test-connection-step.tsx b/ui/components/providers/wizard/steps/test-connection-step.tsx deleted file mode 100644 index cc211c4f47..0000000000 --- a/ui/components/providers/wizard/steps/test-connection-step.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { Loader2 } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { getProvider } from "@/actions/providers"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; - -import { - TestConnectionForm, - TestConnectionProviderData, -} from "../../workflow/forms/test-connection-form"; - -import { - WIZARD_FOOTER_ACTION_TYPE, - WizardFooterConfig, -} from "./footer-controls"; - -interface TestConnectionStepProps { - onSuccess: () => void; - onResetCredentials: () => void; - onFooterChange: (config: WizardFooterConfig) => void; -} - -export function TestConnectionStep({ - onSuccess, - onResetCredentials, - onFooterChange, -}: TestConnectionStepProps) { - const { providerId, providerType, mode, setSecretId } = - useProviderWizardStore(); - const [providerData, setProviderData] = - useState(null); - const [isLoadingProvider, setIsLoadingProvider] = useState(true); - const [isFormLoading, setIsFormLoading] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - const formId = "provider-wizard-test-connection-form"; - - useEffect(() => { - let isMounted = true; - - async function loadProvider() { - if (!providerId || !providerType) { - setErrorMessage("Provider information is missing."); - setIsLoadingProvider(false); - return; - } - - setIsLoadingProvider(true); - setErrorMessage(null); - - const formData = new FormData(); - formData.append("id", providerId); - - const response = await getProvider(formData); - - if (!isMounted) { - return; - } - - if (response?.errors?.length) { - setErrorMessage( - response.errors[0]?.detail || "Failed to load provider.", - ); - setSecretId(null); - setProviderData(null); - setIsLoadingProvider(false); - return; - } - - const resolvedSecretId = - response?.data?.relationships?.secret?.data?.id ?? null; - setSecretId(resolvedSecretId); - setProviderData(response as TestConnectionProviderData); - setIsLoadingProvider(false); - } - - loadProvider(); - - return () => { - isMounted = false; - }; - }, [providerId, providerType, setSecretId]); - - useEffect(() => { - const canSubmit = !isLoadingProvider && !errorMessage && !!providerData; - - onFooterChange({ - showBack: true, - backLabel: "Back", - backDisabled: isFormLoading, - onBack: onResetCredentials, - showAction: canSubmit, - actionLabel: isFormLoading - ? "Checking connection..." - : "Check connection", - actionDisabled: isFormLoading, - actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, - actionFormId: formId, - }); - }, [ - errorMessage, - isFormLoading, - isLoadingProvider, - mode, - onFooterChange, - onResetCredentials, - providerData, - ]); - - if (isLoadingProvider) { - return ( -
- -
- ); - } - - if (errorMessage || !providerData || !providerId || !providerType) { - return ( -
-

- {errorMessage || "Unable to load provider details."} -

-
- ); - } - - return ( - - ); -} diff --git a/ui/components/providers/wizard/wizard-stepper.integration.test.tsx b/ui/components/providers/wizard/wizard-stepper.integration.test.tsx index 09318edd29..34817840da 100644 --- a/ui/components/providers/wizard/wizard-stepper.integration.test.tsx +++ b/ui/components/providers/wizard/wizard-stepper.integration.test.tsx @@ -3,13 +3,17 @@ import { describe, expect } from "vitest"; import { it } from "@/__tests__/fixtures"; import { render } from "@/__tests__/render-browser"; -import { WizardStepper } from "./wizard-stepper"; +import { + ORGANIZATION_WIZARD_STEPS, + PROVIDER_WIZARD_STEPS, + WizardStepper, +} from "./wizard-stepper"; // Like the modal frame: a marked size container, so lg: follows its width. function renderInWizardFrame(width: number) { return render(
- +
, ); } @@ -29,32 +33,27 @@ describe("wizard stepper", () => { .getByRole("navigation", { name: "Wizard progress" }) .element(); expect(boxOf(progress).height).toBeLessThanOrEqual(40); - await expect - .element(screen.getByText("Authenticate Credentials")) - .toBeVisible(); + await expect.element(screen.getByText("Select Accounts")).toBeVisible(); // Other steps keep their names for assistive tech but take no room. await expect.element( screen.getByRole("navigation", { name: "Wizard progress" }), ).toMatchAriaInlineSnapshot(` - navigation "Wizard progress": - - text: Link a Provider Authenticate Credentials Validate Connection Launch Scan + - text: Organization Access Select Accounts Launch Scan `); - const otherStep = screen.getByText("Link a Provider").element(); + const otherStep = screen.getByText("Organization Access").element(); expect(otherStep.checkVisibility()).toBe(true); // The block holding the name is clipped to 1px; the text inside overflows it. expect(boxOf(otherStep.closest("div")!).width).toBeLessThanOrEqual(1); await expect .element( screen.getByText( - "Authorize a secure connection between Prowler and your provider.", + "Prowler discovers the organization; pick what to connect.", ), ) .not.toBeVisible(); expect( - screen - .getByText("Authenticate Credentials") - .element() - .closest("[aria-current]"), + screen.getByText("Select Accounts").element().closest("[aria-current]"), ).toHaveAttribute("aria-current", "step"); }); @@ -66,15 +65,56 @@ describe("wizard stepper", () => { await expect .element( screen.getByText( - "Enter the provider details you would like to add in Prowler.", + "Name the organization and deploy the access Prowler uses in every account.", ), ) .toBeVisible(); - await expect.element(screen.getByText("Link a Provider")).toBeVisible(); + await expect.element(screen.getByText("Organization Access")).toBeVisible(); expect( boxOf( screen.getByRole("navigation", { name: "Wizard progress" }).element(), ).height, - ).toBeGreaterThan(300); + ).toBeGreaterThan(200); + }); + + it.each([360, 720])( + "names both steps of the provider flow on one row at %ipx while stacked", + async (width) => { + // When + const screen = await render( +
+ +
, + ); + + // Then + const progress = screen + .getByRole("navigation", { name: "Wizard progress" }) + .element(); + // A name clipped to 1px for assistive tech would still count as visible. + for (const name of ["Connect Provider", "Launch Scan"]) { + const block = screen.getByText(name).element().closest("div")!; + expect(boxOf(block).width, name).toBeGreaterThan(20); + } + expect(boxOf(progress).height).toBeLessThanOrEqual(40); + expect(progress.scrollWidth).toBeLessThanOrEqual(width); + }, + ); + + it("names the upcoming step beside a wide wizard", async () => { + // When + const screen = await render( +
+ +
, + ); + + // Then + const block = screen.getByText("Launch Scan").element().closest("div")!; + expect(boxOf(block).width).toBeGreaterThan(20); }); }); diff --git a/ui/components/providers/wizard/wizard-stepper.tsx b/ui/components/providers/wizard/wizard-stepper.tsx index 481efbdfc5..6b02309551 100644 --- a/ui/components/providers/wizard/wizard-stepper.tsx +++ b/ui/components/providers/wizard/wizard-stepper.tsx @@ -1,13 +1,13 @@ "use client"; -import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react"; +import { CircleCheckBig, KeyRound, Link2, Network } from "lucide-react"; import { Fragment, ReactElement } from "react"; import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons"; import { cn } from "@/lib/utils"; import { IconComponent, IconSvgProps } from "@/types/components"; -interface StepConfig { +export interface StepConfig { label: string; description: string; icon: IconComponent; @@ -15,58 +15,61 @@ interface StepConfig { interface WizardStepperProps { currentStep: number; - stepOffset?: number; - steps?: StepConfig[]; + steps: readonly StepConfig[]; } -const STEPS: StepConfig[] = [ +const LAUNCH_STEP: StepConfig = { + label: "Launch Scan", + description: "Scan newly connected resources.", + icon: ProwlerShort, +}; + +// Connect registers the account, stores its credentials and tests them at once. +export const PROVIDER_WIZARD_STEPS: readonly StepConfig[] = [ { - label: "Link a Provider", - description: "Enter the provider details you would like to add in Prowler.", - icon: FolderGit2, - }, - { - label: "Authenticate Credentials", + label: "Connect Provider", description: - "Authorize a secure connection between Prowler and your provider.", + "Choose how Prowler accesses it, add the details and test the connection.", + icon: Link2, + }, + LAUNCH_STEP, +]; + +// Opened on an existing account: only its credentials change. +export const UPDATE_PROVIDER_WIZARD_STEPS: readonly StepConfig[] = [ + { + label: "Update Credentials", + description: + "Replace the credentials Prowler uses and test the connection.", + icon: KeyRound, + }, +]; + +export const ORGANIZATION_WIZARD_STEPS: readonly StepConfig[] = [ + { + label: "Organization Access", + description: + "Name the organization and deploy the access Prowler uses in every account.", icon: KeyRound, }, { - label: "Validate Connection", - description: - "Review provider resources and test the connection to Prowler.", - icon: Rocket, - }, - { - label: "Launch Scan", - description: "Scan newly connected resources.", - icon: ProwlerShort, + label: "Select Accounts", + description: "Prowler discovers the organization; pick what to connect.", + icon: Network, }, + LAUNCH_STEP, ]; -export const PROVIDER_WIZARD_STEPS = STEPS; - -// AWS registers the account, stores its credentials and tests the connection in -// one step, so the wizard goes straight from CONNECT to LAUNCH. -export const AWS_PROVIDER_WIZARD_STEPS: StepConfig[] = [ - { - label: "Link a Provider", - description: - "Enter the account details and the credentials Prowler will use, then test the connection.", - icon: FolderGit2, - }, - STEPS[3], -]; - -/** The stepper row a wizard step lands on once its offset is applied. */ +/** The stepper row a wizard step lands on, kept inside the rows that exist. */ export function getActiveStepIndex( currentStep: number, - stepOffset: number, stepCount: number, ): number { - return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1)); + return Math.max(0, Math.min(currentStep, stepCount - 1)); } +const MAX_STEPS_NAMED_WHILE_STACKED = 2; + const CONNECTOR_DIRECTION = { VERTICAL: "to bottom", HORIZONTAL: "to right", @@ -74,18 +77,12 @@ const CONNECTOR_DIRECTION = { type ConnectorDirection = (typeof CONNECTOR_DIRECTION)[keyof typeof CONNECTOR_DIRECTION]; -export function WizardStepper({ - currentStep, - stepOffset = 0, - steps = STEPS, -}: WizardStepperProps) { - const activeVisualStep = getActiveStepIndex( - currentStep, - stepOffset, - steps.length, - ); +export function WizardStepper({ currentStep, steps }: WizardStepperProps) { + const activeVisualStep = getActiveStepIndex(currentStep, steps.length); + // Stacked, a short flow names every step; a longer one only the current. + const namesEveryStep = steps.length <= MAX_STEPS_NAMED_WHILE_STACKED; - // Below lg the wizard stacks: one row of steps, naming only the current one. + // Below lg the wizard stacks: one row of steps. return (