Skip to content

Commit db7554c

Browse files
feat(ui): redesign providers page with modern table and cloud recursion (#10292)
1 parent 65a7098 commit db7554c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+3347
-612
lines changed

ui/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ All notable changes to the **Prowler UI** are documented in this file.
88

99
- 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)
1010
- Refactor simple providers with new components and styles.[(#10259)](https://github.com/prowler-cloud/prowler/pull/10259)
11+
- 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)
1112
- 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)
1213

13-
---
14-
1514
### 🔐 Security
1615

1716
- 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)
1817

18+
---
19+
1920
## [1.19.1] (Prowler v5.19.1 UNRELEASED)
2021

2122
### 🐞 Fixed

ui/actions/manage-groups/manage-groups.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export const getProviderGroups = async ({
2222
}): Promise<ProviderGroupsResponse | undefined> => {
2323
const headers = await getAuthHeaders({ contentType: false });
2424

25-
if (isNaN(Number(page)) || page < 1) redirect("/manage-groups");
25+
if (isNaN(Number(page)) || page < 1)
26+
redirect("/providers?tab=account-groups");
2627

2728
const url = new URL(`${apiBaseUrl}/provider-groups`);
2829

@@ -43,7 +44,7 @@ export const getProviderGroups = async ({
4344
headers,
4445
});
4546

46-
return handleApiResponse(response);
47+
return await handleApiResponse(response);
4748
} catch (error) {
4849
console.error("Error fetching provider groups:", error);
4950
return undefined;
@@ -60,7 +61,7 @@ export const getProviderGroupInfoById = async (providerGroupId: string) => {
6061
headers,
6162
});
6263

63-
return handleApiResponse(response);
64+
return await handleApiResponse(response);
6465
} catch (error) {
6566
handleApiError(error);
6667
}
@@ -111,7 +112,7 @@ export const createProviderGroup = async (formData: FormData) => {
111112
body,
112113
});
113114

114-
return handleApiResponse(response, "/manage-groups");
115+
return await handleApiResponse(response, "/providers?tab=account-groups");
115116
} catch (error) {
116117
handleApiError(error);
117118
}
@@ -156,7 +157,7 @@ export const updateProviderGroup = async (
156157
body: JSON.stringify(payload),
157158
});
158159

159-
return handleApiResponse(response);
160+
return await handleApiResponse(response);
160161
} catch (error) {
161162
handleApiError(error);
162163
}
@@ -168,7 +169,7 @@ export const deleteProviderGroup = async (formData: FormData) => {
168169

169170
if (!providerGroupId) {
170171
return {
171-
errors: [{ detail: "Provider Group ID is required." }],
172+
errors: [{ detail: "Account Group ID is required." }],
172173
};
173174
}
174175

@@ -196,7 +197,7 @@ export const deleteProviderGroup = async (formData: FormData) => {
196197
data = await response.json();
197198
}
198199

199-
revalidatePath("/manage-groups");
200+
revalidatePath("/providers");
200201
return data || { success: true };
201202
} catch (error) {
202203
console.error("Error deleting provider group:", error);

ui/actions/organizations/organizations.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ vi.mock("@/lib/server-actions-helper", () => ({
3131
import {
3232
applyDiscovery,
3333
getDiscovery,
34+
listOrganizations,
35+
listOrganizationsSafe,
36+
listOrganizationUnits,
37+
listOrganizationUnitsSafe,
3438
triggerDiscovery,
3539
updateOrganizationSecret,
3640
} from "./organizations";
@@ -137,4 +141,66 @@ describe("organizations actions", () => {
137141
expect(revalidatePathMock).toHaveBeenCalledTimes(1);
138142
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
139143
});
144+
145+
it("lists organizations with the expected filters", async () => {
146+
// Given
147+
handleApiResponseMock.mockResolvedValue({ data: [] });
148+
149+
// When
150+
await listOrganizations();
151+
152+
// Then
153+
expect(fetchMock).toHaveBeenCalledTimes(1);
154+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
155+
"https://api.example.com/api/v1/organizations?filter%5Borg_type%5D=aws",
156+
);
157+
});
158+
159+
it("lists organization units from the dedicated endpoint", async () => {
160+
// Given
161+
handleApiResponseMock.mockResolvedValue({ data: [] });
162+
163+
// When
164+
await listOrganizationUnits();
165+
166+
// Then
167+
expect(fetchMock).toHaveBeenCalledTimes(1);
168+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
169+
"https://api.example.com/api/v1/organizational-units",
170+
);
171+
});
172+
173+
it("returns an empty organizations payload when the safe organizations request fails", async () => {
174+
// Given
175+
fetchMock.mockResolvedValue(
176+
new Response("Internal Server Error", {
177+
status: 500,
178+
}),
179+
);
180+
181+
// When
182+
const result = await listOrganizationsSafe();
183+
184+
// Then
185+
expect(result).toEqual({ data: [] });
186+
expect(handleApiResponseMock).not.toHaveBeenCalled();
187+
expect(handleApiErrorMock).not.toHaveBeenCalled();
188+
});
189+
190+
it("returns an empty organization units payload when the safe request fails", async () => {
191+
// Given
192+
fetchMock.mockResolvedValue(
193+
new Response("Internal Server Error", {
194+
status: 500,
195+
}),
196+
);
197+
198+
// When
199+
const result = await listOrganizationUnitsSafe();
200+
201+
// Then
202+
expect(result).toEqual({ data: [] });
203+
expect(handleApiResponseMock).not.toHaveBeenCalled();
204+
expect(handleApiErrorMock).not.toHaveBeenCalled();
205+
});
140206
});

