mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
feat(ui): redesign providers page with modern table and cloud recursion (#10292)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
162
ui/app/(prowler)/providers/account-groups-content.tsx
Normal file
162
ui/app/(prowler)/providers/account-groups-content.tsx
Normal 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 });
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
21
ui/app/(prowler)/providers/provider-page-tabs.shared.ts
Normal file
21
ui/app/(prowler)/providers/provider-page-tabs.shared.ts
Normal 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 };
|
||||
83
ui/app/(prowler)/providers/provider-page-tabs.test.tsx
Normal file
83
ui/app/(prowler)/providers/provider-page-tabs.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
59
ui/app/(prowler)/providers/provider-page-tabs.tsx
Normal file
59
ui/app/(prowler)/providers/provider-page-tabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
806
ui/app/(prowler)/providers/providers-page.utils.test.ts
Normal file
806
ui/app/(prowler)/providers/providers-page.utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
632
ui/app/(prowler)/providers/providers-page.utils.ts
Normal file
632
ui/app/(prowler)/providers/providers-page.utils.ts
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ManageGroupsButton = () => {
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/manage-groups">
|
||||
<SettingsIcon size={20} />
|
||||
Manage Groups
|
||||
Account Groups
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
84
ui/components/providers/providers-accounts-table.tsx
Normal file
84
ui/components/providers/providers-accounts-table.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
153
ui/components/providers/providers-filters.tsx
Normal file
153
ui/components/providers/providers-filters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
103
ui/components/providers/table/data-table-row-actions.test.tsx
Normal file
103
ui/components/providers/table/data-table-row-actions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
98
ui/types/providers-table.ts
Normal file
98
ui/types/providers-table.ts
Normal 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;
|
||||
}
|
||||
@@ -88,6 +88,11 @@ export interface ProviderEntity {
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
export interface GroupFilterEntity {
|
||||
name: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionStatus {
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
Reference in New Issue
Block a user