diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index ded143758ec..b5060877e2c 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Findings group resource filters now strip unsupported scan parameters, display scan name instead of provider alias in filter badges, migrate mute modal from HeroUI to shadcn, and add searchable accounts/provider type selectors [(#10662)](https://github.com/prowler-cloud/prowler/pull/10662) - Compliance detail page header now reflects the actual provider, alias and UID of the selected scan instead of always defaulting to AWS [(#10674)](https://github.com/prowler-cloud/prowler/pull/10674) +- Provider wizard modal moved to a stable page-level host so the providers table refreshes after link, authenticate, and connection check without closing the modal [(#10675)](https://github.com/prowler-cloud/prowler/pull/10675) --- @@ -40,10 +41,8 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Preserve query parameters in callbackUrl during invitation flow [(#10571)](https://github.com/prowler-cloud/prowler/pull/10571) -- Deleting the active organization now switches to the target org before deleting, preventing JWT rejection from the backend [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) -- Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446) -- Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) -- Exclude service filter from finding group resources endpoint to prevent empty results when a service filter is active [(#10652)](https://github.com/prowler-cloud/prowler/pull/10652) +- Attack Paths scan auto-refresh now correctly detects "available" (queued) scans as active [(#10476)](https://github.com/prowler-cloud/prowler/pull/10476) +- Attack Paths empty state not showing when no scans exist [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469) --- diff --git a/ui/actions/providers/providers.test.ts b/ui/actions/providers/providers.test.ts new file mode 100644 index 00000000000..ab3037db8bc --- /dev/null +++ b/ui/actions/providers/providers.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + getFormValueMock, + handleApiErrorMock, + handleApiResponseMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + getFormValueMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, + getFormValue: getFormValueMock, + wait: vi.fn(), +})); + +vi.mock("@/lib/provider-credentials/build-crendentials", () => ({ + buildSecretConfig: vi.fn(() => ({ + secretType: "access-secret-key", + secret: { key: "value" }, + })), +})); + +vi.mock("@/lib/provider-filters", () => ({ + appendSanitizedProviderInFilters: vi.fn(), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { + addCredentialsProvider, + addProvider, + checkConnectionProvider, + updateCredentialsProvider, +} from "./providers"; + +describe("providers actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + getFormValueMock.mockImplementation((formData: FormData, field: string) => + formData.get(field), + ); + handleApiErrorMock.mockReturnValue({ error: "Unexpected error" }); + handleApiResponseMock.mockResolvedValue({ data: { id: "secret-1" } }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { id: "secret-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("should revalidate providers after linking a cloud provider", async () => { + // Given + const formData = new FormData(); + formData.set("providerType", "aws"); + formData.set("providerUid", "111111111111"); + + // When + await addProvider(formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); + + it("should revalidate providers after adding credentials in the wizard", async () => { + // Given + const formData = new FormData(); + formData.set("providerId", "provider-1"); + formData.set("providerType", "aws"); + + // When + await addCredentialsProvider(formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); + + it("should revalidate providers after updating credentials in the wizard", async () => { + // Given + const formData = new FormData(); + formData.set("providerId", "provider-1"); + formData.set("providerType", "oraclecloud"); + + // When + await updateCredentialsProvider("secret-1", formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); + + it("should revalidate providers when checking connection from the wizard", async () => { + // Given + const formData = new FormData(); + formData.set("providerId", "provider-1"); + + // When + await checkConnectionProvider(formData); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/providers", + ); + }); +}); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 3520670c327..8cbdfb34593 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,11 +1,6 @@ import { Suspense } from "react"; -import { - AddProviderButton, - MutedFindingsConfigButton, - ProvidersAccountsTable, - ProvidersFilters, -} from "@/components/providers"; +import { ProvidersAccountsView } from "@/components/providers"; import { SkeletonTableProviders } from "@/components/providers/table"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ContentLayout } from "@/components/ui"; @@ -56,15 +51,6 @@ export default async function Providers({ ); } -const ProvidersActions = () => { - return ( -
- - -
- ); -}; - const ProvidersTableFallback = () => { return (
@@ -120,17 +106,12 @@ const ProvidersAccountsContent = async ({ }); return ( -
- } - /> - -
+ ); }; diff --git a/ui/components/providers/add-provider-button.tsx b/ui/components/providers/add-provider-button.tsx index 30d630c85ab..b296533cccb 100644 --- a/ui/components/providers/add-provider-button.tsx +++ b/ui/components/providers/add-provider-button.tsx @@ -5,13 +5,28 @@ import { useState } from "react"; import { ProviderWizardModal } from "@/components/providers/wizard"; import { Button } from "@/components/shadcn"; -export const AddProviderButton = () => { +interface AddProviderButtonProps { + onOpenWizard?: () => void; +} + +export const AddProviderButton = ({ onOpenWizard }: AddProviderButtonProps) => { const [open, setOpen] = useState(false); + const handleOpen = () => { + if (onOpenWizard) { + onOpenWizard(); + return; + } + + setOpen(true); + }; + return ( <> - - + + {!onOpenWizard && ( + + )} ); }; diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts index 71a18944eff..9184ceab2e7 100644 --- a/ui/components/providers/index.ts +++ b/ui/components/providers/index.ts @@ -5,6 +5,7 @@ export * from "./forms/delete-form"; export * from "./link-to-scans"; export * from "./muted-findings-config-button"; export * from "./providers-accounts-table"; +export * from "./providers-accounts-view"; export * from "./providers-filters"; export * from "./radio-card"; export * from "./radio-group-provider"; diff --git a/ui/components/providers/providers-accounts-table.tsx b/ui/components/providers/providers-accounts-table.tsx index 212fd475c95..0c25b609d9b 100644 --- a/ui/components/providers/providers-accounts-table.tsx +++ b/ui/components/providers/providers-accounts-table.tsx @@ -3,6 +3,10 @@ import { RowSelectionState } from "@tanstack/react-table"; import { useEffect, useState } from "react"; +import type { + OrgWizardInitialData, + ProviderWizardInitialData, +} from "@/components/providers/wizard/types"; import { DataTable } from "@/components/ui/table"; import { MetaDataProps } from "@/types"; import { @@ -16,6 +20,8 @@ interface ProvidersAccountsTableProps { isCloud: boolean; metadata?: MetaDataProps; rows: ProvidersTableRow[]; + onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; + onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; } function computeTestableProviderIds( @@ -48,6 +54,8 @@ export function ProvidersAccountsTable({ isCloud, metadata, rows, + onOpenProviderWizard, + onOpenOrganizationWizard, }: ProvidersAccountsTableProps) { const [rowSelection, setRowSelection] = useState({}); @@ -65,6 +73,8 @@ export function ProvidersAccountsTable({ rowSelection, testableProviderIds, clearSelection, + onOpenProviderWizard, + onOpenOrganizationWizard, ); return ( diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx new file mode 100644 index 00000000000..51329b55159 --- /dev/null +++ b/ui/components/providers/providers-accounts-view.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; + +import { AddProviderButton } from "@/components/providers/add-provider-button"; +import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; +import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table"; +import { ProvidersFilters } from "@/components/providers/providers-filters"; +import { ProviderWizardModal } from "@/components/providers/wizard"; +import type { + OrgWizardInitialData, + ProviderWizardInitialData, +} from "@/components/providers/wizard/types"; +import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; +import type { ProvidersTableRow } from "@/types/providers-table"; + +interface ProvidersAccountsViewProps { + isCloud: boolean; + filters: FilterOption[]; + metadata?: MetaDataProps; + providers: ProviderProps[]; + rows: ProvidersTableRow[]; +} + +export function ProvidersAccountsView({ + isCloud, + filters, + metadata, + providers, + rows, +}: ProvidersAccountsViewProps) { + const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); + const [providerWizardInitialData, setProviderWizardInitialData] = useState< + ProviderWizardInitialData | undefined + >(undefined); + const [orgWizardInitialData, setOrgWizardInitialData] = useState< + OrgWizardInitialData | undefined + >(undefined); + + const openProviderWizard = (initialData?: ProviderWizardInitialData) => { + setOrgWizardInitialData(undefined); + setProviderWizardInitialData(initialData); + setIsProviderWizardOpen(true); + }; + + const openOrganizationWizard = (initialData: OrgWizardInitialData) => { + setProviderWizardInitialData(undefined); + setOrgWizardInitialData(initialData); + setIsProviderWizardOpen(true); + }; + + const handleWizardOpenChange = (open: boolean) => { + setIsProviderWizardOpen(open); + + if (!open) { + setProviderWizardInitialData(undefined); + setOrgWizardInitialData(undefined); + } + }; + + return ( + <> + + + openProviderWizard()} /> + + } + /> + + + + ); +} diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 83c9a805896..1203234a317 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -9,6 +9,10 @@ import { ShieldOff, } from "lucide-react"; +import type { + OrgWizardInitialData, + ProviderWizardInitialData, +} from "@/components/providers/wizard/types"; import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { DateWithTime, EntityInfo } from "@/components/ui/entities"; @@ -108,6 +112,8 @@ export function getColumnProviders( rowSelection: RowSelectionState, testableProviderIds: string[], onClearSelection: () => void, + onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void, + onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void, ): ColumnDef[] { return [ { @@ -320,6 +326,8 @@ export function getColumnProviders( isRowSelected={row.getIsSelected()} testableProviderIds={testableProviderIds} onClearSelection={onClearSelection} + onOpenProviderWizard={onOpenProviderWizard} + onOpenOrganizationWizard={onOpenOrganizationWizard} /> ); }, diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index 21feaf7e4e4..d72874af31b 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -3,9 +3,11 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; import { PROVIDERS_GROUP_KIND, PROVIDERS_ROW_TYPE, + ProvidersTableRow, } from "@/types/providers-table"; const checkConnectionProviderMock = vi.hoisted(() => vi.fn()); @@ -18,10 +20,6 @@ vi.mock("@/actions/providers/providers", () => ({ checkConnectionProvider: checkConnectionProviderMock, })); -vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: () => null, -})); - vi.mock("../forms/delete-form", () => ({ DeleteForm: () => null, })); @@ -44,7 +42,7 @@ vi.mock("@/lib/provider-helpers", () => ({ import { DataTableRowActions } from "./data-table-row-actions"; -const createRow = () => +const createRow = (hasSecret = false) => ({ original: { id: "provider-1", @@ -74,7 +72,7 @@ const createRow = () => }, relationships: { secret: { - data: null, + data: hasSecret ? { id: "secret-1", type: "secrets" } : null, }, provider_groups: { meta: { @@ -85,7 +83,7 @@ const createRow = () => }, groupNames: [], }, - }) as Row; + }) as unknown as Row; const createOrgRow = () => ({ @@ -117,7 +115,7 @@ const createOrgRow = () => }, ], }, - }) as Row; + }) as unknown as Row; const createOuRow = () => ({ @@ -142,19 +140,21 @@ const createOuRow = () => }, ], }, - }) as Row; + }) as unknown as Row; describe("DataTableRowActions", () => { - it("renders the exact phase 1 menu actions for provider rows", async () => { + it("renders Add Credentials for provider rows without credentials", async () => { // Given const user = userEvent.setup(); render( , ); @@ -163,9 +163,32 @@ describe("DataTableRowActions", () => { // Then expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument(); - expect(screen.getByText("Update Credentials")).toBeInTheDocument(); + expect(screen.getByText("Add Credentials")).toBeInTheDocument(); expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("Delete Provider")).toBeInTheDocument(); + expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); + }); + + it("renders Update Credentials for provider rows with credentials", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.getByText("Update Credentials")).toBeInTheDocument(); expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); }); @@ -178,6 +201,8 @@ describe("DataTableRowActions", () => { isRowSelected={false} testableProviderIds={[]} onClearSelection={vi.fn()} + onOpenProviderWizard={vi.fn()} + onOpenOrganizationWizard={vi.fn()} />, ); @@ -199,6 +224,8 @@ describe("DataTableRowActions", () => { isRowSelected={false} testableProviderIds={[]} onClearSelection={vi.fn()} + onOpenProviderWizard={vi.fn()} + onOpenOrganizationWizard={vi.fn()} />, ); @@ -220,6 +247,8 @@ describe("DataTableRowActions", () => { isRowSelected={false} testableProviderIds={[]} onClearSelection={vi.fn()} + onOpenProviderWizard={vi.fn()} + onOpenOrganizationWizard={vi.fn()} />, ); @@ -238,6 +267,8 @@ describe("DataTableRowActions", () => { isRowSelected={false} testableProviderIds={["provider-child-1", "provider-standalone"]} onClearSelection={vi.fn()} + onOpenProviderWizard={vi.fn()} + onOpenOrganizationWizard={vi.fn()} />, ); @@ -257,6 +288,8 @@ describe("DataTableRowActions", () => { isRowSelected={false} testableProviderIds={["provider-ou-child-1", "provider-standalone"]} onClearSelection={vi.fn()} + onOpenProviderWizard={vi.fn()} + onOpenOrganizationWizard={vi.fn()} />, ); @@ -276,6 +309,8 @@ describe("DataTableRowActions", () => { isRowSelected={false} testableProviderIds={[]} onClearSelection={vi.fn()} + onOpenProviderWizard={vi.fn()} + onOpenOrganizationWizard={vi.fn()} />, ); @@ -286,4 +321,68 @@ describe("DataTableRowActions", () => { ).not.toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); + + it("opens the shared provider wizard when provider credentials action is selected", async () => { + // Given + const user = userEvent.setup(); + const onOpenProviderWizard = vi.fn(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Update Credentials")); + + // Then + expect(onOpenProviderWizard).toHaveBeenCalledWith({ + providerId: "provider-1", + providerType: "aws", + providerUid: "111111111111", + providerAlias: "AWS App Account", + secretId: "secret-1", + mode: "update", + }); + }); + + it("opens the shared organization wizard when org credentials action is selected", async () => { + // Given + const user = userEvent.setup(); + const onOpenOrganizationWizard = vi.fn(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Update Credentials")); + + // Then + expect(onOpenOrganizationWizard).toHaveBeenCalledWith({ + organizationId: "org-1", + organizationName: "My AWS Organization", + externalId: "o-abc123def4", + targetStep: ORG_WIZARD_STEP.SETUP, + targetPhase: ORG_SETUP_PHASE.ACCESS, + intent: "edit-credentials", + }); + }); }); diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 65d7901f8e4..9b531f272a6 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -6,10 +6,10 @@ import { useState } from "react"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { updateProvider } from "@/actions/providers"; -import { ProviderWizardModal } from "@/components/providers/wizard"; import { ORG_WIZARD_INTENT, OrgWizardInitialData, + ProviderWizardInitialData, } from "@/components/providers/wizard/types"; import { ActionDropdown, @@ -44,6 +44,8 @@ interface DataTableRowActionsProps { testableProviderIds: string[]; /** Callback to clear the row selection after bulk operation */ onClearSelection: () => void; + onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; + onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; } function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] { @@ -69,6 +71,7 @@ interface OrgGroupDropdownActionsProps { onClearSelection: () => void; onBulkTest: (ids: string[]) => Promise; onTestChildConnections: () => Promise; + onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; } function OrgGroupDropdownActions({ @@ -80,12 +83,10 @@ function OrgGroupDropdownActions({ onClearSelection, onBulkTest, onTestChildConnections, + onOpenOrganizationWizard, }: OrgGroupDropdownActionsProps) { const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false); const [isEditNameOpen, setIsEditNameOpen] = useState(false); - const [isOrgWizardOpen, setIsOrgWizardOpen] = useState(false); - const [orgWizardData, setOrgWizardData] = - useState(null); const isOrgKind = rowData.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION; const testIds = hasSelection ? testableProviderIds : childTestableIds; @@ -97,7 +98,7 @@ function OrgGroupDropdownActions({ targetPhase: OrgWizardInitialData["targetPhase"], intent?: OrgWizardInitialData["intent"], ) => { - setOrgWizardData({ + onOpenOrganizationWizard({ organizationId: rowData.id, organizationName: rowData.name, externalId: rowData.externalId ?? "", @@ -105,33 +106,25 @@ function OrgGroupDropdownActions({ targetPhase, intent, }); - setIsOrgWizardOpen(true); }; return ( <> {isOrgKind && ( - <> - - updateOrganizationName(rowData.id, name)} - /> - - + updateOrganizationName(rowData.id, name)} /> - + )} ); } @@ -369,21 +364,6 @@ export function DataTableRowActions({ )} - -
} - label="Update Credentials" - onSelect={() => setIsWizardOpen(true)} + label={hasSecret ? "Update Credentials" : "Add Credentials"} + onSelect={() => + onOpenProviderWizard({ + providerId, + providerType, + providerUid, + providerAlias, + secretId: providerSecretId, + mode: providerSecretId + ? PROVIDER_WIZARD_MODE.UPDATE + : PROVIDER_WIZARD_MODE.ADD, + }) + } /> } 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 9cb55dfb872..187c1e02519 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 @@ -9,8 +9,19 @@ import { PROVIDER_WIZARD_STEP, } from "@/types/provider-wizard"; +import type { ProviderWizardInitialData } from "../types"; import { useProviderWizardController } from "./use-provider-wizard-controller"; +const { refreshMock } = vi.hoisted(() => ({ + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + vi.mock("next-auth/react", () => ({ useSession: () => ({ data: null, @@ -21,12 +32,33 @@ vi.mock("next-auth/react", () => ({ describe("useProviderWizardController", () => { beforeEach(() => { vi.useRealTimers(); + vi.clearAllMocks(); sessionStorage.clear(); localStorage.clear(); useProviderWizardStore.getState().reset(); useOrgSetupStore.getState().reset(); }); + it("refreshes providers data when the wizard closes", () => { + // Given + const onOpenChange = vi.fn(); + const { result } = renderHook(() => + useProviderWizardController({ + open: true, + onOpenChange, + }), + ); + + // When + act(() => { + result.current.handleClose(); + }); + + // Then + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(refreshMock).toHaveBeenCalledTimes(1); + }); + it("hydrates update mode when initial data is provided", async () => { // Given const onOpenChange = vi.fn(); @@ -121,7 +153,7 @@ describe("useProviderWizardController", () => { expect(onOpenChange).not.toHaveBeenCalled(); }); - it("closes the modal after a successful connection test in update mode", async () => { + it("moves to launch step after a successful connection test in update mode", async () => { // Given const onOpenChange = vi.fn(); const { result } = renderHook(() => @@ -149,8 +181,9 @@ describe("useProviderWizardController", () => { result.current.handleTestSuccess(); }); - // Then — update mode should close the modal, not advance to launch - expect(onOpenChange).toHaveBeenCalledWith(false); + // Then + expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH); + expect(onOpenChange).not.toHaveBeenCalled(); }); it("does not override launch footer config in the controller", () => { @@ -215,14 +248,7 @@ describe("useProviderWizardController", () => { initialData, }: { open: boolean; - initialData?: { - providerId: string; - providerType: "gcp"; - providerUid: string; - providerAlias: string; - secretId: string | null; - mode: "add" | "update"; - }; + initialData?: ProviderWizardInitialData; }) => useProviderWizardController({ open, 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 111427131e4..21fce019aa5 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls"; @@ -57,6 +58,7 @@ export function useProviderWizardController({ initialData, orgInitialData, }: UseProviderWizardControllerProps) { + const router = useRouter(); const initialProviderId = initialData?.providerId ?? null; const initialProviderType = initialData?.providerType ?? null; const initialProviderUid = initialData?.providerUid ?? null; @@ -183,6 +185,7 @@ export function useProviderWizardController({ setProviderTypeHint(null); setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); onOpenChange(false); + router.refresh(); }; const handleDialogOpenChange = (nextOpen: boolean) => { @@ -194,10 +197,6 @@ export function useProviderWizardController({ }; const handleTestSuccess = () => { - if (mode === PROVIDER_WIZARD_MODE.UPDATE) { - handleClose(); - return; - } setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); };