feat(ui): redesign providers page with modern table and cloud recursion (#10292)

This commit is contained in:
Alejandro Bailo
2026-03-11 13:13:28 +01:00
committed by GitHub
parent 65a7098104
commit db7554c8fb
46 changed files with 3361 additions and 626 deletions

View File

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

View File

@@ -22,7 +22,8 @@ export const getProviderGroups = async ({
}): Promise<ProviderGroupsResponse | undefined> => {
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);

View File

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

View File

@@ -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<T extends { data: unknown[] }>(
url: URL,
): Promise<T> {
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<OrganizationListResponse> => {
const url = new URL(`${apiBaseUrl}/organizations`);
url.searchParams.set("filter[org_type]", "aws");
url.searchParams.set("page[size]", "100");
return fetchOptionalCollection<OrganizationListResponse>(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<OrganizationUnitListResponse> => {
const url = new URL(`${apiBaseUrl}/organizational-units`);
url.searchParams.set("page[size]", "100");
return fetchOptionalCollection<OrganizationUnitListResponse>(url);
};
/**
* Creates an organization secret (role-based credentials).
* POST /api/v1/organization-secrets

View File

@@ -186,14 +186,14 @@ export const ProviderTypeSelector = ({
if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType;
return (
<span className="flex items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
{renderIcon(providerType)}
<span>{PROVIDER_DATA[providerType].label}</span>
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
</span>
);
}
return (
<span className="truncate">
<span className="min-w-0 truncate">
{selectedTypes.length} providers selected
</span>
);

View File

@@ -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<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams);
const providerGroupId = resolvedSearchParams.groupId;
const groupId = resolvedSearchParams.groupId;
return (
<div className="grid min-h-[70vh] grid-cols-1 items-center justify-center gap-4 md:grid-cols-12">
<div className="col-span-1 flex justify-end md:col-span-4">
<Suspense key={searchParamsKey} fallback={<SkeletonManageGroups />}>
{providerGroupId ? (
<SSRDataEditGroup searchParams={resolvedSearchParams} />
) : (
<div className="flex flex-col">
<h1 className="mb-2 text-xl font-medium" id="getting-started">
Create a new provider group
</h1>
<p className="text-small text-default-500 mb-5">
Create a new provider group to manage the providers and roles.
</p>
<SSRAddGroupForm />
</div>
)}
</Suspense>
</div>
const target = groupId
? `/providers?tab=account-groups&groupId=${groupId}`
: "/providers?tab=account-groups";
<Divider orientation="vertical" className="mx-auto h-full" />
<div className="col-span-1 flex-col justify-start md:col-span-6">
<FilterControls />
<Spacer y={8} />
<h3 className="mb-4 text-sm font-bold uppercase">Provider Groups</h3>
<Suspense key={searchParamsKey} fallback={<SkeletonManageGroups />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
</div>
);
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 <AddGroupForm providers={providersData} roles={rolesData} />;
};
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 <div>Provider group not found</div>;
}
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 (
<div className="flex flex-col">
<h1 className="mb-2 text-xl font-medium" id="getting-started">
Edit provider group
</h1>
<p className="text-small text-default-500 mb-5">
Edit the provider group to manage the providers and roles.
</p>
<EditGroupForm
providerGroupId={providerGroupId}
providerGroupData={formData}
allProviders={providersList}
allRoles={rolesList}
/>
</div>
);
};
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<string, string> = {};
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 (
<>
<DataTable
key={`groups-${Date.now()}`}
columns={ColumnGroups}
data={providerGroupsData?.data || []}
metadata={providerGroupsData?.meta}
/>
</>
);
};

View File

@@ -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 (
<div className="grid min-h-[50vh] grid-cols-1 items-start gap-8 md:grid-cols-12">
{/* Left: Form (Add or Edit) */}
<div className="col-span-1 md:col-span-4">
{providerGroupId && editGroupData?.data ? (
<EditGroupSection
providerGroupId={
Array.isArray(providerGroupId)
? providerGroupId[0]
: providerGroupId
}
groupData={editGroupData.data}
allProviders={providersList}
allRoles={rolesList}
/>
) : (
<div className="flex flex-col">
<h1 className="mb-2 text-xl font-medium">
Create a new account group
</h1>
<p className="text-text-neutral-tertiary mb-5 text-sm">
Create a new account group to manage the providers and roles.
</p>
<AddGroupForm providers={providersList} roles={rolesList} />
</div>
)}
</div>
{/* Divider */}
<div className="border-border-neutral-secondary hidden md:col-span-1 md:flex md:justify-center">
<div className="border-border-neutral-secondary h-full border-l" />
</div>
{/* Right: Table */}
<div className="col-span-1 md:col-span-7">
<DataTable
columns={ColumnGroups}
data={providerGroupsData?.data || []}
metadata={providerGroupsData?.meta}
/>
</div>
</div>
);
};
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 (
<div className="flex flex-col">
<h1 className="mb-2 text-xl font-medium">Edit account group</h1>
<p className="text-text-neutral-tertiary mb-5 text-sm">
Edit the account group to manage the providers and roles.
</p>
<EditGroupForm
providerGroupId={providerGroupId}
providerGroupData={{
name: attributes.name,
providers: associatedProviders,
roles: associatedRoles,
}}
allProviders={allProviders}
allRoles={allRoles}
/>
</div>
);
};
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<string, string> = {};
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 });
};

