Compare commits

...

6 Commits

Author SHA1 Message Date
Alan Buscaglia
0669e608b4 fix(ui): resolve changelog merge conflict with master 2026-04-14 16:38:48 +02:00
Alan Buscaglia
7b83be9d4f fix(ui): move changelog entry to unreleased 1.24.0 2026-04-14 16:36:09 +02:00
Alan Buscaglia
a120c91f1d fix(ui): keep provider wizard open during refreshes
- move the providers wizard to a stable page-level host
- refresh providers data after link, authenticate, and connection checks
- update provider actions and tests for the shared wizard flow
2026-04-14 12:50:17 +02:00
Alan Buscaglia
bc94b46cab fix(ui): keep provider wizard open after connection test
- send update flows to the launch step after a successful test
- avoid providers revalidation during connection checks inside the wizard
- cover the launch transition with regression tests
2026-04-13 21:31:28 +02:00
Alan Buscaglia
b7b6077f6f fix(ui): add changelog, inline comment, and checkConnection test
- add changelog entry for update credentials wizard fix
- add inline comment explaining why revalidation is omitted
- add test verifying checkConnectionProvider still revalidates
2026-04-13 21:17:47 +02:00
Alan Buscaglia
257495ef79 fix(ui): keep update credentials wizard open
- avoid providers revalidation during credential updates
- add regression coverage for add and update credential actions
2026-04-13 21:06:33 +02:00
12 changed files with 458 additions and 106 deletions

View File

@@ -2,11 +2,12 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.23.1] (Prowler UNRELEASED)
## [1.24.0] (Prowler UNRELEASED)
### 🐞 Fixed
- 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)
---
@@ -35,10 +36,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)
---

View File

@@ -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",
);
});
});

View File

@@ -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 (
<div className="flex flex-wrap gap-4 md:justify-end">
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
);
};
const ProvidersTableFallback = () => {
return (
<div className="flex flex-col gap-6">
@@ -120,17 +106,12 @@ const ProvidersAccountsContent = async ({
});
return (
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={providersView.filters}
providers={providersView.providers}
actions={<ProvidersActions />}
/>
<ProvidersAccountsTable
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
metadata={providersView.metadata}
rows={providersView.rows}
/>
</div>
<ProvidersAccountsView
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
filters={providersView.filters}
providers={providersView.providers}
metadata={providersView.metadata}
rows={providersView.rows}
/>
);
};

View File

@@ -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 (
<>
<Button onClick={() => setOpen(true)}>Add Provider</Button>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
<Button onClick={handleOpen}>Add Provider</Button>
{!onOpenWizard && (
<ProviderWizardModal open={open} onOpenChange={setOpen} />
)}
</>
);
};

View File

@@ -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";

View File

@@ -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<RowSelectionState>({});
@@ -65,6 +73,8 @@ export function ProvidersAccountsTable({
rowSelection,
testableProviderIds,
clearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
);
return (

View File

@@ -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 (
<>
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
/>
</>
);
}

View File

@@ -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<ProvidersTableRow>[] {
return [
{
@@ -320,6 +326,8 @@ export function getColumnProviders(
isRowSelected={row.getIsSelected()}
testableProviderIds={testableProviderIds}
onClearSelection={onClearSelection}
onOpenProviderWizard={onOpenProviderWizard}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>
);
},

View File

@@ -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<any>;
}) as unknown as Row<ProvidersTableRow>;
const createOrgRow = () =>
({
@@ -117,7 +115,7 @@ const createOrgRow = () =>
},
],
},
}) as Row<any>;
}) as unknown as Row<ProvidersTableRow>;
const createOuRow = () =>
({
@@ -142,19 +140,21 @@ const createOuRow = () =>
},
],
},
}) as Row<any>;
}) as unknown as Row<ProvidersTableRow>;
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(
<DataTableRowActions
row={createRow()}
row={createRow(false)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
@@ -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(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// 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(
<DataTableRowActions
row={createRow(true)}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={onOpenProviderWizard}
onOpenOrganizationWizard={vi.fn()}
/>,
);
// 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(
<DataTableRowActions
row={createOrgRow()}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
onOpenProviderWizard={vi.fn()}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>,
);
// 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",
});
});
});

View File

@@ -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<void>;
onTestChildConnections: () => Promise<void>;
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<OrgWizardInitialData | null>(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 && (
<>
<Modal
open={isEditNameOpen}
onOpenChange={setIsEditNameOpen}
title="Edit Organization Name"
>
<EditNameForm
currentValue={rowData.name}
label="Name"
successMessage="The organization name was updated successfully."
helperText="If left blank, Prowler will use the name stored in AWS."
setIsOpen={setIsEditNameOpen}
onSave={(name) => updateOrganizationName(rowData.id, name)}
/>
</Modal>
<ProviderWizardModal
open={isOrgWizardOpen}
onOpenChange={setIsOrgWizardOpen}
orgInitialData={orgWizardData ?? undefined}
<Modal
open={isEditNameOpen}
onOpenChange={setIsEditNameOpen}
title="Edit Organization Name"
>
<EditNameForm
currentValue={rowData.name}
label="Name"
successMessage="The organization name was updated successfully."
helperText="If left blank, Prowler will use the name stored in AWS."
setIsOpen={setIsEditNameOpen}
onSave={(name) => updateOrganizationName(rowData.id, name)}
/>
</>
</Modal>
)}
<Modal
open={isDeleteOrgOpen}
@@ -205,10 +198,11 @@ export function DataTableRowActions({
isRowSelected,
testableProviderIds,
onClearSelection,
onOpenProviderWizard,
onOpenOrganizationWizard,
}: DataTableRowActionsProps) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
@@ -323,6 +317,7 @@ export function DataTableRowActions({
onClearSelection={onClearSelection}
onBulkTest={handleBulkTest}
onTestChildConnections={handleTestChildConnections}
onOpenOrganizationWizard={onOpenOrganizationWizard}
/>
);
}
@@ -369,21 +364,6 @@ export function DataTableRowActions({
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
)}
</Modal>
<ProviderWizardModal
open={isWizardOpen}
onOpenChange={setIsWizardOpen}
initialData={{
providerId,
providerType,
providerUid,
providerAlias,
secretId: providerSecretId,
mode: providerSecretId
? PROVIDER_WIZARD_MODE.UPDATE
: PROVIDER_WIZARD_MODE.ADD,
}}
/>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
<ActionDropdownItem
@@ -393,8 +373,19 @@ export function DataTableRowActions({
/>
<ActionDropdownItem
icon={<KeyRound />}
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,
})
}
/>
<ActionDropdownItem
icon={<Rocket />}

View File

@@ -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,

View File

@@ -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);
};