From db7554c8fbc1c5c712b7d191f25c775c029a0d84 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:13:28 +0100 Subject: [PATCH] feat(ui): redesign providers page with modern table and cloud recursion (#10292) --- ui/CHANGELOG.md | 5 +- ui/actions/manage-groups/manage-groups.ts | 15 +- .../organizations/organizations.test.ts | 66 ++ ui/actions/organizations/organizations.ts | 74 +- .../_components/provider-type-selector.tsx | 6 +- ui/app/(prowler)/manage-groups/page.tsx | 191 +---- .../providers/account-groups-content.tsx | 162 ++++ ui/app/(prowler)/providers/page.test.ts | 22 + ui/app/(prowler)/providers/page.tsx | 155 ++-- .../providers/provider-page-tabs.shared.ts | 21 + .../providers/provider-page-tabs.test.tsx | 83 ++ .../providers/provider-page-tabs.tsx | 59 ++ .../providers/providers-page.utils.test.ts | 806 ++++++++++++++++++ .../providers/providers-page.utils.ts | 632 ++++++++++++++ .../providers-badge/iac-provider-badge.tsx | 33 +- .../manage-groups/forms/delete-group-form.tsx | 2 +- .../manage-groups/forms/edit-group-form.tsx | 4 +- .../manage-groups/manage-groups-button.tsx | 2 +- .../table/data-table-row-actions.tsx | 8 +- .../providers/add-provider-button.tsx | 7 +- ui/components/providers/index.ts | 2 + ui/components/providers/link-to-scans.tsx | 18 +- ui/components/providers/provider-info.tsx | 54 +- .../providers/providers-accounts-table.tsx | 84 ++ ui/components/providers/providers-filters.tsx | 153 ++++ .../providers/table/column-providers.tsx | 466 +++++++--- .../table/data-table-row-actions.test.tsx | 103 +++ .../table/data-table-row-actions.tsx | 183 +++- .../table/skeleton-table-provider.tsx | 151 +++- .../wizard/steps/credentials-step.test.tsx | 13 + .../workflow/forms/test-connection-form.tsx | 83 +- ui/components/shadcn/select/multiselect.tsx | 2 +- .../ui/table/data-table-animated-row.tsx | 19 +- .../ui/table/data-table-expand-all-toggle.tsx | 2 +- .../ui/table/data-table-expand-toggle.tsx | 25 +- .../ui/table/data-table-expandable-cell.tsx | 33 +- ui/components/ui/table/data-table.tsx | 7 +- ui/lib/helper-filters.ts | 14 +- ui/lib/menu-list.ts | 2 - ui/lib/provider-helpers.ts | 53 ++ ui/tests/providers/providers-page.ts | 4 +- ui/types/filters.ts | 9 +- ui/types/index.ts | 1 + ui/types/organizations.ts | 50 ++ ui/types/providers-table.ts | 98 +++ ui/types/providers.ts | 5 + 46 files changed, 3361 insertions(+), 626 deletions(-) create mode 100644 ui/app/(prowler)/providers/account-groups-content.tsx create mode 100644 ui/app/(prowler)/providers/provider-page-tabs.shared.ts create mode 100644 ui/app/(prowler)/providers/provider-page-tabs.test.tsx create mode 100644 ui/app/(prowler)/providers/provider-page-tabs.tsx create mode 100644 ui/app/(prowler)/providers/providers-page.utils.test.ts create mode 100644 ui/app/(prowler)/providers/providers-page.utils.ts create mode 100644 ui/components/providers/providers-accounts-table.tsx create mode 100644 ui/components/providers/providers-filters.tsx create mode 100644 ui/components/providers/table/data-table-row-actions.test.tsx create mode 100644 ui/types/providers-table.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index bff954f997..564b77e6a9 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,14 +8,15 @@ All notable changes to the **Prowler UI** are documented in this file. - Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors and layout changes. [(#10249)](https://github.com/prowler-cloud/prowler/pull/10249) - Refactor simple providers with new components and styles.[(#10259)](https://github.com/prowler-cloud/prowler/pull/10259) +- Providers page redesigned with cloud organization hierarchy, HeroUI-to-shadcn migration, organization and account group filters, and row selection for bulk actions [(#10292)](https://github.com/prowler-cloud/prowler/pull/10292) - AWS Organizations onboarding now uses a clearer 3-step flow: deploy the ProwlerScan role in the management account via CloudFormation Stack, deploy to member accounts via StackSet with a copyable template URL, and confirm with the Role ARN [(#10274)](https://github.com/prowler-cloud/prowler/pull/10274) ---- - ### 🔐 Security - npm transitive dependencies patched to resolve 11 Dependabot alerts (6 HIGH, 4 MEDIUM, 1 LOW): hono, @hono/node-server, fast-xml-parser, serialize-javascript, minimatch [(#10267)](https://github.com/prowler-cloud/prowler/pull/10267) +--- + ## [1.19.1] (Prowler v5.19.1 UNRELEASED) ### 🐞 Fixed diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 8899029668..8758b66f68 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -22,7 +22,8 @@ export const getProviderGroups = async ({ }): Promise => { const headers = await getAuthHeaders({ contentType: false }); - if (isNaN(Number(page)) || page < 1) redirect("/manage-groups"); + if (isNaN(Number(page)) || page < 1) + redirect("/providers?tab=account-groups"); const url = new URL(`${apiBaseUrl}/provider-groups`); @@ -43,7 +44,7 @@ export const getProviderGroups = async ({ headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching provider groups:", error); return undefined; @@ -60,7 +61,7 @@ export const getProviderGroupInfoById = async (providerGroupId: string) => { headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { handleApiError(error); } @@ -111,7 +112,7 @@ export const createProviderGroup = async (formData: FormData) => { body, }); - return handleApiResponse(response, "/manage-groups"); + return await handleApiResponse(response, "/providers?tab=account-groups"); } catch (error) { handleApiError(error); } @@ -156,7 +157,7 @@ export const updateProviderGroup = async ( body: JSON.stringify(payload), }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { handleApiError(error); } @@ -168,7 +169,7 @@ export const deleteProviderGroup = async (formData: FormData) => { if (!providerGroupId) { return { - errors: [{ detail: "Provider Group ID is required." }], + errors: [{ detail: "Account Group ID is required." }], }; } @@ -196,7 +197,7 @@ export const deleteProviderGroup = async (formData: FormData) => { data = await response.json(); } - revalidatePath("/manage-groups"); + revalidatePath("/providers"); return data || { success: true }; } catch (error) { console.error("Error deleting provider group:", error); diff --git a/ui/actions/organizations/organizations.test.ts b/ui/actions/organizations/organizations.test.ts index 52a4b82eb3..be1eb6c404 100644 --- a/ui/actions/organizations/organizations.test.ts +++ b/ui/actions/organizations/organizations.test.ts @@ -31,6 +31,10 @@ vi.mock("@/lib/server-actions-helper", () => ({ import { applyDiscovery, getDiscovery, + listOrganizations, + listOrganizationsSafe, + listOrganizationUnits, + listOrganizationUnitsSafe, triggerDiscovery, updateOrganizationSecret, } from "./organizations"; @@ -137,4 +141,66 @@ describe("organizations actions", () => { expect(revalidatePathMock).toHaveBeenCalledTimes(1); expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); }); + + it("lists organizations with the expected filters", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await listOrganizations(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://api.example.com/api/v1/organizations?filter%5Borg_type%5D=aws", + ); + }); + + it("lists organization units from the dedicated endpoint", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await listOrganizationUnits(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://api.example.com/api/v1/organizational-units", + ); + }); + + it("returns an empty organizations payload when the safe organizations request fails", async () => { + // Given + fetchMock.mockResolvedValue( + new Response("Internal Server Error", { + status: 500, + }), + ); + + // When + const result = await listOrganizationsSafe(); + + // Then + expect(result).toEqual({ data: [] }); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + expect(handleApiErrorMock).not.toHaveBeenCalled(); + }); + + it("returns an empty organization units payload when the safe request fails", async () => { + // Given + fetchMock.mockResolvedValue( + new Response("Internal Server Error", { + status: 500, + }), + ); + + // When + const result = await listOrganizationUnitsSafe(); + + // Then + expect(result).toEqual({ data: [] }); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + expect(handleApiErrorMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/actions/organizations/organizations.ts b/ui/actions/organizations/organizations.ts index 16ddd7593d..6d0d0e6afd 100644 --- a/ui/actions/organizations/organizations.ts +++ b/ui/actions/organizations/organizations.ts @@ -4,6 +4,10 @@ import { revalidatePath } from "next/cache"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { + OrganizationListResponse, + OrganizationUnitListResponse, +} from "@/types"; const PATH_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/; @@ -37,6 +41,24 @@ function hasActionError(result: unknown): result is { error: unknown } { ); } +async function fetchOptionalCollection( + url: URL, +): Promise { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + return { data: [] } as unknown as T; + } + + return (await handleApiResponse(response)) as T; + } catch { + return { data: [] } as unknown as T; + } +} + /** * Creates an AWS Organization resource. * POST /api/v1/organizations @@ -82,12 +104,62 @@ export const listOrganizationsByExternalId = async (externalId: string) => { try { const response = await fetch(url.toString(), { headers }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { return handleApiError(error); } }; +/** + * Lists AWS organizations available for the current tenant. + * GET /api/v1/organizations?filter[org_type]=aws + */ +export const listOrganizations = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/organizations`); + url.searchParams.set("filter[org_type]", "aws"); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const listOrganizationsSafe = + async (): Promise => { + const url = new URL(`${apiBaseUrl}/organizations`); + url.searchParams.set("filter[org_type]", "aws"); + url.searchParams.set("page[size]", "100"); + + return fetchOptionalCollection(url); + }; + +/** + * Lists organization units available for the current tenant. + * GET /api/v1/organizational-units + */ +export const listOrganizationUnits = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/organizational-units`); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const listOrganizationUnitsSafe = + async (): Promise => { + const url = new URL(`${apiBaseUrl}/organizational-units`); + url.searchParams.set("page[size]", "100"); + + return fetchOptionalCollection(url); + }; + /** * Creates an organization secret (role-based credentials). * POST /api/v1/organization-secrets diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index a1aaeaa6e5..881bc54969 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -186,14 +186,14 @@ export const ProviderTypeSelector = ({ if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - + {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + {PROVIDER_DATA[providerType].label} ); } return ( - + {selectedTypes.length} providers selected ); diff --git a/ui/app/(prowler)/manage-groups/page.tsx b/ui/app/(prowler)/manage-groups/page.tsx index dc035c014e..8eb286f77a 100644 --- a/ui/app/(prowler)/manage-groups/page.tsx +++ b/ui/app/(prowler)/manage-groups/page.tsx @@ -1,20 +1,6 @@ -import { Divider } from "@heroui/divider"; -import { Spacer } from "@heroui/spacer"; import { redirect } from "next/navigation"; -import React, { Suspense } from "react"; -import { - getProviderGroupInfoById, - getProviderGroups, -} from "@/actions/manage-groups/manage-groups"; -import { getProviders } from "@/actions/providers"; -import { getRoles } from "@/actions/roles"; -import { FilterControls } from "@/components/filters/filter-controls"; -import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms"; -import { SkeletonManageGroups } from "@/components/manage-groups/skeleton-manage-groups"; -import { ColumnGroups } from "@/components/manage-groups/table"; -import { DataTable } from "@/components/ui/table"; -import { ProviderProps, Role, SearchParamsProps } from "@/types"; +import { SearchParamsProps } from "@/types"; export default async function ManageGroupsPage({ searchParams, @@ -22,176 +8,11 @@ export default async function ManageGroupsPage({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const searchParamsKey = JSON.stringify(resolvedSearchParams); - const providerGroupId = resolvedSearchParams.groupId; + const groupId = resolvedSearchParams.groupId; - return ( -
-
- }> - {providerGroupId ? ( - - ) : ( -
-

- Create a new provider group -

-

- Create a new provider group to manage the providers and roles. -

- -
- )} -
-
+ const target = groupId + ? `/providers?tab=account-groups&groupId=${groupId}` + : "/providers?tab=account-groups"; - - -
- - -

Provider Groups