View File

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

View File

@@ -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<SearchParamsProps>;
}) {
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 (
<ContentLayout title="Cloud Providers" icon="lucide:cloud-cog">
<div className="flex flex-col gap-6">
<FilterControls search customFilters={filterProviders || []} />
<ProvidersActions />
<Suspense key={searchParamsKey} fallback={<ProvidersTableFallback />}>
<ProvidersTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
<FilterTransitionWrapper>
<ProviderPageTabs
activeTab={activeTab}
accountsContent={
<Suspense
key={`accounts-${searchParamsKey}`}
fallback={<ProvidersTableFallback />}
>
<ProvidersAccountsContent searchParams={resolvedSearchParams} />
</Suspense>
}
accountGroupsContent={
<Suspense
key={`groups-${searchParamsKey}`}
fallback={<AccountGroupsFallback />}
>
<AccountGroupsContent searchParams={resolvedSearchParams} />
</Suspense>
}
/>
</FilterTransitionWrapper>
</ContentLayout>
);
}
@@ -39,7 +59,6 @@ export default async function Providers({
const ProvidersActions = () => {
return (
<div className="flex flex-wrap gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
@@ -48,68 +67,68 @@ const ProvidersActions = () => {
const ProvidersTableFallback = () => {
return (
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-4">
{/* ProviderTypeSelector */}
<Skeleton className="h-[52px] min-w-[200px] flex-1 rounded-lg md:max-w-[280px]" />
{/* Account filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Connection Status filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Action buttons */}
<div className="ml-auto flex flex-wrap gap-4">
<Skeleton className="h-9 w-[160px] rounded-md" />
<Skeleton className="h-9 w-[120px] rounded-md" />
</div>
</div>
<SkeletonTableProviders />
</div>
);
};
const AccountGroupsFallback = () => {
return (
<div className="grid min-h-[50vh] grid-cols-1 items-start gap-8 md:grid-cols-12">
<div className="col-span-1 md:col-span-4">
<div className="flex flex-col gap-4">
<Skeleton className="h-7 w-48 rounded" />
<Skeleton className="h-4 w-64 rounded" />
<Skeleton className="h-10 w-full rounded-md" />
<Skeleton className="h-10 w-full rounded-md" />
<Skeleton className="h-10 w-full rounded-md" />
<Skeleton className="h-10 w-32 rounded-md" />
</div>
</div>
<div className="col-span-1 md:col-span-1" />
<div className="col-span-1 md:col-span-6">
<SkeletonTableProviders />
</div>
</div>
);
};
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<string, string>, 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 (
<>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<DataTable
columns={ColumnProviders}
data={enrichedProviders || []}
metadata={providersData?.meta}
/>
</div>
</div>
</>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={providersView.filters}
providers={providersView.providers}
actions={<ProvidersActions />}
/>
<ProvidersAccountsTable
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
metadata={providersView.metadata}
rows={providersView.rows}
/>
</div>
);
};

View File

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

View File

@@ -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(
<ProviderPageTabs
activeTab={PROVIDER_TAB.ACCOUNT_GROUPS}
accountsContent={<div>Accounts content</div>}
accountGroupsContent={<div>Account groups content</div>}
/>,
);
expect(screen.getByRole("tab", { name: "Account Groups" })).toHaveAttribute(
"data-state",
"active",
);
rerender(
<ProviderPageTabs
activeTab={PROVIDER_TAB.ACCOUNTS}
accountsContent={<div>Accounts content</div>}
accountGroupsContent={<div>Account groups content</div>}
/>,
);
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(
<ProviderPageTabs
activeTab={PROVIDER_TAB.ACCOUNTS}
accountsContent={<div>Accounts content</div>}
accountGroupsContent={<div>Account groups content</div>}
/>,
);
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");
});
});