ui/actions/organizations/organizations.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { revalidatePath } from "next/cache";
44

55
import { apiBaseUrl, getAuthHeaders } from "@/lib";
66
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
7+
import {
8+
OrganizationListResponse,
9+
OrganizationUnitListResponse,
10+
} from "@/types";
711

812
const PATH_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/;
913

@@ -37,6 +41,24 @@ function hasActionError(result: unknown): result is { error: unknown } {
3741
);
3842
}
3943

44+
async function fetchOptionalCollection<T extends { data: unknown[] }>(
45+
url: URL,
46+
): Promise<T> {
47+
const headers = await getAuthHeaders({ contentType: false });
48+
49+
try {
50+
const response = await fetch(url.toString(), { headers });
51+
52+
if (!response.ok) {
53+
return { data: [] } as unknown as T;
54+
}
55+
56+
return (await handleApiResponse(response)) as T;
57+
} catch {
58+
return { data: [] } as unknown as T;
59+
}
60+
}
61+
4062
/**
4163
* Creates an AWS Organization resource.
4264
* POST /api/v1/organizations
@@ -82,12 +104,62 @@ export const listOrganizationsByExternalId = async (externalId: string) => {
82104

83105
try {
84106
const response = await fetch(url.toString(), { headers });
85-
return handleApiResponse(response);
107+
return await handleApiResponse(response);
86108
} catch (error) {
87109
return handleApiError(error);
88110
}
89111
};
90112

113+
/**
114+
* Lists AWS organizations available for the current tenant.
115+
* GET /api/v1/organizations?filter[org_type]=aws
116+
*/
117+
export const listOrganizations = async () => {
118+
const headers = await getAuthHeaders({ contentType: false });
119+
const url = new URL(`${apiBaseUrl}/organizations`);
120+
url.searchParams.set("filter[org_type]", "aws");
121+
122+
try {
123+
const response = await fetch(url.toString(), { headers });
124+
return await handleApiResponse(response);
125+
} catch (error) {
126+
return handleApiError(error);
127+
}
128+
};
129+
130+
export const listOrganizationsSafe =
131+
async (): Promise<OrganizationListResponse> => {
132+
const url = new URL(`${apiBaseUrl}/organizations`);
133+
url.searchParams.set("filter[org_type]", "aws");
134+
url.searchParams.set("page[size]", "100");
135+
136+
return fetchOptionalCollection<OrganizationListResponse>(url);
137+
};
138+
139+
/**
140+
* Lists organization units available for the current tenant.
141+
* GET /api/v1/organizational-units
142+
*/
143+
export const listOrganizationUnits = async () => {
144+
const headers = await getAuthHeaders({ contentType: false });
145+
const url = new URL(`${apiBaseUrl}/organizational-units`);
146+
147+
try {
148+
const response = await fetch(url.toString(), { headers });
149+
return await handleApiResponse(response);
150+
} catch (error) {
151+
return handleApiError(error);
152+
}
153+
};
154+
155+
export const listOrganizationUnitsSafe =
156+
async (): Promise<OrganizationUnitListResponse> => {
157+
const url = new URL(`${apiBaseUrl}/organizational-units`);
158+
url.searchParams.set("page[size]", "100");
159+
160+
return fetchOptionalCollection<OrganizationUnitListResponse>(url);
161+
};
162+
91163
/**
92164
* Creates an organization secret (role-based credentials).
93165
* POST /api/v1/organization-secrets

ui/app/(prowler)/_overview/_components/provider-type-selector.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,14 @@ export const ProviderTypeSelector = ({
186186
if (selectedTypes.length === 1) {
187187
const providerType = selectedTypes[0] as ProviderType;
188188
return (
189-
<span className="flex items-center gap-2">
189+
<span className="flex min-w-0 items-center gap-2">
190190
{renderIcon(providerType)}
191-
<span>{PROVIDER_DATA[providerType].label}</span>
191+
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
192192
</span>
193193
);
194194
}
195195
return (
196-
<span className="truncate">
196+
<span className="min-w-0 truncate">
197197
{selectedTypes.length} providers selected
198198
</span>
199199
);

0 commit comments

Comments
 (0)