- }> - - -
-
- ); + redirect(target); } - -const SSRAddGroupForm = async () => { - const providersResponse = await getProviders({ pageSize: 50 }); - const rolesResponse = await getRoles({}); - - const providersData = - providersResponse?.data?.map((provider: ProviderProps) => ({ - id: provider.id, - name: provider.attributes.alias || provider.attributes.uid, - })) || []; - - const rolesData = - rolesResponse?.data?.map((role: Role) => ({ - id: role.id, - name: role.attributes.name, - })) || []; - - return ; -}; - -const SSRDataEditGroup = async ({ - searchParams, -}: { - searchParams: SearchParamsProps; -}) => { - const providerGroupId = searchParams.groupId; - - // Redirect if no group ID is provided or if the parameter is invalid - if (!providerGroupId || Array.isArray(providerGroupId)) { - redirect("/manage-groups"); - } - - // Fetch the provider group details - const providerGroupData = await getProviderGroupInfoById(providerGroupId); - - if (!providerGroupData || providerGroupData.error) { - return
Provider group not found
; - } - - const providersResponse = await getProviders({ pageSize: 50 }); - const rolesResponse = await getRoles({}); - - const providersList = - providersResponse?.data?.map((provider: ProviderProps) => ({ - id: provider.id, - name: provider.attributes.alias || provider.attributes.uid, - })) || []; - - const rolesList = - rolesResponse?.data?.map((role: Role) => ({ - id: role.id, - name: role.attributes.name, - })) || []; - - const { attributes, relationships } = providerGroupData.data; - - const associatedProviders = relationships.providers?.data.map( - (provider: ProviderProps) => { - const matchingProvider = providersList.find( - (p: { id: string; name: string }) => p.id === provider.id, - ); - return { - id: provider.id, - name: matchingProvider?.name || "Unavailable for your role", - }; - }, - ); - - const associatedRoles = relationships.roles?.data.map((role: Role) => { - const matchingRole = rolesList.find((r: Role) => r.id === role.id); - return { - id: role.id, - name: matchingRole?.name || "Unavailable for your role", - }; - }); - - const formData = { - name: attributes.name, - providers: associatedProviders, - roles: associatedRoles, - }; - - return ( -
-

- Edit provider group -

-

- Edit the provider group to manage the providers and roles. -

- -
- ); -}; - -const SSRDataTable = async ({ - searchParams, -}: { - searchParams: SearchParamsProps; -}) => { - const page = parseInt(searchParams.page?.toString() || "1", 10); - const sort = searchParams.sort?.toString(); - const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); - - // Convert filters to the correct type - const filters: Record = {}; - Object.entries(searchParams) - .filter(([key]) => key.startsWith("filter[")) - .forEach(([key, value]) => { - filters[key] = value?.toString() || ""; - }); - - const query = (filters["filter[search]"] as string) || ""; - const providerGroupsData = await getProviderGroups({ - query, - page, - sort, - filters, - pageSize, - }); - - return ( - <> - - - ); -}; diff --git a/ui/app/(prowler)/providers/account-groups-content.tsx b/ui/app/(prowler)/providers/account-groups-content.tsx new file mode 100644 index 0000000000..f171f521f9 --- /dev/null +++ b/ui/app/(prowler)/providers/account-groups-content.tsx @@ -0,0 +1,162 @@ +import { + getProviderGroupInfoById, + getProviderGroups, +} from "@/actions/manage-groups/manage-groups"; +import { getProviders } from "@/actions/providers"; +import { getRoles } from "@/actions/roles"; +import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms"; +import { ColumnGroups } from "@/components/manage-groups/table"; +import { DataTable } from "@/components/ui/table"; +import { ProviderProps, Role, SearchParamsProps } from "@/types"; + +export const AccountGroupsContent = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const providerGroupId = searchParams.groupId; + + // Fetch all data in parallel + const [providersResponse, rolesResponse, providerGroupsData, editGroupData] = + await Promise.all([ + getProviders({ pageSize: 50 }), + getRoles({}), + fetchGroupsTableData(searchParams), + providerGroupId && !Array.isArray(providerGroupId) + ? getProviderGroupInfoById(providerGroupId) + : Promise.resolve(null), + ]); + + const providersList = + providersResponse?.data?.map((provider: ProviderProps) => ({ + id: provider.id, + name: provider.attributes.alias || provider.attributes.uid, + })) || []; + + const rolesList = + rolesResponse?.data?.map((role: Role) => ({ + id: role.id, + name: role.attributes.name, + })) || []; + + return ( +
+ {/* Left: Form (Add or Edit) */} +
+ {providerGroupId && editGroupData?.data ? ( + + ) : ( +
+

+ Create a new account group +

+

+ Create a new account group to manage the providers and roles. +

+ +
+ )} +
+ + {/* Divider */} +
+
+
+ + {/* Right: Table */} +
+ +
+
+ ); +}; + +interface EditGroupRelationships { + providers?: { data: ProviderProps[] }; + roles?: { data: Role[] }; +} + +interface EditGroupData { + attributes: { name: string }; + relationships: EditGroupRelationships; +} + +const EditGroupSection = ({ + providerGroupId, + groupData, + allProviders, + allRoles, +}: { + providerGroupId: string; + groupData: EditGroupData; + allProviders: { id: string; name: string }[]; + allRoles: { id: string; name: string }[]; +}) => { + const { attributes, relationships } = groupData; + + const associatedProviders = relationships.providers?.data.map( + (provider: ProviderProps) => { + const match = allProviders.find((p) => p.id === provider.id); + return { + id: provider.id, + name: match?.name || "Unavailable for your role", + }; + }, + ); + + const associatedRoles = relationships.roles?.data.map((role: Role) => { + const match = allRoles.find((r) => r.id === role.id); + return { + id: role.id, + name: match?.name || "Unavailable for your role", + }; + }); + + return ( +
+

Edit account group

+

+ Edit the account group to manage the providers and roles. +

+ +
+ ); +}; + +const fetchGroupsTableData = async (searchParams: SearchParamsProps) => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const sort = searchParams.sort?.toString(); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + + const filters: Record = {}; + Object.entries(searchParams) + .filter(([key]) => key.startsWith("filter[")) + .forEach(([key, value]) => { + filters[key] = value?.toString() || ""; + }); + + const query = (filters["filter[search]"] as string) || ""; + return getProviderGroups({ query, page, sort, filters, pageSize }); +}; diff --git a/ui/app/(prowler)/providers/page.test.ts b/ui/app/(prowler)/providers/page.test.ts index 8ede1bc614..8e2aabf50a 100644 --- a/ui/app/(prowler)/providers/page.test.ts +++ b/ui/app/(prowler)/providers/page.test.ts @@ -12,4 +12,26 @@ describe("providers page", () => { expect(source).not.toContain("key={`providers-${Date.now()}`}"); }); + + it("does not pass non-serializable DataTable callbacks from the server page", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + expect(source).not.toContain("getSubRows={(row) => row.subRows}"); + }); + + it("keeps expandable providers columns on explicit fixed widths", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const columnsPath = path.join( + currentDir, + "../../../components/providers/table/column-providers.tsx", + ); + const source = readFileSync(columnsPath, "utf8"); + + // Account is fixed, Account Groups is fluid (no explicit size) + expect(source).toContain("size: 420"); + expect(source).toContain("size: 160"); + expect(source).toContain("size: 140"); + }); }); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 0f7cce564d..190980f0f5 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,19 +1,21 @@ import { Suspense } from "react"; -import { getProviders } from "@/actions/providers"; -import { FilterControls, filterProviders } from "@/components/filters"; -import { ManageGroupsButton } from "@/components/manage-groups"; import { AddProviderButton, MutedFindingsConfigButton, + ProvidersAccountsTable, + ProvidersFilters, } from "@/components/providers"; -import { - ColumnProviders, - SkeletonTableProviders, -} from "@/components/providers/table"; +import { SkeletonTableProviders } from "@/components/providers/table"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ContentLayout } from "@/components/ui"; -import { DataTable } from "@/components/ui/table"; -import { ProviderProps, SearchParamsProps } from "@/types"; +import { FilterTransitionWrapper } from "@/contexts"; +import { SearchParamsProps } from "@/types"; + +import { AccountGroupsContent } from "./account-groups-content"; +import { ProviderPageTabs } from "./provider-page-tabs"; +import { getProviderTab } from "./provider-page-tabs.shared"; +import { loadProvidersAccountsViewData } from "./providers-page.utils"; export default async function Providers({ searchParams, @@ -21,17 +23,35 @@ export default async function Providers({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); + const activeTab = getProviderTab(resolvedSearchParams.tab); + + // Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend + const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {}; + const searchParamsKey = JSON.stringify(paramsWithoutTab); return ( -
- - - }> - - -
+ + } + > + + + } + accountGroupsContent={ + } + > + + + } + /> +
); } @@ -39,7 +59,6 @@ export default async function Providers({ const ProvidersActions = () => { return (
-
@@ -48,68 +67,68 @@ const ProvidersActions = () => { const ProvidersTableFallback = () => { return ( -
-
+
+
+ {/* ProviderTypeSelector */} + + {/* Account filter */} + + {/* Connection Status filter */} + + {/* Action buttons */} +
+ + +
+
+ +
+ ); +}; + +const AccountGroupsFallback = () => { + return ( +
+
+
+ + + + + + +
+
+
+
); }; -const ProvidersTable = async ({ +const ProvidersAccountsContent = async ({ searchParams, }: { searchParams: SearchParamsProps; }) => { - const page = parseInt(searchParams.page?.toString() || "1", 10); - const sort = searchParams.sort?.toString(); - const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); - - // Extract all filter parameters - const filters = Object.fromEntries( - Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")), - ); - - // Extract query from filters - const query = (filters["filter[search]"] as string) || ""; - - const providersData = await getProviders({ - query, - page, - sort, - filters, - pageSize, + const providersView = await loadProvidersAccountsViewData({ + searchParams, + isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true", }); - const providerGroupDict = - providersData?.included - ?.filter((item: any) => item.type === "provider-groups") - .reduce((acc: Record, group: any) => { - acc[group.id] = group.attributes.name; - return acc; - }, {}) || {}; - - const enrichedProviders = - providersData?.data?.map((provider: ProviderProps) => { - const groupNames = - provider.relationships?.provider_groups?.data?.map( - (group: { id: string }) => - providerGroupDict[group.id] || "Unknown Group", - ) || []; - return { ...provider, groupNames }; - }) || []; - return ( - <> -
-
- -
-
- +
+ } + /> + +
); }; diff --git a/ui/app/(prowler)/providers/provider-page-tabs.shared.ts b/ui/app/(prowler)/providers/provider-page-tabs.shared.ts new file mode 100644 index 0000000000..25136ba49e --- /dev/null +++ b/ui/app/(prowler)/providers/provider-page-tabs.shared.ts @@ -0,0 +1,21 @@ +const PROVIDER_TAB = { + ACCOUNTS: "accounts", + ACCOUNT_GROUPS: "account-groups", +} as const; + +type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB]; + +function isProviderTab(value: string): value is ProviderTab { + return Object.values(PROVIDER_TAB).includes(value as ProviderTab); +} + +function getProviderTab(value: string | string[] | undefined): ProviderTab { + if (typeof value !== "string") { + return PROVIDER_TAB.ACCOUNTS; + } + + return isProviderTab(value) ? value : PROVIDER_TAB.ACCOUNTS; +} + +export type { ProviderTab }; +export { getProviderTab, PROVIDER_TAB }; diff --git a/ui/app/(prowler)/providers/provider-page-tabs.test.tsx b/ui/app/(prowler)/providers/provider-page-tabs.test.tsx new file mode 100644 index 0000000000..0707218a87 --- /dev/null +++ b/ui/app/(prowler)/providers/provider-page-tabs.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ProviderPageTabs } from "./provider-page-tabs"; +import { getProviderTab, PROVIDER_TAB } from "./provider-page-tabs.shared"; + +const { pushMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +describe("ProviderPageTabs", () => { + beforeEach(() => { + pushMock.mockClear(); + }); + + it("falls back to accounts when tab search params are invalid", () => { + expect(getProviderTab(undefined)).toBe(PROVIDER_TAB.ACCOUNTS); + expect(getProviderTab(["account-groups"])).toBe(PROVIDER_TAB.ACCOUNTS); + expect(getProviderTab("invalid-tab")).toBe(PROVIDER_TAB.ACCOUNTS); + expect(getProviderTab(PROVIDER_TAB.ACCOUNT_GROUPS)).toBe( + PROVIDER_TAB.ACCOUNT_GROUPS, + ); + }); + + it("shows the accounts tab when the route changes back to accounts", () => { + const { rerender } = render( + Accounts content
} + accountGroupsContent={
Account groups content
} + />, + ); + + expect(screen.getByRole("tab", { name: "Account Groups" })).toHaveAttribute( + "data-state", + "active", + ); + + rerender( + Accounts content
} + accountGroupsContent={
Account groups content
} + />, + ); + + expect(screen.getByRole("tab", { name: "Accounts" })).toHaveAttribute( + "data-state", + "active", + ); + expect(screen.getByText("Accounts content")).toBeVisible(); + }); + + it("does not switch the active tab before navigation updates the route", async () => { + const user = userEvent.setup(); + + render( + Accounts content
} + accountGroupsContent={
Account groups content
} + />, + ); + + await user.click(screen.getByRole("tab", { name: "Account Groups" })); + + expect(pushMock).toHaveBeenCalledWith("/providers?tab=account-groups"); + expect(screen.getByRole("tab", { name: "Accounts" })).toHaveAttribute( + "data-state", + "active", + ); + expect( + screen.getByRole("tab", { name: "Account Groups" }), + ).not.toHaveAttribute("data-state", "active"); + }); +}); diff --git a/ui/app/(prowler)/providers/provider-page-tabs.tsx b/ui/app/(prowler)/providers/provider-page-tabs.tsx new file mode 100644 index 0000000000..437c8b6da0 --- /dev/null +++ b/ui/app/(prowler)/providers/provider-page-tabs.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { ReactNode } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn"; + +import { PROVIDER_TAB, type ProviderTab } from "./provider-page-tabs.shared"; + +interface ProviderPageTabsProps { + activeTab: ProviderTab; + accountsContent: ReactNode; + accountGroupsContent: ReactNode; +} + +export const ProviderPageTabs = ({ + activeTab, + accountsContent, + accountGroupsContent, +}: ProviderPageTabsProps) => { + const router = useRouter(); + + const handleTabChange = (tab: string) => { + const typedTab = tab as ProviderTab; + + if (typedTab === activeTab) { + return; + } + + if (typedTab === PROVIDER_TAB.ACCOUNTS) { + router.push("/providers"); + } else { + router.push(`/providers?tab=${typedTab}`); + } + }; + + return ( + + + Accounts + + Account Groups + + + + + {accountsContent} + + + + {accountGroupsContent} + + + ); +}; diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts new file mode 100644 index 0000000000..a937bcd89a --- /dev/null +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -0,0 +1,806 @@ +import { describe, expect, it, vi } from "vitest"; + +const providersActionsMock = vi.hoisted(() => ({ + getProviders: vi.fn(), +})); + +const manageGroupsActionsMock = vi.hoisted(() => ({ + getProviderGroups: vi.fn(), +})); + +const organizationsActionsMock = vi.hoisted(() => ({ + listOrganizationsSafe: vi.fn(), + listOrganizationUnitsSafe: vi.fn(), +})); + +const scansActionsMock = vi.hoisted(() => ({ + getScans: vi.fn(), +})); + +vi.mock("@/actions/providers", () => providersActionsMock); +vi.mock("@/actions/manage-groups", () => manageGroupsActionsMock); +vi.mock( + "@/actions/organizations/organizations", + () => organizationsActionsMock, +); +vi.mock("@/actions/scans", () => scansActionsMock); + +import { SearchParamsProps } from "@/types"; +import { ProviderGroupsResponse } from "@/types/components"; +import { ProvidersApiResponse } from "@/types/providers"; +import { ProvidersProviderRow } from "@/types/providers-table"; + +import { + buildProvidersTableRows, + loadProvidersAccountsViewData, + PROVIDERS_ROW_TYPE, +} from "./providers-page.utils"; + +const providersResponse: ProvidersApiResponse = { + links: { + first: "", + last: "", + next: null, + prev: null, + }, + data: [ + { + id: "provider-1", + type: "providers", + attributes: { + provider: "aws", + uid: "111111111111", + alias: "AWS App Account", + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2025-02-13T11:17:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2025-02-13T11:17:00Z", + updated_at: "2025-02-13T11:17:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: { + type: "provider-secrets", + id: "secret-1", + }, + }, + provider_groups: { + meta: { + count: 1, + }, + data: [ + { + type: "provider-groups", + id: "group-1", + }, + ], + }, + }, + }, + { + id: "provider-2", + type: "providers", + attributes: { + provider: "aws", + uid: "222222222222", + alias: "Standalone Account", + status: "completed", + resources: 0, + connection: { + connected: false, + last_checked_at: "2025-02-13T11:17:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2025-02-13T11:17:00Z", + updated_at: "2025-02-13T11:17:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }, + ], + included: [ + { + type: "provider-groups", + id: "group-1", + attributes: { + name: "AWS Team", + }, + }, + ], + meta: { + pagination: { + page: 1, + pages: 1, + count: 2, + }, + version: "1", + }, +}; + +const providerGroupsResponse: ProviderGroupsResponse = { + links: { + first: "", + last: "", + next: null, + prev: null, + }, + data: [ + { + id: "group-1", + type: "provider-groups", + attributes: { + name: "AWS Team", + inserted_at: "2025-02-13T11:17:00Z", + updated_at: "2025-02-13T11:17:00Z", + }, + relationships: { + providers: { + meta: { count: 1 }, + data: [{ type: "providers", id: "provider-1" }], + }, + roles: { + meta: { count: 0 }, + data: [], + }, + }, + links: { + self: "", + }, + }, + ], + meta: { + pagination: { + page: 1, + pages: 1, + count: 1, + }, + version: "1", + }, +}; + +const toProviderRow = ( + provider: (typeof providersResponse.data)[number], + overrides?: Partial, +): ProvidersProviderRow => ({ + ...provider, + ...overrides, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + groupNames: provider.id === "provider-1" ? ["AWS Team"] : [], + hasSchedule: false, + relationships: { + ...provider.relationships, + ...overrides?.relationships, + }, +}); + +describe("buildProvidersTableRows", () => { + it("returns a flat providers table for OSS", () => { + // Given + const providers = providersResponse.data.map((provider) => + toProviderRow(provider), + ); + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [], + organizationUnits: [], + isCloud: false, + }); + + // Then + expect(rows).toHaveLength(2); + expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER); + expect(rows[1].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER); + }); + + it("nests providers under organizations and organization units in cloud", () => { + // Given + const providers = providersResponse.data.map((provider) => + toProviderRow(provider, { + relationships: { + ...provider.relationships, + organization: { + data: + provider.id === "provider-1" + ? { type: "organizations", id: "org-1" } + : null, + }, + organization_unit: { + data: + provider.id === "provider-1" + ? { type: "organizational-units", id: "ou-1" } + : null, + }, + }, + }), + ); + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Root Organization", + org_type: "aws", + external_id: "o-root", + metadata: {}, + root_external_id: "r-root", + }, + relationships: {}, + }, + ], + organizationUnits: [ + { + id: "ou-1", + type: "organizational-units", + attributes: { + name: "Security OU", + external_id: "ou-security", + parent_external_id: "r-root", + metadata: {}, + }, + relationships: { + organization: { + data: { + type: "organizations", + id: "org-1", + }, + }, + }, + }, + ], + isCloud: true, + }); + + // Then + expect(rows).toHaveLength(2); + expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(rows[0].subRows).toHaveLength(1); + expect(rows[0].subRows?.[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(rows[0].subRows?.[0].subRows?.[0].rowType).toBe( + PROVIDERS_ROW_TYPE.PROVIDER, + ); + expect(rows[1].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER); + }); + + it("nests organizational units recursively up to multiple levels", () => { + // Given — OU hierarchy: org-1 > ou-root > ou-child > ou-grandchild + const providers = [ + toProviderRow(providersResponse.data[0], { + relationships: { + ...providersResponse.data[0].relationships, + organization: { + data: { type: "organizations", id: "org-1" }, + }, + organization_unit: { + data: { type: "organizational-units", id: "ou-grandchild" }, + }, + }, + }), + ]; + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Root Organization", + org_type: "aws", + external_id: "o-root", + metadata: {}, + root_external_id: "r-root", + }, + relationships: {}, + }, + ], + organizationUnits: [ + { + id: "ou-root", + type: "organizational-units", + attributes: { + name: "Production", + external_id: "ou-prod", + parent_external_id: "r-root", + metadata: {}, + }, + relationships: { + organization: { + data: { type: "organizations", id: "org-1" }, + }, + }, + }, + { + id: "ou-child", + type: "organizational-units", + attributes: { + name: "EMEA", + external_id: "ou-emea", + parent_external_id: "ou-prod", + metadata: {}, + }, + relationships: { + organization: { + data: { type: "organizations", id: "org-1" }, + }, + }, + }, + { + id: "ou-grandchild", + type: "organizational-units", + attributes: { + name: "Security", + external_id: "ou-security", + parent_external_id: "ou-emea", + metadata: {}, + }, + relationships: { + organization: { + data: { type: "organizations", id: "org-1" }, + }, + }, + }, + ], + isCloud: true, + }); + + // Then — org > ou-root > ou-child > ou-grandchild > provider + expect(rows).toHaveLength(1); + const orgRow = rows[0]; + expect(orgRow.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(orgRow.subRows).toHaveLength(1); + + const ouRoot = orgRow.subRows![0]; + expect(ouRoot.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(ouRoot.subRows).toHaveLength(1); + + const ouChild = ouRoot.subRows![0]; + expect(ouChild.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(ouChild.subRows).toHaveLength(1); + + const ouGrandchild = ouChild.subRows![0]; + expect(ouGrandchild.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(ouGrandchild.subRows).toHaveLength(1); + expect(ouGrandchild.subRows![0].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER); + }); + + it("nests providers under OUs using relationship-based parent IDs", () => { + // Given — providers have no org/OU linkage; tree is built from OU relationships + const providers = [toProviderRow(providersResponse.data[0])]; + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Root Organization", + org_type: "aws", + external_id: "o-root", + metadata: {}, + root_external_id: "r-root", + }, + relationships: {}, + }, + ], + organizationUnits: [ + { + id: "ou-parent", + type: "organizational-units", + attributes: { + name: "Workloads", + external_id: "ou-workloads", + parent_external_id: null, + metadata: {}, + }, + relationships: { + organization: { + data: { type: "organizations", id: "org-1" }, + }, + parent: { + data: null, + }, + }, + }, + { + id: "ou-child", + type: "organizational-units", + attributes: { + name: "Team A", + external_id: "ou-team-a", + parent_external_id: null, + metadata: {}, + }, + relationships: { + organization: { + data: { type: "organizations", id: "org-1" }, + }, + parent: { + data: { type: "organizational-units", id: "ou-parent" }, + }, + providers: { + data: [{ type: "providers", id: "provider-1" }], + }, + }, + }, + ], + isCloud: true, + }); + + // Then — org > ou-parent > ou-child > provider + // Provider is claimed by ou-child via relationships, so org's direct + // providers list becomes empty and the org row only contains the OU subtree. + expect(rows).toHaveLength(1); + const orgRow = rows[0]; + expect(orgRow.subRows).toHaveLength(1); + + const ouParent = orgRow.subRows![0]; + expect(ouParent.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(ouParent.subRows).toHaveLength(1); + + const ouChild = ouParent.subRows![0]; + expect(ouChild.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(ouChild.subRows).toHaveLength(1); + expect(ouChild.subRows![0].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER); + }); + + it("does not duplicate providers that appear in both org relationships and OU assignments", () => { + // Given — provider-1 is linked to org-1 AND assigned to ou-1 + const providers = [ + toProviderRow(providersResponse.data[0], { + relationships: { + ...providersResponse.data[0].relationships, + organization: { + data: { type: "organizations", id: "org-1" }, + }, + organization_unit: { + data: { type: "organizational-units", id: "ou-1" }, + }, + }, + }), + ]; + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Root Organization", + org_type: "aws", + external_id: "o-root", + metadata: {}, + root_external_id: "r-root", + }, + relationships: { + providers: { + data: [{ type: "providers", id: "provider-1" }], + }, + }, + }, + ], + organizationUnits: [ + { + id: "ou-1", + type: "organizational-units", + attributes: { + name: "Security OU", + external_id: "ou-security", + parent_external_id: "r-root", + metadata: {}, + }, + relationships: { + organization: { + data: { type: "organizations", id: "org-1" }, + }, + }, + }, + ], + isCloud: true, + }); + + // Then — provider appears only under OU, not duplicated at org level + expect(rows).toHaveLength(1); + const orgRow = rows[0]; + expect(orgRow.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + // Org should contain only the OU row, not the provider directly + expect(orgRow.subRows).toHaveLength(1); + expect(orgRow.subRows![0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + // The OU should contain the provider + expect(orgRow.subRows![0].subRows).toHaveLength(1); + expect(orgRow.subRows![0].subRows![0].rowType).toBe( + PROVIDERS_ROW_TYPE.PROVIDER, + ); + expect(orgRow.subRows![0].subRows![0].id).toBe("provider-1"); + }); + + it("keeps org-only providers as direct org children even when org has relationship data", () => { + // Given — provider-1 belongs to org-1 but has no OU + const providers = [ + toProviderRow(providersResponse.data[0], { + relationships: { + ...providersResponse.data[0].relationships, + organization: { + data: { type: "organizations", id: "org-1" }, + }, + organization_unit: { + data: null, + }, + }, + }), + ]; + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Root Organization", + org_type: "aws", + external_id: "o-root", + metadata: {}, + root_external_id: "r-root", + }, + relationships: { + providers: { + data: [{ type: "providers", id: "provider-1" }], + }, + }, + }, + ], + organizationUnits: [], + isCloud: true, + }); + + // Then — provider appears as a direct child of the org + expect(rows).toHaveLength(1); + const orgRow = rows[0]; + expect(orgRow.rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(orgRow.subRows).toHaveLength(1); + expect(orgRow.subRows![0].rowType).toBe(PROVIDERS_ROW_TYPE.PROVIDER); + expect(orgRow.subRows![0].id).toBe("provider-1"); + }); + + it("groups providers from organization relationships when provider resources do not expose organization linkage", () => { + // Given + const providers = providersResponse.data.map((provider) => + toProviderRow(provider, { + relationships: { + ...provider.relationships, + organization: { + data: null, + }, + organization_unit: { + data: null, + }, + }, + }), + ); + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Shared Organization", + org_type: "aws", + external_id: "o-shared", + metadata: {}, + root_external_id: "r-shared", + }, + relationships: { + providers: { + data: [ + { type: "providers", id: "provider-1" }, + { type: "providers", id: "provider-2" }, + ], + }, + organizational_units: { + data: [], + }, + }, + }, + ], + organizationUnits: [], + isCloud: true, + }); + + // Then + expect(rows).toHaveLength(1); + expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + expect(rows[0].subRows).toHaveLength(2); + expect( + rows[0].subRows?.every( + (row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER, + ), + ).toBe(true); + }); +}); + +describe("loadProvidersAccountsViewData", () => { + it("does not call organizations endpoints in OSS", async () => { + // Given + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + manageGroupsActionsMock.getProviderGroups.mockResolvedValue( + providerGroupsResponse, + ); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then + expect( + organizationsActionsMock.listOrganizationsSafe, + ).not.toHaveBeenCalled(); + expect( + organizationsActionsMock.listOrganizationUnitsSafe, + ).not.toHaveBeenCalled(); + expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual( + ["Account Groups", "Status"], + ); + }); + + it("loads organizations filters and recursive rows in cloud", async () => { + // Given + providersActionsMock.getProviders.mockResolvedValue({ + ...providersResponse, + data: providersResponse.data.map((provider) => ({ + ...provider, + relationships: { + ...provider.relationships, + organization: { + data: + provider.id === "provider-1" + ? { type: "organizations", id: "org-1" } + : null, + }, + organization_unit: { + data: + provider.id === "provider-1" + ? { type: "organizational-units", id: "ou-1" } + : null, + }, + }, + })), + }); + manageGroupsActionsMock.getProviderGroups.mockResolvedValue( + providerGroupsResponse, + ); + organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ + data: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Root Organization", + org_type: "aws", + external_id: "o-root", + metadata: {}, + root_external_id: "r-root", + }, + relationships: {}, + }, + ], + }); + organizationsActionsMock.listOrganizationUnitsSafe.mockResolvedValue({ + data: [ + { + id: "ou-1", + type: "organizational-units", + attributes: { + name: "Security OU", + external_id: "ou-security", + parent_external_id: "r-root", + metadata: {}, + }, + relationships: { + organization: { + data: { + type: "organizations", + id: "org-1", + }, + }, + }, + }, + ], + }); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: true, + }); + + // Then + expect( + organizationsActionsMock.listOrganizationsSafe, + ).toHaveBeenCalledTimes(1); + expect( + organizationsActionsMock.listOrganizationUnitsSafe, + ).toHaveBeenCalledTimes(1); + expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual( + ["Organizations", "Account Groups", "Status"], + ); + expect(viewData.rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); + }); + + it("falls back to empty cloud grouping data when organizations endpoints fail", async () => { + // Given + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + manageGroupsActionsMock.getProviderGroups.mockResolvedValue( + providerGroupsResponse, + ); + organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ + data: [], + }); + organizationsActionsMock.listOrganizationUnitsSafe.mockResolvedValue({ + data: [], + }); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: true, + }); + + // Then + expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual( + ["Account Groups", "Status"], + ); + expect(viewData.rows).toHaveLength(2); + expect( + viewData.rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER), + ).toBe(true); + }); +}); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts new file mode 100644 index 0000000000..5781020354 --- /dev/null +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -0,0 +1,632 @@ +import { getProviderGroups } from "@/actions/manage-groups"; +import { + listOrganizationsSafe, + listOrganizationUnitsSafe, +} from "@/actions/organizations/organizations"; +import { getProviders } from "@/actions/providers"; +import { getScans } from "@/actions/scans"; +import { + extractFiltersAndQuery, + extractSortAndKey, +} from "@/lib/helper-filters"; +import { + FilterEntity, + FilterOption, + OrganizationListResponse, + OrganizationResource, + OrganizationUnitListResponse, + OrganizationUnitResource, + ProviderGroupsResponse, + ProvidersApiResponse, + SearchParamsProps, +} from "@/types"; +import { + PROVIDERS_GROUP_KIND, + PROVIDERS_PAGE_FILTER, + PROVIDERS_ROW_TYPE, + ProvidersAccountsViewData, + ProvidersOrganizationRow, + ProvidersProviderRow, + ProvidersTableRow, + ProvidersTableRowsInput, +} from "@/types/providers-table"; +import { SCAN_TRIGGER, ScanProps } from "@/types/scans"; + +const PROVIDERS_STATUS_MAPPING = [ + { + true: { + label: "Connected", + value: "true", + }, + }, + { + false: { + label: "Not connected", + value: "false", + }, + }, +] as Array<{ [key: string]: FilterEntity }>; + +interface ProvidersAccountsViewInput { + isCloud: boolean; + searchParams: SearchParamsProps; +} + +interface ProvidersTableLocalFilters { + organizationIds: string[]; + providerGroupIds: string[]; +} + +function hasActionError(result: unknown): result is { + error: unknown; +} { + return Boolean( + result && + typeof result === "object" && + "error" in (result as Record) && + (result as Record).error !== null && + (result as Record).error !== undefined, + ); +} + +async function resolveActionResult( + action: Promise, + fallback?: T, +): Promise { + try { + const result = await action; + + if (hasActionError(result)) { + return fallback; + } + + return result ?? fallback; + } catch { + return fallback; + } +} + +const createProvidersFilters = ({ + isCloud, + organizations, + providerGroups, +}: { + isCloud: boolean; + organizations: OrganizationResource[]; + providerGroups: ProviderGroupsResponse["data"]; +}): FilterOption[] => { + // Provider type and account selection are handled by ProviderTypeSelector + // and AccountsSelector. These filters go in the expandable "More Filters" section. + const filters: FilterOption[] = []; + + if (isCloud && organizations.length > 0) { + filters.push({ + key: PROVIDERS_PAGE_FILTER.ORGANIZATION, + labelCheckboxGroup: "Organizations", + values: organizations.map((organization) => organization.id), + index: 0, + valueLabelMapping: organizations.map((organization) => ({ + [organization.id]: { + name: organization.attributes.name, + uid: organization.attributes.external_id, + }, + })), + }); + } + + filters.push({ + key: PROVIDERS_PAGE_FILTER.ACCOUNT_GROUP, + labelCheckboxGroup: "Account Groups", + values: providerGroups.map((providerGroup) => providerGroup.id), + index: 1, + valueLabelMapping: providerGroups.map((providerGroup) => ({ + [providerGroup.id]: { + name: providerGroup.attributes.name, + uid: providerGroup.id, + }, + })), + }); + + filters.push({ + key: PROVIDERS_PAGE_FILTER.STATUS, + labelCheckboxGroup: "Status", + values: ["true", "false"], + valueLabelMapping: PROVIDERS_STATUS_MAPPING, + index: 2, + }); + + return filters; +}; + +const createProviderGroupLookup = ( + providersResponse?: ProvidersApiResponse, +): Map => { + const lookup = new Map(); + + for (const includedItem of providersResponse?.included ?? []) { + if ( + includedItem.type === "provider-groups" && + typeof includedItem.attributes?.name === "string" + ) { + lookup.set(includedItem.id, includedItem.attributes.name); + } + } + + return lookup; +}; + +const ACTIVE_SCAN_STATES = new Set(["scheduled", "available", "executing"]); + +const buildScheduledProviderIds = (scans: ScanProps[]): Set => { + const scheduled = new Set(); + + for (const scan of scans) { + if ( + scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED && + ACTIVE_SCAN_STATES.has(scan.attributes.state) + ) { + const providerId = scan.relationships.provider?.data?.id; + if (providerId) { + scheduled.add(providerId); + } + } + } + + return scheduled; +}; + +const enrichProviders = ( + providersResponse?: ProvidersApiResponse, + scheduledProviderIds?: Set, +): ProvidersProviderRow[] => { + const providerGroupLookup = createProviderGroupLookup(providersResponse); + + return (providersResponse?.data ?? []).map((provider) => ({ + ...provider, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + groupNames: + provider.relationships.provider_groups.data.map( + (providerGroup: { id: string }) => + providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", + ) ?? [], + hasSchedule: scheduledProviderIds?.has(provider.id) ?? false, + })); +}; + +const getFilterValues = ( + searchParams: SearchParamsProps, + key: string, +): string[] => { + const rawValue = searchParams[`filter[${key}]`]; + + if (!rawValue) { + return []; + } + + const filterValue = Array.isArray(rawValue) ? rawValue.join(",") : rawValue; + return filterValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +}; + +/** + * Filters providers client-side by organization and provider group. + * + * The API's ProviderFilter does not support `organization_id__in` or + * `provider_group_id__in`, so these filters must be applied after fetching. + * TODO: Move to server-side when API adds support for these filter params. + */ +const applyLocalFilters = ({ + organizationIds, + providerGroupIds, + providers, +}: ProvidersTableLocalFilters & { providers: ProvidersProviderRow[] }) => { + return providers.filter((provider) => { + const organizationId = + provider.relationships.organization?.data?.id ?? null; + const matchesOrganization = + organizationIds.length === 0 || + organizationIds.includes(organizationId ?? ""); + const providerGroupIdsForProvider = + provider.relationships.provider_groups.data.map( + (providerGroup) => providerGroup.id, + ); + const matchesGroup = + providerGroupIds.length === 0 || + providerGroupIds.some((providerGroupId) => + providerGroupIdsForProvider.includes(providerGroupId), + ); + + return matchesOrganization && matchesGroup; + }); +}; + +const createOrganizationRow = ({ + groupKind, + id, + name, + externalId, + organizationId, + parentExternalId, + subRows, +}: { + externalId: string | null; + groupKind: ProvidersOrganizationRow["groupKind"]; + id: string; + name: string; + organizationId: string | null; + parentExternalId: string | null; + subRows: ProvidersTableRow[]; +}): ProvidersOrganizationRow => ({ + id, + rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, + groupKind, + name, + externalId, + organizationId, + parentExternalId, + providerCount: countProviderRows(subRows), + subRows, +}); + +function getRelationshipProviderIds( + relationships: + | { + providers?: { + data?: Array<{ id: string; type: string }>; + }; + } + | undefined, +): string[] { + return relationships?.providers?.data?.map((provider) => provider.id) ?? []; +} + +function getOrganizationUnitParentId( + organizationUnit: OrganizationUnitResource, +): string | null { + return organizationUnit.relationships.parent?.data?.id ?? null; +} + +function getProviderRowsByIds({ + providerIds, + providerLookup, +}: { + providerIds: string[]; + providerLookup: Map; +}): ProvidersProviderRow[] { + return providerIds + .map((providerId) => providerLookup.get(providerId)) + .filter((provider): provider is ProvidersProviderRow => Boolean(provider)); +} + +function countProviderRows(rows: ProvidersTableRow[]): number { + return rows.reduce((total, row) => { + if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { + return total + 1; + } + + return total + countProviderRows(row.subRows); + }, 0); +} + +function getOrganizationUnitRelationshipId( + provider: ProvidersProviderRow, +): string | null { + return ( + provider.relationships.organization_unit?.data?.id ?? + provider.relationships.organizational_unit?.data?.id ?? + null + ); +} + +function buildOrganizationUnitRows({ + organizationId, + organizationUnits, + providerLookup, + providersByOrganizationUnitId, + useParentIdRelationships, + parentExternalId, + parentOrganizationUnitId, + maxDepth = 10, +}: { + organizationId: string; + organizationUnits: OrganizationUnitResource[]; + parentExternalId: string | null; + parentOrganizationUnitId: string | null; + providerLookup: Map; + providersByOrganizationUnitId: Map; + useParentIdRelationships: boolean; + maxDepth?: number; +}): ProvidersOrganizationRow[] { + if (maxDepth <= 0) { + return []; + } + + return organizationUnits + .filter( + (organizationUnit) => + organizationUnit.relationships.organization.data.id === + organizationId && + (useParentIdRelationships + ? getOrganizationUnitParentId(organizationUnit) === + parentOrganizationUnitId + : organizationUnit.attributes.parent_external_id === + parentExternalId), + ) + .map((organizationUnit) => { + const childOrganizationUnitRows = buildOrganizationUnitRows({ + organizationId, + organizationUnits, + parentOrganizationUnitId: organizationUnit.id, + parentExternalId: organizationUnit.attributes.external_id, + providerLookup, + providersByOrganizationUnitId, + useParentIdRelationships, + maxDepth: maxDepth - 1, + }); + const providerRowsFromRelationships = getProviderRowsByIds({ + providerIds: getRelationshipProviderIds(organizationUnit.relationships), + providerLookup, + }); + const providerRows = + providerRowsFromRelationships.length > 0 + ? providerRowsFromRelationships + : (providersByOrganizationUnitId.get(organizationUnit.id) ?? []); + const subRows = [...childOrganizationUnitRows, ...providerRows]; + + return createOrganizationRow({ + groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT, + id: organizationUnit.id, + name: organizationUnit.attributes.name, + externalId: organizationUnit.attributes.external_id, + organizationId, + parentExternalId: organizationUnit.attributes.parent_external_id, + subRows, + }); + }) + .filter((organizationUnitRow) => organizationUnitRow.subRows.length > 0); +} + +export function buildProvidersTableRows({ + isCloud, + organizations, + organizationUnits, + providers, +}: ProvidersTableRowsInput): ProvidersTableRow[] { + if (!isCloud) { + return providers; + } + + const providerLookup = new Map( + providers.map((provider) => [provider.id, provider] as const), + ); + const providersByOrganizationId = new Map(); + const providersByOrganizationUnitId = new Map< + string, + ProvidersProviderRow[] + >(); + + for (const provider of providers) { + const organizationId = + provider.relationships.organization?.data?.id ?? null; + const organizationUnitId = getOrganizationUnitRelationshipId(provider); + + if (organizationUnitId) { + const organizationUnitProviders = + providersByOrganizationUnitId.get(organizationUnitId) ?? []; + organizationUnitProviders.push(provider); + providersByOrganizationUnitId.set( + organizationUnitId, + organizationUnitProviders, + ); + continue; + } + + if (organizationId) { + const organizationProviders = + providersByOrganizationId.get(organizationId) ?? []; + organizationProviders.push(provider); + providersByOrganizationId.set(organizationId, organizationProviders); + } + } + + const useParentIdRelationships = organizationUnits.some( + (organizationUnit) => organizationUnit.relationships.parent !== undefined, + ); + + // Build a set of provider IDs that are assigned to OUs, so we can + // exclude them from the org's direct children and avoid duplication. + const providersAssignedToOu = new Set( + Array.from(providersByOrganizationUnitId.values()).flatMap((providers) => + providers.map((p) => p.id), + ), + ); + + const organizationRows = organizations + .map((organization) => { + const organizationUnitRows = buildOrganizationUnitRows({ + organizationId: organization.id, + organizationUnits, + parentOrganizationUnitId: null, + parentExternalId: organization.attributes.root_external_id, + providerLookup, + providersByOrganizationUnitId, + useParentIdRelationships, + }); + + // Collect all provider IDs already placed inside OUs to avoid duplication + // at the org level. This covers both relationship-based and fallback assignments. + const providersInOus = new Set(); + function collectOuProviderIds(rows: ProvidersTableRow[]) { + for (const row of rows) { + if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { + providersInOus.add(row.id); + } else { + collectOuProviderIds(row.subRows); + } + } + } + collectOuProviderIds(organizationUnitRows); + + const organizationProvidersFromRelationships = getProviderRowsByIds({ + providerIds: getRelationshipProviderIds(organization.relationships), + providerLookup, + }).filter( + (provider) => + !providersAssignedToOu.has(provider.id) && + !providersInOus.has(provider.id), + ); + const organizationProviders = + organizationProvidersFromRelationships.length > 0 + ? organizationProvidersFromRelationships + : (providersByOrganizationId.get(organization.id) ?? []).filter( + (provider) => !providersInOus.has(provider.id), + ); + const subRows = [...organizationProviders, ...organizationUnitRows]; + + return createOrganizationRow({ + groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION, + id: organization.id, + name: organization.attributes.name, + externalId: organization.attributes.external_id, + organizationId: organization.id, + parentExternalId: organization.attributes.root_external_id, + subRows, + }); + }) + .filter((organizationRow) => organizationRow.subRows.length > 0); + + const assignedProviderIds = new Set(); + + function collectAssignedProviderIds(rows: ProvidersTableRow[]) { + for (const row of rows) { + if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { + assignedProviderIds.add(row.id); + continue; + } + + collectAssignedProviderIds(row.subRows); + } + } + + collectAssignedProviderIds(organizationRows); + const orphanProviders = providers.filter( + (provider) => !assignedProviderIds.has(provider.id), + ); + + return [...organizationRows, ...orphanProviders]; +} + +export async function loadProvidersAccountsViewData({ + isCloud, + searchParams, +}: ProvidersAccountsViewInput): Promise { + const page = parseInt(searchParams.page?.toString() ?? "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() ?? "10", 10); + const { encodedSort } = extractSortAndKey(searchParams); + const { filters, query } = extractFiltersAndQuery(searchParams); + + const localFilters = { + organizationIds: getFilterValues( + searchParams, + PROVIDERS_PAGE_FILTER.ORGANIZATION, + ), + providerGroupIds: getFilterValues( + searchParams, + PROVIDERS_PAGE_FILTER.ACCOUNT_GROUP, + ), + }; + + const providerFilters = { ...filters }; + + // Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param) + const providerTypeFilter = + providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`]; + if (providerTypeFilter) { + providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] = + providerTypeFilter; + } + + // Remove client-side-only filters before sending to the API. + // TODO: Move organization and account group filtering to server-side + // when API adds support for `organization_id__in` and `provider_group_id__in`. + delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`]; + delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.ORGANIZATION}]`]; + delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.ACCOUNT_GROUP}]`]; + + const emptyOrganizationsResponse: OrganizationListResponse = { + data: [], + }; + const emptyOrganizationUnitsResponse: OrganizationUnitListResponse = { + data: [], + }; + + const [ + providersResponse, + allProvidersResponse, + providerGroupsResponse, + scansResponse, + organizationsResponse, + organizationUnitsResponse, + ] = await Promise.all([ + resolveActionResult( + getProviders({ + filters: providerFilters, + page, + pageSize, + query, + sort: encodedSort, + }), + ), + // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; + // TODO: Replace with a dedicated lightweight endpoint when available. + resolveActionResult(getProviders({ pageSize: 500 })), + resolveActionResult(getProviderGroups({ page: 1, pageSize: 500 })), + // Fetch active scheduled scans to determine daily schedule per provider + resolveActionResult( + getScans({ + pageSize: 500, + filters: { + "filter[trigger]": SCAN_TRIGGER.SCHEDULED, + "filter[state__in]": "scheduled,available", + }, + }), + ), + isCloud + ? listOrganizationsSafe() + : Promise.resolve(emptyOrganizationsResponse), + isCloud + ? listOrganizationUnitsSafe() + : Promise.resolve(emptyOrganizationUnitsResponse), + ]); + + const scheduledProviderIds = buildScheduledProviderIds( + scansResponse?.data ?? [], + ); + + const providers = applyLocalFilters({ + ...localFilters, + providers: enrichProviders(providersResponse, scheduledProviderIds), + }); + + const rows = buildProvidersTableRows({ + isCloud, + organizations: organizationsResponse?.data ?? [], + organizationUnits: organizationUnitsResponse?.data ?? [], + providers, + }); + + return { + filters: createProvidersFilters({ + isCloud, + organizations: organizationsResponse?.data ?? [], + providerGroups: providerGroupsResponse?.data ?? [], + }), + metadata: providersResponse?.meta, + providers: allProvidersResponse?.data ?? [], + rows, + }; +} + +export { PROVIDERS_ROW_TYPE }; diff --git a/ui/components/icons/providers-badge/iac-provider-badge.tsx b/ui/components/icons/providers-badge/iac-provider-badge.tsx index 9a701606ed..24302e2efd 100644 --- a/ui/components/icons/providers-badge/iac-provider-badge.tsx +++ b/ui/components/icons/providers-badge/iac-provider-badge.tsx @@ -15,30 +15,23 @@ export const IacProviderBadge: React.FC = ({ focusable="false" height={size || height} role="presentation" - viewBox="0 0 24 24" + viewBox="0 0 256 256" width={size || width} {...props} > - + - - + > + {/* Slash: / */} + + {/* Left bracket: < */} + + {/* Right bracket: > */} + + ); diff --git a/ui/components/manage-groups/forms/delete-group-form.tsx b/ui/components/manage-groups/forms/delete-group-form.tsx index 96911dbdda..7f79817440 100644 --- a/ui/components/manage-groups/forms/delete-group-form.tsx +++ b/ui/components/manage-groups/forms/delete-group-form.tsx @@ -48,7 +48,7 @@ export const DeleteGroupForm = ({ title: "Success!", description: "The provider group was removed successfully.", }); - router.push("/manage-groups"); + router.push("/providers?tab=account-groups"); } setIsOpen(false); // Close the modal on success } diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx index a29a054e8b..907e372b0e 100644 --- a/ui/components/manage-groups/forms/edit-group-form.tsx +++ b/ui/components/manage-groups/forms/edit-group-form.tsx @@ -130,7 +130,7 @@ export const EditGroupForm = ({ title: "Success!", description: "The group was updated successfully.", }); - router.push("/manage-groups"); + router.push("/providers?tab=account-groups"); } } catch (_error) { toast({ @@ -263,7 +263,7 @@ export const EditGroupForm = ({ type="button" variant="ghost" onClick={() => { - router.push("/manage-groups"); + router.push("/providers?tab=account-groups"); }} disabled={isLoading} > diff --git a/ui/components/manage-groups/manage-groups-button.tsx b/ui/components/manage-groups/manage-groups-button.tsx index 48df5ee607..3b08f5d8eb 100644 --- a/ui/components/manage-groups/manage-groups-button.tsx +++ b/ui/components/manage-groups/manage-groups-button.tsx @@ -10,7 +10,7 @@ export const ManageGroupsButton = () => { ); diff --git a/ui/components/manage-groups/table/data-table-row-actions.tsx b/ui/components/manage-groups/table/data-table-row-actions.tsx index 519b0b05e2..c737e80604 100644 --- a/ui/components/manage-groups/table/data-table-row-actions.tsx +++ b/ui/components/manage-groups/table/data-table-row-actions.tsx @@ -49,13 +49,15 @@ export function DataTableRowActions({ > } - label="Edit Provider Group" - onSelect={() => router.push(`/manage-groups?groupId=${groupId}`)} + label="Edit Account Group" + onSelect={() => + router.push(`/providers?tab=account-groups&groupId=${groupId}`) + } /> } - label="Delete Provider Group" + label="Delete Account Group" destructive onSelect={() => setIsDeleteOpen(true)} /> diff --git a/ui/components/providers/add-provider-button.tsx b/ui/components/providers/add-provider-button.tsx index 223d3a390d..30d630c85a 100644 --- a/ui/components/providers/add-provider-button.tsx +++ b/ui/components/providers/add-provider-button.tsx @@ -5,17 +5,12 @@ import { useState } from "react"; import { ProviderWizardModal } from "@/components/providers/wizard"; import { Button } from "@/components/shadcn"; -import { AddIcon } from "../icons"; - export const AddProviderButton = () => { const [open, setOpen] = useState(false); return ( <> - + ); diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts index 1cd69cc9f3..6ae4b6bece 100644 --- a/ui/components/providers/index.ts +++ b/ui/components/providers/index.ts @@ -5,5 +5,7 @@ export * from "./forms/delete-form"; export * from "./link-to-scans"; export * from "./muted-findings-config-button"; export * from "./provider-info"; +export * from "./providers-accounts-table"; +export * from "./providers-filters"; export * from "./radio-card"; export * from "./radio-group-provider"; diff --git a/ui/components/providers/link-to-scans.tsx b/ui/components/providers/link-to-scans.tsx index 81fd9816b8..09f06a7fd8 100644 --- a/ui/components/providers/link-to-scans.tsx +++ b/ui/components/providers/link-to-scans.tsx @@ -5,15 +5,21 @@ import Link from "next/link"; import { Button } from "@/components/shadcn"; interface LinkToScansProps { + hasSchedule: boolean; providerUid?: string; } -export const LinkToScans = ({ providerUid }: LinkToScansProps) => { +export const LinkToScans = ({ hasSchedule, providerUid }: LinkToScansProps) => { return ( - +
+ + {hasSchedule ? "Daily" : "None"} + + +
); }; diff --git a/ui/components/providers/provider-info.tsx b/ui/components/providers/provider-info.tsx index 9043c8b8e8..75ebe018b1 100644 --- a/ui/components/providers/provider-info.tsx +++ b/ui/components/providers/provider-info.tsx @@ -1,60 +1,32 @@ -import { Tooltip } from "@heroui/tooltip"; - import { ProviderType } from "@/types"; -import { ConnectionFalse, ConnectionPending, ConnectionTrue } from "../icons"; import { getProviderLogo } from "../ui/entities"; interface ProviderInfoProps { - connected: boolean | null; provider: ProviderType; - providerAlias: string; + providerAlias: string | null; providerUID?: string; } export const ProviderInfo = ({ - connected, provider, providerAlias, providerUID, }: ProviderInfoProps) => { - const getIcon = () => { - switch (connected) { - case true: - return ( - -
- -
-
- ); - case false: - return ( - -
- -
-
- ); - case null: - return ( - -
- -
-
- ); - default: - return ; - } - }; - return ( -
-
+
+
{getProviderLogo(provider)}
- {getIcon()} - {providerAlias || providerUID} +
+ + {providerAlias || providerUID} + + {providerUID && ( + + UID: {providerUID} + + )} +
); diff --git a/ui/components/providers/providers-accounts-table.tsx b/ui/components/providers/providers-accounts-table.tsx new file mode 100644 index 0000000000..212fd475c9 --- /dev/null +++ b/ui/components/providers/providers-accounts-table.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { RowSelectionState } from "@tanstack/react-table"; +import { useEffect, useState } from "react"; + +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps } from "@/types"; +import { + isProvidersOrganizationRow, + ProvidersTableRow, +} from "@/types/providers-table"; + +import { getColumnProviders } from "./table"; + +interface ProvidersAccountsTableProps { + isCloud: boolean; + metadata?: MetaDataProps; + rows: ProvidersTableRow[]; +} + +function computeTestableProviderIds( + rows: ProvidersTableRow[], + rowSelection: RowSelectionState, +): string[] { + const ids: string[] = []; + + function walk(items: ProvidersTableRow[], prefix: string) { + items.forEach((item, idx) => { + const key = prefix ? `${prefix}.${idx}` : `${idx}`; + if ( + rowSelection[key] && + !isProvidersOrganizationRow(item) && + item.relationships.secret.data + ) { + ids.push(item.id); + } + if (item.subRows) { + walk(item.subRows, key); + } + }); + } + + walk(rows, ""); + return ids; +} + +export function ProvidersAccountsTable({ + isCloud, + metadata, + rows, +}: ProvidersAccountsTableProps) { + const [rowSelection, setRowSelection] = useState({}); + + // Reset selection when page changes + const currentPage = metadata?.pagination?.page; + useEffect(() => { + setRowSelection({}); + }, [currentPage]); + + const testableProviderIds = computeTestableProviderIds(rows, rowSelection); + + const clearSelection = () => setRowSelection({}); + + const columns = getColumnProviders( + rowSelection, + testableProviderIds, + clearSelection, + ); + + return ( + row.subRows} + defaultExpanded={isCloud} + showSearch + enableRowSelection + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + enableSubRowSelection + /> + ); +} diff --git a/ui/components/providers/providers-filters.tsx b/ui/components/providers/providers-filters.tsx new file mode 100644 index 0000000000..b0eaf30e72 --- /dev/null +++ b/ui/components/providers/providers-filters.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; + +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + MultiSelectSelectAll, + MultiSelectSeparator, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { EntityInfo } from "@/components/ui/entities/entity-info"; +import { useUrlFilters } from "@/hooks/use-url-filters"; +import { isConnectionStatus, isGroupFilterEntity } from "@/lib/helper-filters"; +import { FilterEntity, FilterOption, ProviderEntity } from "@/types"; +import { + GroupFilterEntity, + ProviderConnectionStatus, + ProviderProps, +} from "@/types/providers"; + +interface ProvidersFiltersProps { + filters: FilterOption[]; + providers: ProviderProps[]; + actions?: ReactNode; +} + +export const ProvidersFilters = ({ + filters, + providers, + actions, +}: ProvidersFiltersProps) => { + const { updateFilter } = useUrlFilters(); + const searchParams = useSearchParams(); + + const sortedFilters = [...filters].sort((a, b) => { + if (a.index !== undefined && b.index !== undefined) + return a.index - b.index; + if (a.index !== undefined) return -1; + if (b.index !== undefined) return 1; + return 0; + }); + + const getSelectedValues = (filter: FilterOption): string[] => { + const filterKey = filter.key.startsWith("filter[") + ? filter.key + : `filter[${filter.key}]`; + const paramValue = searchParams.get(filterKey); + if (!paramValue && filter.defaultToSelectAll) return filter.values; + return paramValue ? paramValue.split(",") : []; + }; + + const pushDropdownFilter = (filter: FilterOption, values: string[]) => { + const allSelected = + filter.values.length > 0 && values.length === filter.values.length; + if (filter.defaultToSelectAll && allSelected) { + updateFilter(filter.key, null); + return; + } + updateFilter(filter.key, values.length > 0 ? values : null); + }; + + const getEntityForValue = ( + filter: FilterOption, + value: string, + ): FilterEntity | undefined => { + if (!filter.valueLabelMapping) return undefined; + const entry = filter.valueLabelMapping.find((mapping) => mapping[value]); + return entry ? entry[value] : undefined; + }; + + const getBadgeLabel = ( + entity: FilterEntity | undefined, + value: string, + ): string => { + if (!entity) return value; + if (isConnectionStatus(entity)) { + return (entity as ProviderConnectionStatus).label; + } + if (isGroupFilterEntity(entity)) { + return (entity as GroupFilterEntity).name || value; + } + const providerEntity = entity as ProviderEntity; + return providerEntity.alias || providerEntity.uid || value; + }; + + const renderEntityContent = (entity: FilterEntity) => { + if (isConnectionStatus(entity)) { + return {(entity as ProviderConnectionStatus).label}; + } + if (isGroupFilterEntity(entity)) { + return {(entity as GroupFilterEntity).name}; + } + const providerEntity = entity as ProviderEntity; + return ( + + ); + }; + + return ( +
+
+ +
+ {sortedFilters.map((filter) => { + const selectedValues = getSelectedValues(filter); + return ( +
+ pushDropdownFilter(filter, values)} + > + + + + + Select All + + {filter.values.map((value) => { + const entity = getEntityForValue(filter, value); + const displayLabel = filter.labelFormatter + ? filter.labelFormatter(value) + : value; + return ( + + {entity ? renderEntityContent(entity) : displayLabel} + + ); + })} + + +
+ ); + })} + + {actions &&
{actions}
} +
+ ); +}; diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 47748f22ac..095722a59e 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -1,11 +1,27 @@ "use client"; -import { Chip } from "@heroui/chip"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; +import { + Building2, + FolderTree, + ShieldAlert, + ShieldCheck, + ShieldOff, +} from "lucide-react"; -import { DateWithTime, SnippetChip } from "@/components/ui/entities"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; +import { DateWithTime } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; -import { ProviderProps } from "@/types"; +import { DataTableExpandAllToggle } from "@/components/ui/table/data-table-expand-all-toggle"; +import { DataTableExpandableCell } from "@/components/ui/table/data-table-expandable-cell"; +import { + isProvidersOrganizationRow, + PROVIDERS_GROUP_KIND, + ProvidersOrganizationRow, + ProvidersProviderRow, + ProvidersTableRow, +} from "@/types/providers-table"; import { LinkToScans } from "../link-to-scans"; import { ProviderInfo } from "../provider-info"; @@ -15,117 +31,343 @@ interface GroupNameChipsProps { groupNames?: string[]; } -const getProviderData = (row: { original: ProviderProps }) => { - const provider = row.original; - return { - attributes: provider.attributes, - groupNames: provider.groupNames, - }; -}; +interface OrganizationCellProps { + organization: ProvidersOrganizationRow; + selectionLabel?: string; +} -export const ColumnProviders: ColumnDef[] = [ - { - accessorKey: "account", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { connection, provider, alias, uid }, - } = getProviderData(row); - return ( - - ); - }, - }, - { - accessorKey: "scanJobs", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { uid }, - } = getProviderData(row); - return ; - }, - enableSorting: false, - }, - { - accessorKey: "groupNames", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { groupNames } = getProviderData(row); - return ; - }, - enableSorting: false, - }, - { - accessorKey: "uid", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { uid }, - } = getProviderData(row); - return ; - }, - }, - { - accessorKey: "added", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { inserted_at }, - } = getProviderData(row); - return ; - }, - }, - { - id: "actions", - header: ({ column }) => , - cell: ({ row }) => { - return ; - }, - enableSorting: false, - }, -]; +const OrganizationCell = ({ + organization, + selectionLabel, +}: OrganizationCellProps) => { + const Icon = + organization.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION + ? Building2 + : FolderTree; -export const GroupNameChips: React.FC = ({ - groupNames, -}) => { return ( -
- {groupNames?.map((name, index) => ( - - {name} - - ))} +
+
+ +
+
+
+ {organization.name} + {selectionLabel && ( + + ({selectionLabel}) + + )} +
+ {organization.externalId && ( + + UID: {organization.externalId} + + )} +
); }; + +const ProviderStatusCell = ({ connected }: { connected: boolean | null }) => { + if (connected === true) { + return ( +
+ + Connected +
+ ); + } + + if (connected === false) { + return ( +
+ + Connection failed +
+ ); + } + + return ( +
+ + Not connected +
+ ); +}; + +function getSelectionLabel(row: Row): string | undefined { + const isSelected = row.getIsSelected(); + const isSomeSelected = row.getIsSomeSelected(); + + if (!isSelected && !isSomeSelected) return undefined; + + const subRows = row.subRows ?? []; + const totalLeaves = countLeaves(subRows); + const selectedLeaves = countSelectedLeaves(subRows); + + return `${selectedLeaves.toLocaleString()} of ${totalLeaves.toLocaleString()} Selected`; +} + +function countLeaves(rows: Row[]): number { + let count = 0; + for (const row of rows) { + if (row.subRows && row.subRows.length > 0) { + count += countLeaves(row.subRows); + } else { + count++; + } + } + return count; +} + +function countSelectedLeaves(rows: Row[]): number { + let count = 0; + for (const row of rows) { + if (row.subRows && row.subRows.length > 0) { + count += countSelectedLeaves(row.subRows); + } else if (row.getIsSelected()) { + count++; + } + } + return count; +} + +export function getColumnProviders( + rowSelection: RowSelectionState, + testableProviderIds: string[], + onClearSelection: () => void, +): ColumnDef[] { + return [ + { + id: "account", + size: 420, + accessorFn: (row) => + isProvidersOrganizationRow(row) ? row.name : row.attributes.alias, + header: ({ column, table }) => ( +
+ + + table.toggleAllPageRowsSelected(checked === true) + } + onClick={(e) => e.stopPropagation()} + aria-label="Select all" + /> +
+ +
+
+ ), + cell: ({ row }) => { + const isExpanded = row.getIsExpanded(); + + const checkboxSlot = ( + row.toggleSelected(checked === true)} + onClick={(e) => e.stopPropagation()} + aria-label="Select row" + /> + ); + + if (isProvidersOrganizationRow(row.original)) { + return ( + + + + ); + } + + const provider = row.original; + + return ( + + + + ); + }, + }, + { + accessorKey: "groupNames", + size: 160, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (isProvidersOrganizationRow(row.original)) { + return ( + + {row.original.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION + ? "Organization" + : "Organizational Unit"} + + ); + } + + return ; + }, + enableSorting: false, + }, + { + id: "lastScan", + size: 160, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (isProvidersOrganizationRow(row.original)) { + return -; + } + + const lastCheckedAt = (row.original as ProvidersProviderRow).attributes + .connection.last_checked_at; + + if (!lastCheckedAt) { + return ( + Never + ); + } + + return ; + }, + enableSorting: false, + }, + { + id: "scanSchedule", + size: 140, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (isProvidersOrganizationRow(row.original)) { + return ( + + {row.original.providerCount} Accounts + + ); + } + + return ( + + ); + }, + enableSorting: false, + }, + { + id: "status", + size: 170, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (isProvidersOrganizationRow(row.original)) { + return -; + } + + return ( + + ); + }, + }, + { + accessorKey: "added", + size: 140, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (isProvidersOrganizationRow(row.original)) { + return -; + } + + return ( + + ); + }, + }, + { + id: "actions", + size: 56, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const hasSelection = Object.values(rowSelection).some(Boolean); + + return ( + + ); + }, + enableSorting: false, + }, + ]; +} + +export function GroupNameChips({ groupNames }: GroupNameChipsProps) { + if (!groupNames || groupNames.length === 0) { + return ( + No groups + ); + } + + return ( +
+ {groupNames.map((name, index) => ( + + {name} + + ))} +
+ ); +} diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx new file mode 100644 index 0000000000..7405d27ec1 --- /dev/null +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -0,0 +1,103 @@ +import { Row } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { PROVIDERS_ROW_TYPE } from "@/types/providers-table"; + +const checkConnectionProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/actions/providers/providers", () => ({ + checkConnectionProvider: checkConnectionProviderMock, +})); + +vi.mock("@/components/providers/wizard", () => ({ + ProviderWizardModal: () => null, +})); + +vi.mock("../forms", () => ({ + EditForm: () => null, +})); + +vi.mock("../forms/delete-form", () => ({ + DeleteForm: () => null, +})); + +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/lib/provider-helpers", () => ({ + testProviderConnection: vi.fn(), +})); + +import { DataTableRowActions } from "./data-table-row-actions"; + +const createRow = () => + ({ + original: { + id: "provider-1", + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + type: "providers", + attributes: { + provider: "aws", + uid: "111111111111", + alias: "AWS App Account", + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2025-02-13T11:17:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2025-02-13T11:17:00Z", + updated_at: "2025-02-13T11:17:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + groupNames: [], + }, + }) as Row; + +describe("DataTableRowActions", () => { + it("renders the exact phase 1 menu actions for provider rows", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument(); + expect(screen.getByText("Update Credentials")).toBeInTheDocument(); + expect(screen.getByText("Test Connection")).toBeInTheDocument(); + expect(screen.getByText("Delete Provider")).toBeInTheDocument(); + expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index adbb5f0e9d..18aea03937 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -1,10 +1,9 @@ "use client"; import { Row } from "@tanstack/react-table"; -import { Pencil, PlugZap, Trash2 } from "lucide-react"; +import { KeyRound, Pencil, Rocket, Trash2 } from "lucide-react"; import { useState } from "react"; -import { checkConnectionProvider } from "@/actions/providers/providers"; import { VerticalDotsIcon } from "@/components/icons"; import { ProviderWizardModal } from "@/components/providers/wizard"; import { Button } from "@/components/shadcn"; @@ -14,38 +13,142 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Modal } from "@/components/shadcn/modal"; +import { useToast } from "@/components/ui"; +import { runWithConcurrencyLimit } from "@/lib/concurrency"; +import { testProviderConnection } from "@/lib/provider-helpers"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; -import { ProviderProps } from "@/types/providers"; +import { + isProvidersOrganizationRow, + ProvidersTableRow, +} from "@/types/providers-table"; import { EditForm } from "../forms"; import { DeleteForm } from "../forms/delete-form"; interface DataTableRowActionsProps { - row: Row; + row: Row; + /** Whether any rows in the table are currently selected */ + hasSelection: boolean; + /** Whether this specific row is selected */ + isRowSelected: boolean; + /** IDs of all selected providers that have credentials (testable) */ + testableProviderIds: string[]; + /** Callback to clear the row selection after bulk operation */ + onClearSelection: () => void; } -export function DataTableRowActions({ row }: DataTableRowActionsProps) { +export function DataTableRowActions({ + row, + hasSelection, + isRowSelected, + testableProviderIds, + onClearSelection, +}: DataTableRowActionsProps) { const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); const [loading, setLoading] = useState(false); - const provider = row.original; - const providerId = provider.id; - const providerType = provider.attributes.provider; - const providerUid = provider.attributes.uid; - const providerAlias = provider.attributes.alias ?? null; - const providerSecretId = provider.relationships.secret.data?.id ?? null; + const { toast } = useToast(); + + const rowData = row.original; + const isOrganizationRow = isProvidersOrganizationRow(rowData); + const provider = isOrganizationRow ? null : rowData; + const providerId = provider?.id ?? ""; + const providerType = provider?.attributes.provider ?? "aws"; + const providerUid = provider?.attributes.uid ?? ""; + const providerAlias = provider?.attributes.alias ?? null; + const providerSecretId = provider?.relationships.secret.data?.id ?? null; + const hasSecret = Boolean(provider?.relationships.secret.data); const handleTestConnection = async () => { - setLoading(true); - const formData = new FormData(); - formData.append("providerId", providerId); - await checkConnectionProvider(formData); - setLoading(false); + if (hasSelection && isRowSelected) { + // Bulk: test all selected providers + if (testableProviderIds.length === 0) return; + setLoading(true); + + const results = await runWithConcurrencyLimit( + testableProviderIds, + 10, + async (id) => { + try { + return await testProviderConnection(id); + } catch { + return { connected: false, error: "Unexpected error" }; + } + }, + ); + + const succeeded = results.filter((r) => r.connected).length; + const failed = results.length - succeeded; + + if (failed === 0) { + toast({ + title: "Connection test completed", + description: `${succeeded} ${succeeded === 1 ? "provider" : "providers"} tested successfully.`, + }); + } else { + toast({ + variant: "destructive", + title: "Connection test completed", + description: `${succeeded} succeeded, ${failed} failed out of ${results.length} providers.`, + }); + } + + setLoading(false); + onClearSelection(); + } else { + // Single: test only this provider + if (!providerId) return; + setLoading(true); + const result = await testProviderConnection(providerId); + setLoading(false); + + if (!result.connected) { + toast({ + variant: "destructive", + title: "Connection test failed", + description: result.error ?? "Unknown error", + }); + } else { + toast({ + title: "Connection test completed", + description: "Provider tested successfully.", + }); + } + } }; - const hasSecret = Boolean(provider.relationships.secret.data); + // When this row is part of the selection, only show "Test Connection" + if (hasSelection && isRowSelected) { + const bulkCount = + testableProviderIds.length > 1 ? ` (${testableProviderIds.length})` : ""; + return ( +
+ + + + } + > + } + label={loading ? "Testing..." : `Test Connection${bulkCount}`} + onSelect={(e) => { + e.preventDefault(); + handleTestConnection(); + }} + disabled={ + isOrganizationRow || testableProviderIds.length === 0 || loading + } + /> + +
+ ); + } + + // Normal mode: all actions return ( <> - + {provider && ( + + )} - + {provider && ( + + )} - + } > } - label={hasSecret ? "Update Credentials" : "Add Credentials"} - onSelect={() => setIsWizardOpen(true)} + label="Edit Provider Alias" + onSelect={() => setIsEditOpen(true)} + disabled={isOrganizationRow} /> } + icon={} + label="Update Credentials" + onSelect={() => setIsWizardOpen(true)} + disabled={isOrganizationRow} + /> + } label={loading ? "Testing..." : "Test Connection"} - description={ - hasSecret && !loading - ? "Check the provider connection" - : loading - ? "Checking provider connection" - : "Add credentials to test the connection" - } onSelect={(e) => { e.preventDefault(); handleTestConnection(); }} - disabled={!hasSecret || loading} - /> - } - label="Edit Provider Alias" - onSelect={() => setIsEditOpen(true)} + disabled={isOrganizationRow || !hasSecret || loading} /> setIsDeleteOpen(true)} + disabled={isOrganizationRow} /> diff --git a/ui/components/providers/table/skeleton-table-provider.tsx b/ui/components/providers/table/skeleton-table-provider.tsx index 4476919840..7d57112291 100644 --- a/ui/components/providers/table/skeleton-table-provider.tsx +++ b/ui/components/providers/table/skeleton-table-provider.tsx @@ -1,39 +1,124 @@ -import React from "react"; - -import { Card } from "@/components/shadcn/card/card"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -export const SkeletonTableProviders = () => { +const SkeletonTableRow = () => { return ( - - {/* Table headers */} -
- - - - - - - -
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- - - - - - - + + {/* Account: provider logo + alias + UID */} + +
+ +
+ +
- ))} -
- +
+ + {/* Account Groups: badge chips */} + +
+ + +
+ + {/* Last Scan: date + time */} + +
+ + +
+ + {/* Scan Schedule */} + + + + {/* Status: icon + text */} + +
+ + +
+ + {/* Added: date + time */} + +
+ + +
+ + {/* Actions */} + + + + + ); +}; + +export const SkeletonTableProviders = () => { + const rows = 10; + + return ( +
+ {/* Toolbar: Search + Total entries */} +
+ + +
+ + {/* Table */} + + + + {/* Account */} + + {/* Account Groups */} + + {/* Last Scan */} + + {/* Scan Schedule */} + + {/* Status */} + + {/* Added */} + + {/* Actions - empty header */} + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ + + + + + + + + + + + +
+ + {/* Pagination */} +
+
+ + +
+
+ +
+ + + + +
+
+
+
); }; diff --git a/ui/components/providers/wizard/steps/credentials-step.test.tsx b/ui/components/providers/wizard/steps/credentials-step.test.tsx index 7c662f70b9..7f4610b212 100644 --- a/ui/components/providers/wizard/steps/credentials-step.test.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.test.tsx @@ -42,6 +42,19 @@ 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(); diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index 2ee612701b..3d6c710b19 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -9,18 +9,13 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { - checkConnectionProvider, - deleteCredentials, -} from "@/actions/providers"; -import { getTask } from "@/actions/task/tasks"; +import { deleteCredentials } from "@/actions/providers"; import { CheckIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/ui"; import { Form } from "@/components/ui/form"; -import { checkTaskStatus } from "@/lib/helper"; +import { testProviderConnection } from "@/lib/provider-helpers"; import { ProviderType } from "@/types"; -import { ApiError, testConnectionFormSchema } from "@/types"; +import { testConnectionFormSchema } from "@/types"; import { ProviderConnectionInfo } from "./provider-connection-info"; @@ -70,7 +65,6 @@ export const TestConnectionForm = ({ hideActions = false, onLoadingChange, }: TestConnectionFormProps) => { - const { toast } = useToast(); const router = useRouter(); const providerId = searchParams.id; @@ -99,70 +93,23 @@ export const TestConnectionForm = ({ }, [isLoading, isResettingCredentials, onLoadingChange]); const onSubmitClient = async (values: FormValues) => { - const formData = new FormData(); - formData.append("providerId", values.providerId); + setApiErrorMessage(null); - const data = await checkConnectionProvider(formData); + const result = await testProviderConnection(values.providerId); - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; + if (result.error === "Not found.") { + setApiErrorMessage(result.error); + return; + } - switch (errorMessage) { - case "Not found.": - setApiErrorMessage(errorMessage); - break; - default: - toast({ - variant: "destructive", - title: `Error ${error.status}`, - description: errorMessage, - }); - } - }); - } else { - const taskId = data.data.id; - setApiErrorMessage(null); + setConnectionStatus(result); - // Use the helper function to check the task status - const taskResult = await checkTaskStatus(taskId); - - if (taskResult.completed) { - const task = await getTask(taskId); - const { connected, error } = task.data.attributes.result; - - setConnectionStatus({ - connected, - error: connected ? null : error || "Unknown error", - }); - - if (connected && isUpdated) { - if (onSuccess) { - onSuccess(); - return; - } - return router.push("/providers"); - } - - if (connected && !isUpdated) { - if (onSuccess) { - onSuccess(); - return; - } - - return router.push("/providers"); - } else { - setConnectionStatus({ - connected: false, - error: error || "Connection failed, please review credentials.", - }); - } - } else { - setConnectionStatus({ - connected: false, - error: taskResult.error || "Unknown error", - }); + if (result.connected) { + if (onSuccess) { + onSuccess(); + return; } + return router.push("/providers"); } }; diff --git a/ui/components/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index 58b9b86035..53d66989c2 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -121,7 +121,7 @@ export function MultiSelectTrigger({ data-slot="multiselect-trigger" data-size={size} className={cn( - "border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6", + "border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6", className, )} > diff --git a/ui/components/ui/table/data-table-animated-row.tsx b/ui/components/ui/table/data-table-animated-row.tsx index 8ea153df4d..e8098e7672 100644 --- a/ui/components/ui/table/data-table-animated-row.tsx +++ b/ui/components/ui/table/data-table-animated-row.tsx @@ -7,6 +7,10 @@ import { cn } from "@/lib/utils"; interface DataTableAnimatedRowProps { row: Row; + /** Pass explicitly to break React Compiler memoization when selection changes */ + isSelected?: boolean; + /** Pass explicitly to break React Compiler memoization for partial selection */ + isSomeSelected?: boolean; } /** @@ -24,7 +28,12 @@ interface DataTableAnimatedRowProps { */ export function DataTableAnimatedRow({ row, + isSelected, + isSomeSelected, }: DataTableAnimatedRowProps) { + "use no memo"; + void isSomeSelected; + return ( ({ collapsed: { opacity: 0 }, }} transition={{ duration: 0.2 }} - data-state={row.getIsSelected() ? "selected" : undefined} + data-state={isSelected ? "selected" : undefined} className={cn( "transition-colors", "[&>td:first-child]:rounded-l-full [&>td:last-child]:rounded-r-full", @@ -46,6 +55,12 @@ export function DataTableAnimatedRow({ {row.getVisibleCells().map((cell: Cell, index, cells) => { const isFirst = index === 0; const isLast = index === cells.length - 1; + const cellDef = cell.column.columnDef.cell; + const cellContext = cell.getContext(); + const cellContent = + typeof cellDef === "function" + ? cellDef(cellContext) + : flexRender(cellDef, cellContext); return ( @@ -80,7 +95,7 @@ export function DataTableAnimatedRow({ isLast && "pr-3", )} > - {flexRender(cell.column.columnDef.cell, cell.getContext())} + {cellContent}
diff --git a/ui/components/ui/table/data-table-expand-all-toggle.tsx b/ui/components/ui/table/data-table-expand-all-toggle.tsx index c53782f451..44dfc9ed96 100644 --- a/ui/components/ui/table/data-table-expand-all-toggle.tsx +++ b/ui/components/ui/table/data-table-expand-all-toggle.tsx @@ -52,7 +52,7 @@ export function DataTableExpandAllToggle({ diff --git a/ui/components/ui/table/data-table-expandable-cell.tsx b/ui/components/ui/table/data-table-expandable-cell.tsx index 78bfa651cf..8121e81b30 100644 --- a/ui/components/ui/table/data-table-expandable-cell.tsx +++ b/ui/components/ui/table/data-table-expandable-cell.tsx @@ -5,7 +5,11 @@ import { CornerDownRightIcon } from "lucide-react"; import { DataTableExpandToggle } from "./data-table-expand-toggle"; -/** Indentation per nesting level in rem units */ +/** + * Indentation per nesting level in rem units. + * Matches the parent's icon (w-4 = 1rem) + gap-2 (0.5rem) = 1.5rem, + * so the child's first icon aligns horizontally with the parent's checkbox. + */ const INDENT_PER_LEVEL_REM = 1.5; interface DataTableExpandableCellProps { @@ -13,6 +17,12 @@ interface DataTableExpandableCellProps { children: React.ReactNode; /** Whether to show the expand/collapse toggle (default: true) */ showToggle?: boolean; + /** Explicit expanded state — pass to break React Compiler memoization */ + isExpanded?: boolean; + /** Hide the CornerDownRight icon even for child rows (e.g. OUs that can expand) */ + hideChildIcon?: boolean; + /** Optional slot rendered after expand arrows and before children (e.g. checkbox) */ + checkboxSlot?: React.ReactNode; } /** @@ -43,26 +53,33 @@ export function DataTableExpandableCell({ row, children, showToggle = true, + isExpanded, + hideChildIcon = false, + checkboxSlot, }: DataTableExpandableCellProps) { const isChildRow = row.depth > 0; const canExpand = row.getCanExpand(); return (
{showToggle && ( <> - {canExpand ? ( - - ) : isChildRow ? ( - - ) : ( -
+ {isChildRow && !hideChildIcon && ( + )} + {canExpand ? ( + + ) : !isChildRow ? ( +
+ ) : null} )} + {checkboxSlot && ( +
{checkboxSlot}
+ )} {children}
); diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index 3fbdd780cc..4548fc22b8 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -255,7 +255,12 @@ export function DataTable({ {rows?.length ? ( rows.map((row) => getSubRows && row.depth > 0 ? ( - + ) : ( { + return !!( + entity && + "name" in entity && + !("provider" in entity) && + !("label" in entity) + ); +}; + /** * Connection status mapping for provider filters. * Maps boolean string values to user-friendly labels. diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts index 6881201089..efabbb2df9 100644 --- a/ui/lib/menu-list.ts +++ b/ui/lib/menu-list.ts @@ -2,7 +2,6 @@ import { CloudCog, Cog, GitBranch, - Group, Mail, MessageCircleQuestion, Puzzle, @@ -115,7 +114,6 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => { icon: VolumeX, active: pathname === "/mutelist", }, - { href: "/manage-groups", label: "Provider Groups", icon: Group }, { href: "/scans", label: "Scan Jobs", icon: Timer }, { href: "/integrations", label: "Integrations", icon: Puzzle }, { href: "/roles", label: "Roles", icon: UserCog }, diff --git a/ui/lib/provider-helpers.ts b/ui/lib/provider-helpers.ts index 95ba5c86c4..eedda4f776 100644 --- a/ui/lib/provider-helpers.ts +++ b/ui/lib/provider-helpers.ts @@ -1,3 +1,5 @@ +import { checkConnectionProvider } from "@/actions/providers/providers"; +import { getTask } from "@/actions/task/tasks"; import { ProviderEntity, ProviderProps, @@ -5,6 +7,8 @@ import { ProviderType, } from "@/types/providers"; +import { checkTaskStatus } from "./helper"; + export const extractProviderUIDs = ( providersData: ProvidersApiResponse, ): string[] => { @@ -167,3 +171,52 @@ export const requiresBackButton = (via?: string | null): boolean => { return validViaTypes.includes(via); }; + +export interface TestConnectionResult { + connected: boolean; + error: string | null; +} + +/** + * Tests a provider's connection end-to-end: submits the task, polls until + * completion, and returns the real connection result. + * + * Used by both the Provider Wizard (single) and bulk test (via concurrency limit). + */ +export async function testProviderConnection( + providerId: string, +): Promise { + const formData = new FormData(); + formData.append("providerId", providerId); + + const data = await checkConnectionProvider(formData); + + if (data?.errors && data.errors.length > 0) { + return { + connected: false, + error: data.errors[0]?.detail ?? "Unknown error", + }; + } + + const taskId = data?.data?.id; + if (!taskId) { + return { connected: false, error: "No task ID returned" }; + } + + const taskResult = await checkTaskStatus(taskId); + + if (!taskResult.completed) { + return { + connected: false, + error: taskResult.error ?? "Connection test timed out", + }; + } + + const task = await getTask(taskId); + const { connected, error } = task.data.attributes.result; + + return { + connected, + error: connected ? null : error || "Unknown error", + }; +} diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 117590dc33..55a0ed21ab 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -316,12 +316,12 @@ export class ProvidersPage extends BasePage { // Button to add a new cloud provider this.addProviderButton = page .getByRole("button", { - name: "Add Cloud Provider", + name: "Add Provider", exact: true, }) .or( page.getByRole("link", { - name: "Add Cloud Provider", + name: "Add Provider", exact: true, }), ); diff --git a/ui/types/filters.ts b/ui/types/filters.ts index ee58e6edcf..b8e8105a81 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -1,10 +1,15 @@ -import { ProviderConnectionStatus, ProviderEntity } from "./providers"; +import { + GroupFilterEntity, + ProviderConnectionStatus, + ProviderEntity, +} from "./providers"; import { ScanEntity } from "./scans"; export type FilterEntity = | ProviderEntity | ScanEntity - | ProviderConnectionStatus; + | ProviderConnectionStatus + | GroupFilterEntity; export interface FilterOption { key: string; diff --git a/ui/types/index.ts b/ui/types/index.ts index d10da8be0c..7832c7b8d8 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -6,6 +6,7 @@ export * from "./organizations"; export * from "./processors"; export * from "./provider-wizard"; export * from "./providers"; +export * from "./providers-table"; export * from "./resources"; export * from "./scans"; export * from "./tree"; diff --git a/ui/types/organizations.ts b/ui/types/organizations.ts index 2a6626f957..008179a442 100644 --- a/ui/types/organizations.ts +++ b/ui/types/organizations.ts @@ -148,10 +148,60 @@ export interface OrganizationAttributes { updated_at?: string; } +interface OrganizationRelationshipRef { + data: Array<{ id: string; type: T }>; +} + +interface OrganizationRelationships { + providers?: OrganizationRelationshipRef<"providers">; + organizational_units?: OrganizationRelationshipRef<"organizational-units">; +} + export interface OrganizationResource { id: string; type: "organizations"; attributes: OrganizationAttributes; + relationships?: OrganizationRelationships; +} + +export interface OrganizationListResponse { + data: OrganizationResource[]; + meta?: { + version?: string; + }; +} + +export interface OrganizationUnitAttributes { + name: string; + external_id: string; + parent_external_id: string | null; + metadata: Record; + inserted_at?: string; + updated_at?: string; +} + +export interface OrganizationUnitRelationships { + organization: { + data: { id: string; type: "organizations" }; + }; + parent?: { + data: { id: string; type: "organizational-units" } | null; + }; + providers?: OrganizationRelationshipRef<"providers">; +} + +export interface OrganizationUnitResource { + id: string; + type: "organizational-units"; + attributes: OrganizationUnitAttributes; + relationships: OrganizationUnitRelationships; +} + +export interface OrganizationUnitListResponse { + data: OrganizationUnitResource[]; + meta?: { + version?: string; + }; } export interface DiscoveryAttributes { diff --git a/ui/types/providers-table.ts b/ui/types/providers-table.ts new file mode 100644 index 0000000000..cad6f37ec7 --- /dev/null +++ b/ui/types/providers-table.ts @@ -0,0 +1,98 @@ +import { MetaDataProps } from "./components"; +import { FilterOption } from "./filters"; +import { + OrganizationResource, + OrganizationUnitResource, +} from "./organizations"; +import { ProviderProps } from "./providers"; + +export const PROVIDERS_ROW_TYPE = { + ORGANIZATION: "organization", + PROVIDER: "provider", +} as const; + +export type ProvidersRowType = + (typeof PROVIDERS_ROW_TYPE)[keyof typeof PROVIDERS_ROW_TYPE]; + +export const PROVIDERS_GROUP_KIND = { + ORGANIZATION: "organization", + ORGANIZATION_UNIT: "organization-unit", +} as const; + +export type ProvidersGroupKind = + (typeof PROVIDERS_GROUP_KIND)[keyof typeof PROVIDERS_GROUP_KIND]; + +export const PROVIDERS_PAGE_FILTER = { + PROVIDER: "provider__in", + PROVIDER_TYPE: "provider_type__in", + ORGANIZATION: "organization_id__in", + ACCOUNT_GROUP: "provider_group_id__in", + STATUS: "connected", +} as const; + +export type ProvidersPageFilter = + (typeof PROVIDERS_PAGE_FILTER)[keyof typeof PROVIDERS_PAGE_FILTER]; + +export interface ProviderTableRelationshipData { + id: string; + type: string; +} + +export interface ProviderTableRelationshipRef { + data: ProviderTableRelationshipData | null; +} + +export type ProviderTableRelationships = ProviderProps["relationships"] & { + organization?: ProviderTableRelationshipRef; + organization_unit?: ProviderTableRelationshipRef; + organizational_unit?: ProviderTableRelationshipRef; +}; + +export interface ProvidersProviderRow + extends Omit { + rowType: typeof PROVIDERS_ROW_TYPE.PROVIDER; + relationships: ProviderTableRelationships; + groupNames: string[]; + hasSchedule: boolean; + subRows?: ProvidersTableRow[]; +} + +export interface ProvidersOrganizationRow { + id: string; + rowType: typeof PROVIDERS_ROW_TYPE.ORGANIZATION; + groupKind: ProvidersGroupKind; + name: string; + externalId: string | null; + parentExternalId: string | null; + organizationId: string | null; + providerCount: number; + subRows: ProvidersTableRow[]; +} + +export type ProvidersTableRow = ProvidersOrganizationRow | ProvidersProviderRow; + +export interface ProvidersTableRowsInput { + isCloud: boolean; + organizations: OrganizationResource[]; + organizationUnits: OrganizationUnitResource[]; + providers: ProvidersProviderRow[]; +} + +export interface ProvidersAccountsViewData { + filters: FilterOption[]; + metadata?: MetaDataProps; + providers: ProviderProps[]; + rows: ProvidersTableRow[]; +} + +export function isProvidersOrganizationRow( + row: ProvidersTableRow, +): row is ProvidersOrganizationRow { + return row.rowType === PROVIDERS_ROW_TYPE.ORGANIZATION; +} + +export function isProvidersProviderRow( + row: ProvidersTableRow, +): row is ProvidersProviderRow { + return row.rowType === PROVIDERS_ROW_TYPE.PROVIDER; +} diff --git a/ui/types/providers.ts b/ui/types/providers.ts index aa04b8dd23..6e6a2c79c6 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -88,6 +88,11 @@ export interface ProviderEntity { alias: string | null; } +export interface GroupFilterEntity { + name: string; + uid: string; +} + export interface ProviderConnectionStatus { label: string; value: string;