View File

@@ -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 (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex w-full flex-col gap-6"
>
<TabsList>
<TabsTrigger value={PROVIDER_TAB.ACCOUNTS}>Accounts</TabsTrigger>
<TabsTrigger value={PROVIDER_TAB.ACCOUNT_GROUPS}>
Account Groups
</TabsTrigger>
</TabsList>
<TabsContent value={PROVIDER_TAB.ACCOUNTS} className="mt-0">
{accountsContent}
</TabsContent>
<TabsContent value={PROVIDER_TAB.ACCOUNT_GROUPS} className="mt-0">
{accountGroupsContent}
</TabsContent>
</Tabs>
);
};

View File

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

View File

@@ -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<string, unknown>) &&
(result as Record<string, unknown>).error !== null &&
(result as Record<string, unknown>).error !== undefined,
);
}
async function resolveActionResult<T>(
action: Promise<T | undefined>,
fallback?: T,
): Promise<T | undefined> {
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<string, string> => {
const lookup = new Map<string, string>();
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<string> => {
const scheduled = new Set<string>();
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<string>,
): 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<string, ProvidersProviderRow>;
}): 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<string, ProvidersProviderRow>;
providersByOrganizationUnitId: Map<string, ProvidersProviderRow[]>;
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<string, ProvidersProviderRow[]>();
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<string>();
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<string>();
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<ProvidersAccountsViewData> {
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 };

View File

@@ -15,30 +15,23 @@ export const IacProviderBadge: React.FC<IconSvgProps> = ({
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<path
d="M13 21L17 3"
stroke="currentColor"
strokeWidth="2"
<rect width="256" height="256" fill="#e8eaed" rx="60" />
<g
stroke="#5f6368"
strokeWidth="14"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 8L3 12L7 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17 8L21 12L17 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
>
{/* Slash: / */}
<path d="M112 205L148 51" />
{/* Left bracket: < */}
<path d="M85 85L45 128L85 171" />
{/* Right bracket: > */}
<path d="M171 85L211 128L171 171" />
</g>
</svg>
);

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export const ManageGroupsButton = () => {
<Button asChild variant="outline">
<Link href="/manage-groups">
<SettingsIcon size={20} />
Manage Groups
Account Groups
</Link>
</Button>
);

View File

@@ -49,13 +49,15 @@ export function DataTableRowActions<ProviderProps>({
>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Provider Group"
onSelect={() => router.push(`/manage-groups?groupId=${groupId}`)}
label="Edit Account Group"
onSelect={() =>
router.push(`/providers?tab=account-groups&groupId=${groupId}`)
}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
icon={<Trash2 />}
label="Delete Provider Group"
label="Delete Account Group"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>

View File

@@ -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 (
<>
<Button onClick={() => setOpen(true)}>
Add Cloud Provider
<AddIcon size={20} />
</Button>
<Button onClick={() => setOpen(true)}>Add Provider</Button>
<ProviderWizardModal open={open} onOpenChange={setOpen} />
</>
);

View File

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

View File

@@ -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 (
<Button asChild variant="link" size="sm" className="text-xs">
<Link href={`/scans?filter[provider_uid]=${providerUid}`}>
View Scan Jobs
</Link>
</Button>
<div className="flex items-center gap-1">
<span className="text-text-neutral-secondary text-sm">
{hasSchedule ? "Daily" : "None"}
</span>
<Button asChild variant="link" size="sm" className="text-xs">
<Link href={`/scans?filter[provider_uid]=${providerUid}`}>
View Jobs
</Link>
</Button>
</div>
);
};

View File

@@ -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 (
<Tooltip content="Provider connected" className="text-xs">
<div className="rounded-medium border-system-success bg-system-success-lighter flex items-center justify-center border-2 p-1">
<ConnectionTrue className="text-system-success" size={24} />
</div>
</Tooltip>
);
case false:
return (
<Tooltip content="Provider connection failed" className="text-xs">
<div className="rounded-medium border-border-error flex items-center justify-center border-2 p-1">
<ConnectionFalse className="text-text-error-primary" size={24} />
</div>
</Tooltip>
);
case null:
return (
<Tooltip content="Provider not connected" className="text-xs">
<div className="bg-info-lighter border-info-lighter rounded-medium flex items-center justify-center border p-1">
<ConnectionPending className="text-info" size={24} />
</div>
</Tooltip>
);
default:
return <ConnectionPending size={24} />;
}
};
return (
<div className="flex items-center text-sm">
<div className="flex items-center gap-4">
<div className="flex min-w-0 items-center text-sm">
<div className="flex min-w-0 items-center gap-4">
<div className="shrink-0">{getProviderLogo(provider)}</div>
{getIcon()}
<span className="font-medium">{providerAlias || providerUID}</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">
{providerAlias || providerUID}
</span>
{providerUID && (
<span className="text-text-neutral-tertiary truncate text-xs">
UID: {providerUID}
</span>
)}
</div>
</div>
</div>
);

View File

@@ -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<RowSelectionState>({});
// 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 (
<DataTable
columns={columns}
data={rows}
metadata={metadata}
getSubRows={(row) => row.subRows}
defaultExpanded={isCloud}
showSearch
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
enableSubRowSelection
/>
);
}

View File

@@ -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 <span>{(entity as ProviderConnectionStatus).label}</span>;
}
if (isGroupFilterEntity(entity)) {
return <span>{(entity as GroupFilterEntity).name}</span>;
}
const providerEntity = entity as ProviderEntity;
return (
<EntityInfo
cloudProvider={providerEntity.provider}
entityAlias={providerEntity.alias ?? undefined}
entityId={providerEntity.uid}
showCopyAction={false}
/>
);
};
return (
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector providers={providers} />
</div>
{sortedFilters.map((filter) => {
const selectedValues = getSelectedValues(filter);
return (
<div key={filter.key} className="max-w-[240px] min-w-[180px] flex-1">
<MultiSelect
values={selectedValues}
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
<MultiSelectTrigger size="default">
<MultiSelectValue placeholder={filter.labelCheckboxGroup} />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{filter.values.map((value) => {
const entity = getEntityForValue(filter, value);
const displayLabel = filter.labelFormatter
? filter.labelFormatter(value)
: value;
return (
<MultiSelectItem
key={value}
value={value}
badgeLabel={getBadgeLabel(entity, displayLabel)}
>
{entity ? renderEntityContent(entity) : displayLabel}
</MultiSelectItem>
);
})}
</MultiSelectContent>
</MultiSelect>
</div>
);
})}
<ClearFiltersButton showCount />
{actions && <div className="ml-auto flex flex-wrap gap-4">{actions}</div>}
</div>
);
};

View File

@@ -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<ProviderProps>[] = [
{
accessorKey: "account",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Provider"} param="alias" />
),
cell: ({ row }) => {
const {
attributes: { connection, provider, alias, uid },
} = getProviderData(row);
return (
<ProviderInfo
connected={connection.connected}
provider={provider}
providerAlias={alias}
providerUID={uid}
/>
);
},
},
{
accessorKey: "scanJobs",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scan Jobs" />
),
cell: ({ row }) => {
const {
attributes: { uid },
} = getProviderData(row);
return <LinkToScans providerUid={uid} />;
},
enableSorting: false,
},
{
accessorKey: "groupNames",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Groups" />
),
cell: ({ row }) => {
const { groupNames } = getProviderData(row);
return <GroupNameChips groupNames={groupNames || []} />;
},
enableSorting: false,
},
{
accessorKey: "uid",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Provider UID"}
param="uid"
/>
),
cell: ({ row }) => {
const {
attributes: { uid },
} = getProviderData(row);
return <SnippetChip value={uid} className="h-7" />;
},
},
{
accessorKey: "added",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Added"}
param="inserted_at"
/>
),
cell: ({ row }) => {
const {
attributes: { inserted_at },
} = getProviderData(row);
return <DateWithTime dateTime={inserted_at} showTime={false} />;
},
},
{
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
const OrganizationCell = ({
organization,
selectionLabel,
}: OrganizationCellProps) => {
const Icon =
organization.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION
? Building2
: FolderTree;
export const GroupNameChips: React.FC<GroupNameChipsProps> = ({
groupNames,
}) => {
return (
<div className="flex max-w-[300px] flex-wrap gap-1">
{groupNames?.map((name, index) => (
<Chip
key={index}
size="sm"
variant="flat"
classNames={{
base: "bg-default-100",
}}
>
{name}
</Chip>
))}
<div className="flex min-w-0 items-center gap-3">
<div className="bg-bg-neutral-tertiary text-text-neutral-primary flex size-9 shrink-0 items-center justify-center rounded-xl">
<Icon className="size-4" />
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-medium">{organization.name}</span>
{selectionLabel && (
<span className="text-text-neutral-tertiary shrink-0 text-xs">
({selectionLabel})
</span>
)}
</div>
{organization.externalId && (
<span className="text-text-neutral-tertiary truncate text-xs">
UID: {organization.externalId}
</span>
)}
</div>
</div>
);
};
const ProviderStatusCell = ({ connected }: { connected: boolean | null }) => {
if (connected === true) {
return (
<div className="text-system-success flex items-center gap-2 text-sm whitespace-nowrap">
<ShieldCheck className="size-4 shrink-0" />
<span>Connected</span>
</div>
);
}
if (connected === false) {
return (
<div className="text-text-error-primary flex items-center gap-2 text-sm whitespace-nowrap">
<ShieldAlert className="size-4 shrink-0" />
<span>Connection failed</span>
</div>
);
}
return (
<div className="text-text-neutral-secondary flex items-center gap-2 text-sm whitespace-nowrap">
<ShieldOff className="size-4 shrink-0" />
<span>Not connected</span>
</div>
);
};
function getSelectionLabel(row: Row<ProvidersTableRow>): 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<ProvidersTableRow>[]): 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<ProvidersTableRow>[]): 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<ProvidersTableRow>[] {
return [
{
id: "account",
size: 420,
accessorFn: (row) =>
isProvidersOrganizationRow(row) ? row.name : row.attributes.alias,
header: ({ column, table }) => (
<div className="flex items-center gap-2">
<DataTableExpandAllToggle table={table} />
<Checkbox
size="sm"
checked={table.getIsAllPageRowsSelected()}
indeterminate={
!table.getIsAllPageRowsSelected() &&
table.getIsSomePageRowsSelected()
}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
}
onClick={(e) => e.stopPropagation()}
aria-label="Select all"
/>
<div className="ml-2">
<DataTableColumnHeader
column={column}
title="Account"
param="alias"
/>
</div>
</div>
),
cell: ({ row }) => {
const isExpanded = row.getIsExpanded();
const checkboxSlot = (
<Checkbox
size="sm"
checked={row.getIsSelected()}
indeterminate={!row.getIsSelected() && row.getIsSomeSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
onClick={(e) => e.stopPropagation()}
aria-label="Select row"
/>
);
if (isProvidersOrganizationRow(row.original)) {
return (
<DataTableExpandableCell
row={row}
isExpanded={isExpanded}
hideChildIcon
checkboxSlot={checkboxSlot}
>
<OrganizationCell
organization={row.original}
selectionLabel={getSelectionLabel(row)}
/>
</DataTableExpandableCell>
);
}
const provider = row.original;
return (
<DataTableExpandableCell
row={row}
isExpanded={isExpanded}
checkboxSlot={checkboxSlot}
>
<ProviderInfo
provider={provider.attributes.provider}
providerAlias={provider.attributes.alias}
providerUID={provider.attributes.uid}
/>
</DataTableExpandableCell>
);
},
},
{
accessorKey: "groupNames",
size: 160,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Account Groups" />
),
cell: ({ row }) => {
if (isProvidersOrganizationRow(row.original)) {
return (
<span className="text-text-neutral-tertiary text-sm">
{row.original.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION
? "Organization"
: "Organizational Unit"}
</span>
);
}
return <GroupNameChips groupNames={row.original.groupNames || []} />;
},
enableSorting: false,
},
{
id: "lastScan",
size: 160,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Last Scan" />
),
cell: ({ row }) => {
if (isProvidersOrganizationRow(row.original)) {
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
const lastCheckedAt = (row.original as ProvidersProviderRow).attributes
.connection.last_checked_at;
if (!lastCheckedAt) {
return (
<span className="text-text-neutral-tertiary text-sm">Never</span>
);
}
return <DateWithTime dateTime={lastCheckedAt} showTime />;
},
enableSorting: false,
},
{
id: "scanSchedule",
size: 140,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scan Schedule" />
),
cell: ({ row }) => {
if (isProvidersOrganizationRow(row.original)) {
return (
<span className="text-text-neutral-tertiary text-sm">
{row.original.providerCount} Accounts
</span>
);
}
return (
<LinkToScans
hasSchedule={row.original.hasSchedule}
providerUid={row.original.attributes.uid}
/>
);
},
enableSorting: false,
},
{
id: "status",
size: 170,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Status"
param="connected"
/>
),
cell: ({ row }) => {
if (isProvidersOrganizationRow(row.original)) {
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
return (
<ProviderStatusCell
connected={row.original.attributes.connection.connected}
/>
);
},
},
{
accessorKey: "added",
size: 140,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Added"
param="inserted_at"
/>
),
cell: ({ row }) => {
if (isProvidersOrganizationRow(row.original)) {
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
return (
<DateWithTime
dateTime={row.original.attributes.inserted_at}
showTime
/>
);
},
},
{
id: "actions",
size: 56,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="" />
),
cell: ({ row }) => {
const hasSelection = Object.values(rowSelection).some(Boolean);
return (
<DataTableRowActions
row={row}
hasSelection={hasSelection}
isRowSelected={row.getIsSelected()}
testableProviderIds={testableProviderIds}
onClearSelection={onClearSelection}
/>
);
},
enableSorting: false,
},
];
}
export function GroupNameChips({ groupNames }: GroupNameChipsProps) {
if (!groupNames || groupNames.length === 0) {
return (
<span className="text-text-neutral-tertiary text-sm">No groups</span>
);
}
return (
<div className="flex max-w-[260px] flex-wrap gap-1">
{groupNames.map((name, index) => (
<Badge key={index} variant="tag">
{name}
</Badge>
))}
</div>
);
}

View File

@@ -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<any>;
describe("DataTableRowActions", () => {
it("renders the exact phase 1 menu actions for provider rows", async () => {
// Given
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow()}
hasSelection={false}
isRowSelected={false}
testableProviderIds={[]}
onClearSelection={vi.fn()}
/>,
);
// 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();
});
});

View File

@@ -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<ProviderProps>;
row: Row<ProvidersTableRow>;
/** 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 (
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-text-neutral-secondary" />
</Button>
}
>
<ActionDropdownItem
icon={<Rocket />}
label={loading ? "Testing..." : `Test Connection${bulkCount}`}
onSelect={(e) => {
e.preventDefault();
handleTestConnection();
}}
disabled={
isOrganizationRow || testableProviderIds.length === 0 || loading
}
/>
</ActionDropdown>
</div>
);
}
// Normal mode: all actions
return (
<>
<Modal
@@ -53,11 +156,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
onOpenChange={setIsEditOpen}
title="Edit Provider Alias"
>
<EditForm
providerId={providerId}
providerAlias={providerAlias}
setIsOpen={setIsEditOpen}
/>
{provider && (
<EditForm
providerId={providerId}
providerAlias={providerAlias ?? undefined}
setIsOpen={setIsEditOpen}
/>
)}
</Modal>
<Modal
open={isDeleteOpen}
@@ -65,7 +170,9 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
>
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
{provider && (
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
)}
</Modal>
<ProviderWizardModal
open={isWizardOpen}
@@ -86,35 +193,30 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<ActionDropdown
trigger={
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
<VerticalDotsIcon className="text-text-neutral-secondary" />
</Button>
}
>
<ActionDropdownItem
icon={<Pencil />}
label={hasSecret ? "Update Credentials" : "Add Credentials"}
onSelect={() => setIsWizardOpen(true)}
label="Edit Provider Alias"
onSelect={() => setIsEditOpen(true)}
disabled={isOrganizationRow}
/>
<ActionDropdownItem
icon={<PlugZap />}
icon={<KeyRound />}
label="Update Credentials"
onSelect={() => setIsWizardOpen(true)}
disabled={isOrganizationRow}
/>
<ActionDropdownItem
icon={<Rocket />}
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}
/>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Provider Alias"
onSelect={() => setIsEditOpen(true)}
disabled={isOrganizationRow || !hasSecret || loading}
/>
<ActionDropdownDangerZone>
<ActionDropdownItem
@@ -122,6 +224,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
label="Delete Provider"
destructive
onSelect={() => setIsDeleteOpen(true)}
disabled={isOrganizationRow}
/>
</ActionDropdownDangerZone>
</ActionDropdown>

View File

@@ -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 (
<Card variant="base" padding="md" className="flex flex-col gap-4">
{/* Table headers */}
<div className="hidden gap-4 md:flex">
<Skeleton className="h-8 w-1/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-1/12" />
<Skeleton className="h-8 w-1/12" />
</div>
{/* Table body */}
<div className="flex flex-col gap-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col gap-4 md:flex-row md:items-center"
>
<Skeleton className="h-12 w-full md:w-1/12" />
<Skeleton className="h-12 w-full md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
<tr className="border-border-neutral-secondary border-b">
{/* Account: provider logo + alias + UID */}
<td className="w-[420px] px-3 py-4">
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-lg" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-24 rounded" />
</div>
))}
</div>
</Card>
</div>
</td>
{/* Account Groups: badge chips */}
<td className="px-3 py-4">
<div className="flex items-center gap-1.5">
<Skeleton className="h-6 w-14 rounded-md" />
<Skeleton className="h-6 w-16 rounded-md" />
</div>
</td>
{/* Last Scan: date + time */}
<td className="px-3 py-4">
<div className="space-y-1">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-3 w-16 rounded" />
</div>
</td>
{/* Scan Schedule */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-12 rounded" />
</td>
{/* Status: icon + text */}
<td className="px-3 py-4">
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-4 w-20 rounded" />
</div>
</td>
{/* Added: date + time */}
<td className="px-3 py-4">
<div className="space-y-1">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-3 w-16 rounded" />
</div>
</td>
{/* Actions */}
<td className="px-2 py-4">
<Skeleton className="size-6 rounded" />
</td>
</tr>
);
};
export const SkeletonTableProviders = () => {
const rows = 10;
return (
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4">
{/* Toolbar: Search + Total entries */}
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-md" />
<Skeleton className="h-4 w-28 rounded" />
</div>
{/* Table */}
<table className="w-full">
<thead>
<tr className="border-border-neutral-secondary border-b">
{/* Account */}
<th className="w-[420px] px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Account Groups */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-24 rounded" />
</th>
{/* Last Scan */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
{/* Scan Schedule */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-20 rounded" />
</th>
{/* Status */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
{/* Added */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
{/* Actions - empty header */}
<th className="w-14 py-3" />
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-9 w-16 rounded-md" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-24 rounded" />
<div className="flex gap-1">
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -42,6 +42,19 @@ vi.mock("../../workflow/forms/update-via-service-account-key-form", () => ({
UpdateViaServiceAccountForm: () => <div>update-via-service-account-form</div>,
}));
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();

View File

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

View File

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

View File

@@ -7,6 +7,10 @@ import { cn } from "@/lib/utils";
interface DataTableAnimatedRowProps<TData> {
row: Row<TData>;
/** 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<TData> {
*/
export function DataTableAnimatedRow<TData>({
row,
isSelected,
isSomeSelected,
}: DataTableAnimatedRowProps<TData>) {
"use no memo";
void isSomeSelected;
return (
<motion.tr
initial="collapsed"
@@ -35,7 +44,7 @@ export function DataTableAnimatedRow<TData>({
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<TData>({
{row.getVisibleCells().map((cell: Cell<TData, unknown>, 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 (
<td key={cell.id} className="overflow-hidden p-0">
@@ -80,7 +95,7 @@ export function DataTableAnimatedRow<TData>({
isLast && "pr-3",
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{cellContent}
</div>
</motion.div>
</td>

View File

@@ -52,7 +52,7 @@ export function DataTableExpandAllToggle<TData>({
<button
onClick={() => table.toggleAllRowsExpanded(!isAllExpanded)}
className={cn(
"rounded p-1 transition-colors",
"rounded transition-colors",
"hover:bg-prowler-white/10",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
)}

View File

@@ -7,6 +7,13 @@ import { cn } from "@/lib/utils";
interface DataTableExpandToggleProps<TData> {
row: Row<TData>;
/**
* Explicit expanded state to ensure React Compiler re-renders when state changes.
* TanStack Table Row instances keep stable references — getter methods like
* `row.getIsExpanded()` read mutable state that React Compiler cannot track.
* Pass this prop from the parent to break memoization.
*/
isExpanded?: boolean;
}
/**
@@ -24,15 +31,19 @@ interface DataTableExpandToggleProps<TData> {
* // In column definition:
* {
* id: "expand",
* cell: ({ row }) => <DataTableExpandToggle row={row} />,
* cell: ({ row }) => (
* <DataTableExpandToggle row={row} isExpanded={row.getIsExpanded()} />
* ),
* }
* ```
*/
export function DataTableExpandToggle<TData>({
row,
isExpanded: isExpandedProp,
}: DataTableExpandToggleProps<TData>) {
const isExpanded = isExpandedProp ?? row.getIsExpanded();
if (!row.getCanExpand()) {
// Return a spacer div for alignment when row has no sub-rows
return <div className="w-4" />;
}
@@ -40,17 +51,17 @@ export function DataTableExpandToggle<TData>({
<button
onClick={row.getToggleExpandedHandler()}
className={cn(
"rounded p-1 transition-colors",
"rounded transition-colors",
"hover:bg-prowler-white/10",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
)}
aria-label={row.getIsExpanded() ? "Collapse row" : "Expand row"}
aria-expanded={row.getIsExpanded()}
aria-label={isExpanded ? "Collapse row" : "Expand row"}
aria-expanded={isExpanded}
>
<ChevronRightIcon
className={cn(
"h-4 w-4 shrink-0 transition-transform duration-200",
row.getIsExpanded() && "rotate-90",
"text-text-neutral-tertiary h-4 w-4 shrink-0 transition-transform duration-200",
isExpanded && "rotate-90",
)}
/>
</button>

View File

@@ -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<TData> {
@@ -13,6 +17,12 @@ interface DataTableExpandableCellProps<TData> {
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<TData>({
row,
children,
showToggle = true,
isExpanded,
hideChildIcon = false,
checkboxSlot,
}: DataTableExpandableCellProps<TData>) {
const isChildRow = row.depth > 0;
const canExpand = row.getCanExpand();
return (
<div
className="flex items-center gap-2"
className="flex min-w-0 items-center gap-2 overflow-hidden"
style={{ paddingLeft: `${row.depth * INDENT_PER_LEVEL_REM}rem` }}
>
{showToggle && (
<>
{canExpand ? (
<DataTableExpandToggle row={row} />
) : isChildRow ? (
<CornerDownRightIcon className="h-4 w-4 shrink-0" />
) : (
<div className="w-4" />
{isChildRow && !hideChildIcon && (
<CornerDownRightIcon className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
)}
{canExpand ? (
<DataTableExpandToggle row={row} isExpanded={isExpanded} />
) : !isChildRow ? (
<div className="w-4" />
) : null}
</>
)}
{checkboxSlot && (
<div className="mr-2 flex items-center">{checkboxSlot}</div>
)}
{children}
</div>
);

View File

@@ -255,7 +255,12 @@ export function DataTable<TData, TValue>({
{rows?.length ? (
rows.map((row) =>
getSubRows && row.depth > 0 ? (
<DataTableAnimatedRow key={row.id} row={row} />
<DataTableAnimatedRow
key={row.id}
row={row}
isSelected={row.getIsSelected()}
isSomeSelected={row.getIsSomeSelected()}
/>
) : (
<TableRow
key={row.id}

View File

@@ -1,6 +1,6 @@
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
import { FilterEntity } from "@/types/filters";
import { ProviderConnectionStatus } from "@/types/providers";
import { GroupFilterEntity, ProviderConnectionStatus } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
/**
@@ -135,6 +135,18 @@ export const isConnectionStatus = (
return !!(entity && "label" in entity && "value" in entity);
};
// Helper to check if entity is a GroupFilterEntity (organization or account group)
export const isGroupFilterEntity = (
entity: FilterEntity,
): entity is GroupFilterEntity => {
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,10 +148,60 @@ export interface OrganizationAttributes {
updated_at?: string;
}
interface OrganizationRelationshipRef<T extends string = string> {
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<string, unknown>;
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 {

View File

@@ -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<ProviderProps, "relationships"> {
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;
}

View File

@@ -88,6 +88,11 @@ export interface ProviderEntity {
alias: string | null;
}
export interface GroupFilterEntity {
name: string;
uid: string;
}
export interface ProviderConnectionStatus {
label: string;
value: string;