mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): add organization-specific actions to providers table dropdown (#10317)
This commit is contained in:
@@ -92,6 +92,59 @@ export const createOrganization = async (formData: FormData) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an AWS Organization's name.
|
||||
* PATCH /api/v1/organizations/{id}
|
||||
*/
|
||||
export const updateOrganizationName = async (
|
||||
organizationId: string,
|
||||
name: string,
|
||||
) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return { error: "Organization name cannot be empty." };
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
const idValidation = validatePathIdentifier(
|
||||
organizationId,
|
||||
"Organization ID is required",
|
||||
"Invalid organization ID",
|
||||
);
|
||||
if ("error" in idValidation) {
|
||||
return idValidation;
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/organizations/${encodeURIComponent(idValidation.value)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "organizations",
|
||||
id: idValidation.value,
|
||||
attributes: {
|
||||
name: trimmed,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse(response);
|
||||
if (!hasActionError(result)) {
|
||||
revalidatePath("/providers");
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists AWS Organizations filtered by external ID.
|
||||
* GET /api/v1/organizations?filter[external_id]={externalId}&filter[org_type]=aws
|
||||
@@ -273,6 +326,72 @@ export const listOrganizationSecretsByOrganizationId = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an AWS Organization resource.
|
||||
* DELETE /api/v1/organizations/{id}
|
||||
*/
|
||||
export const deleteOrganization = async (organizationId: string) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const organizationIdValidation = validatePathIdentifier(
|
||||
organizationId,
|
||||
"Organization ID is required",
|
||||
"Invalid organization ID",
|
||||
);
|
||||
if ("error" in organizationIdValidation) {
|
||||
return organizationIdValidation;
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response, "/providers");
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an organizational unit.
|
||||
* DELETE /api/v1/organizational-units/{id}
|
||||
*/
|
||||
export const deleteOrganizationalUnit = async (
|
||||
organizationalUnitId: string,
|
||||
) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const idValidation = validatePathIdentifier(
|
||||
organizationalUnitId,
|
||||
"Organizational unit ID is required",
|
||||
"Invalid organizational unit ID",
|
||||
);
|
||||
if ("error" in idValidation) {
|
||||
return idValidation;
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/organizational-units/${encodeURIComponent(idValidation.value)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response, "/providers");
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers an async discovery of the AWS Organization.
|
||||
* POST /api/v1/organizations/{id}/discover
|
||||
|
||||
@@ -4,10 +4,6 @@ const providersActionsMock = vi.hoisted(() => ({
|
||||
getProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
const manageGroupsActionsMock = vi.hoisted(() => ({
|
||||
getProviderGroups: vi.fn(),
|
||||
}));
|
||||
|
||||
const organizationsActionsMock = vi.hoisted(() => ({
|
||||
listOrganizationsSafe: vi.fn(),
|
||||
listOrganizationUnitsSafe: vi.fn(),
|
||||
@@ -18,7 +14,6 @@ const scansActionsMock = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers", () => providersActionsMock);
|
||||
vi.mock("@/actions/manage-groups", () => manageGroupsActionsMock);
|
||||
vi.mock(
|
||||
"@/actions/organizations/organizations",
|
||||
() => organizationsActionsMock,
|
||||
@@ -26,7 +21,6 @@ vi.mock(
|
||||
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";
|
||||
|
||||
@@ -146,47 +140,6 @@ const providersResponse: ProvidersApiResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
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>,
|
||||
@@ -666,9 +619,6 @@ 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
|
||||
@@ -685,7 +635,7 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
organizationsActionsMock.listOrganizationUnitsSafe,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual(
|
||||
["Account Groups", "Status"],
|
||||
["Status"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -712,9 +662,6 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
},
|
||||
})),
|
||||
});
|
||||
manageGroupsActionsMock.getProviderGroups.mockResolvedValue(
|
||||
providerGroupsResponse,
|
||||
);
|
||||
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -769,7 +716,7 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
organizationsActionsMock.listOrganizationUnitsSafe,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual(
|
||||
["Organizations", "Account Groups", "Status"],
|
||||
["Status"],
|
||||
);
|
||||
expect(viewData.rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION);
|
||||
});
|
||||
@@ -777,9 +724,6 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
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: [],
|
||||
});
|
||||
@@ -796,7 +740,7 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
|
||||
// Then
|
||||
expect(viewData.filters.map((filter) => filter.labelCheckboxGroup)).toEqual(
|
||||
["Account Groups", "Status"],
|
||||
["Status"],
|
||||
);
|
||||
expect(viewData.rows).toHaveLength(2);
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getProviderGroups } from "@/actions/manage-groups";
|
||||
import {
|
||||
listOrganizationsSafe,
|
||||
listOrganizationUnitsSafe,
|
||||
@@ -13,10 +12,8 @@ import {
|
||||
FilterEntity,
|
||||
FilterOption,
|
||||
OrganizationListResponse,
|
||||
OrganizationResource,
|
||||
OrganizationUnitListResponse,
|
||||
OrganizationUnitResource,
|
||||
ProviderGroupsResponse,
|
||||
ProvidersApiResponse,
|
||||
SearchParamsProps,
|
||||
} from "@/types";
|
||||
@@ -52,11 +49,6 @@ interface ProvidersAccountsViewInput {
|
||||
searchParams: SearchParamsProps;
|
||||
}
|
||||
|
||||
interface ProvidersTableLocalFilters {
|
||||
organizationIds: string[];
|
||||
providerGroupIds: string[];
|
||||
}
|
||||
|
||||
function hasActionError(result: unknown): result is {
|
||||
error: unknown;
|
||||
} {
|
||||
@@ -86,56 +78,16 @@ async function resolveActionResult<T>(
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
const createProvidersFilters = (): FilterOption[] => {
|
||||
return [
|
||||
{
|
||||
key: PROVIDERS_PAGE_FILTER.STATUS,
|
||||
labelCheckboxGroup: "Status",
|
||||
values: ["true", "false"],
|
||||
valueLabelMapping: PROVIDERS_STATUS_MAPPING,
|
||||
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 = (
|
||||
@@ -193,55 +145,6 @@ const enrichProviders = (
|
||||
}));
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -527,17 +430,6 @@ export async function loadProvidersAccountsViewData({
|
||||
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)
|
||||
@@ -548,12 +440,7 @@ export async function loadProvidersAccountsViewData({
|
||||
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: [],
|
||||
@@ -565,7 +452,6 @@ export async function loadProvidersAccountsViewData({
|
||||
const [
|
||||
providersResponse,
|
||||
allProvidersResponse,
|
||||
providerGroupsResponse,
|
||||
scansResponse,
|
||||
organizationsResponse,
|
||||
organizationUnitsResponse,
|
||||
@@ -582,7 +468,6 @@ export async function loadProvidersAccountsViewData({
|
||||
// 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({
|
||||
@@ -605,24 +490,19 @@ export async function loadProvidersAccountsViewData({
|
||||
scansResponse?.data ?? [],
|
||||
);
|
||||
|
||||
const providers = applyLocalFilters({
|
||||
...localFilters,
|
||||
providers: enrichProviders(providersResponse, scheduledProviderIds),
|
||||
});
|
||||
const orgs = organizationsResponse?.data ?? [];
|
||||
const ous = organizationUnitsResponse?.data ?? [];
|
||||
const providers = enrichProviders(providersResponse, scheduledProviderIds);
|
||||
|
||||
const rows = buildProvidersTableRows({
|
||||
isCloud,
|
||||
organizations: organizationsResponse?.data ?? [],
|
||||
organizationUnits: organizationUnitsResponse?.data ?? [],
|
||||
organizations: orgs,
|
||||
organizationUnits: ous,
|
||||
providers,
|
||||
});
|
||||
|
||||
return {
|
||||
filters: createProvidersFilters({
|
||||
isCloud,
|
||||
organizations: organizationsResponse?.data ?? [],
|
||||
providerGroups: providerGroupsResponse?.data ?? [],
|
||||
}),
|
||||
filters: createProvidersFilters(),
|
||||
metadata: providersResponse?.meta,
|
||||
providers: allProvidersResponse?.data ?? [],
|
||||
rows,
|
||||
|
||||
91
ui/components/providers/forms/delete-organization-form.tsx
Normal file
91
ui/components/providers/forms/delete-organization-form.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import {
|
||||
deleteOrganization,
|
||||
deleteOrganizationalUnit,
|
||||
} from "@/actions/organizations/organizations";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import {
|
||||
PROVIDERS_GROUP_KIND,
|
||||
ProvidersGroupKind,
|
||||
} from "@/types/providers-table";
|
||||
|
||||
interface DeleteOrganizationFormProps {
|
||||
id: string;
|
||||
name: string;
|
||||
variant: ProvidersGroupKind;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function DeleteOrganizationForm({
|
||||
id,
|
||||
name,
|
||||
variant,
|
||||
setIsOpen,
|
||||
}: DeleteOrganizationFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const isOrg = variant === PROVIDERS_GROUP_KIND.ORGANIZATION;
|
||||
const entityLabel = isOrg ? "organization" : "organizational unit";
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const result = isOrg
|
||||
? await deleteOrganization(id)
|
||||
: await deleteOrganizationalUnit(id);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (result?.errors && result.errors.length > 0) {
|
||||
const error = result.errors[0];
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: `${error.detail}`,
|
||||
});
|
||||
} else if (result?.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.error,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: `The ${entityLabel} "${name}" was removed successfully.`,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{!isLoading && <DeleteIcon size={24} />}
|
||||
{isLoading ? "Loading" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateProvider } from "@/actions/providers";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { editProviderFormSchema } from "@/types";
|
||||
|
||||
export const EditForm = ({
|
||||
providerId,
|
||||
providerAlias,
|
||||
setIsOpen,
|
||||
}: {
|
||||
providerId: string;
|
||||
providerAlias?: string;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const formSchema = editProviderFormSchema(providerAlias ?? "");
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
[ProviderCredentialFields.PROVIDER_ID]: providerId,
|
||||
[ProviderCredentialFields.PROVIDER_ALIAS]: providerAlias,
|
||||
},
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
|
||||
Object.entries(values).forEach(
|
||||
([key, value]) => value !== undefined && formData.append(key, value),
|
||||
);
|
||||
|
||||
const data = await updateProvider(formData);
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
const error = data.errors[0];
|
||||
const errorMessage = `${error.detail}`;
|
||||
// show error
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: errorMessage,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The provider was updated successfully.",
|
||||
});
|
||||
setIsOpen(false); // Close the modal on success
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="text-md">
|
||||
Current alias: <span className="font-bold">{providerAlias}</span>
|
||||
</div>
|
||||
<div>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name={ProviderCredentialFields.PROVIDER_ALIAS}
|
||||
type="text"
|
||||
label="Alias"
|
||||
labelPlacement="outside"
|
||||
placeholder={providerAlias}
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="providerId" value={providerId} />
|
||||
|
||||
<FormButtons setIsOpen={setIsOpen} isDisabled={isLoading} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
123
ui/components/providers/forms/edit-name-form.tsx
Normal file
123
ui/components/providers/forms/edit-name-form.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SaveIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import { useToast } from "@/components/ui";
|
||||
|
||||
interface EditNameFormProps {
|
||||
currentValue: string;
|
||||
label: string;
|
||||
successMessage: string;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
validate?: (value: string) => string | null;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onSave: (value: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function EditNameForm({
|
||||
currentValue,
|
||||
label,
|
||||
successMessage,
|
||||
placeholder,
|
||||
helperText,
|
||||
validate,
|
||||
setIsOpen,
|
||||
onSave,
|
||||
}: EditNameFormProps) {
|
||||
const [value, setValue] = useState(currentValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (validate) {
|
||||
const validationError = validate(trimmed);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const result = (await onSave(trimmed)) as Record<string, unknown>;
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
const errors = result?.errors as Array<{ detail: string }> | undefined;
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: `${errors[0].detail}`,
|
||||
});
|
||||
} else if (result?.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: String(result.error),
|
||||
});
|
||||
} else {
|
||||
toast({ title: "Success!", description: successMessage });
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="text-md">
|
||||
Current {label.toLowerCase()}:{" "}
|
||||
<span className="font-bold">{currentValue || "—"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="edit-name-input"
|
||||
className="text-text-neutral-primary text-sm font-medium"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-name-input"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={placeholder ?? currentValue}
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{error && <p className="text-destructive text-xs">{error}</p>}
|
||||
{helperText && !error && (
|
||||
<p className="text-muted-foreground text-xs">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="lg" disabled={isLoading}>
|
||||
{!isLoading && <SaveIcon size={24} />}
|
||||
{isLoading ? "Loading" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./delete-form";
|
||||
export * from "./edit-form";
|
||||
export * from "./edit-name-form";
|
||||
|
||||
@@ -8,22 +8,29 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { updateOrganizationName } from "@/actions/organizations/organizations";
|
||||
import { AWSProviderBadge } from "@/components/icons/providers-badge";
|
||||
import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "@/components/providers/wizard/steps/footer-controls";
|
||||
import {
|
||||
ORG_WIZARD_INTENT,
|
||||
OrgWizardIntent,
|
||||
} from "@/components/providers/wizard/types";
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
getAWSCredentialsTemplateLinks,
|
||||
PROWLER_CF_TEMPLATE_URL,
|
||||
STACKSET_CONSOLE_URL,
|
||||
} from "@/lib";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations";
|
||||
|
||||
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
|
||||
@@ -53,23 +60,36 @@ const orgSetupSchema = z.object({
|
||||
|
||||
type OrgSetupFormData = z.infer<typeof orgSetupSchema>;
|
||||
|
||||
interface OrgSetupFormInitialValues {
|
||||
organizationName: string;
|
||||
awsOrgId: string;
|
||||
}
|
||||
|
||||
interface OrgSetupFormProps {
|
||||
onBack: () => void;
|
||||
onClose?: () => void;
|
||||
onNext: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
onPhaseChange: (phase: OrgSetupPhase) => void;
|
||||
initialPhase?: OrgSetupPhase;
|
||||
initialValues?: OrgSetupFormInitialValues;
|
||||
intent?: OrgWizardIntent;
|
||||
}
|
||||
|
||||
export function OrgSetupForm({
|
||||
onBack,
|
||||
onClose,
|
||||
onNext,
|
||||
onFooterChange,
|
||||
onPhaseChange,
|
||||
initialPhase = ORG_SETUP_PHASE.DETAILS,
|
||||
initialValues,
|
||||
intent = ORG_WIZARD_INTENT.FULL,
|
||||
}: OrgSetupFormProps) {
|
||||
const { data: session } = useSession();
|
||||
const stackSetExternalId = session?.tenantId ?? "";
|
||||
const { organizationId } = useOrgSetupStore();
|
||||
const { toast } = useToast();
|
||||
const { copied: isExternalIdCopied, copy: copyExternalId } = useClipboard({
|
||||
timeout: 1500,
|
||||
});
|
||||
@@ -77,15 +97,18 @@ export function OrgSetupForm({
|
||||
timeout: 1500,
|
||||
});
|
||||
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const formId = "org-wizard-setup-form";
|
||||
|
||||
const isReadOnlyOrgId = Boolean(initialValues?.awsOrgId);
|
||||
|
||||
const form = useForm<OrgSetupFormData>({
|
||||
resolver: zodResolver(orgSetupSchema),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: {
|
||||
organizationName: "",
|
||||
awsOrgId: "",
|
||||
organizationName: initialValues?.organizationName ?? "",
|
||||
awsOrgId: initialValues?.awsOrgId ?? "",
|
||||
roleArn: "",
|
||||
stackSetDeployed: false,
|
||||
},
|
||||
@@ -120,21 +143,23 @@ export function OrgSetupForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
|
||||
const isEditName = intent === ORG_WIZARD_INTENT.EDIT_NAME;
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: !isOrgIdValid,
|
||||
actionLabel: isEditName ? "Save" : "Next",
|
||||
actionDisabled: isEditName ? isSaving : !isOrgIdValid,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditCredentials = intent === ORG_WIZARD_INTENT.EDIT_CREDENTIALS;
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
showBack: !isEditCredentials,
|
||||
backLabel: "Back",
|
||||
backDisabled: isSubmitting,
|
||||
onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
|
||||
@@ -146,7 +171,9 @@ export function OrgSetupForm({
|
||||
});
|
||||
}, [
|
||||
formId,
|
||||
intent,
|
||||
isOrgIdValid,
|
||||
isSaving,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
onBack,
|
||||
@@ -170,9 +197,42 @@ export function OrgSetupForm({
|
||||
setSetupPhase(ORG_SETUP_PHASE.ACCESS);
|
||||
};
|
||||
|
||||
const handleSaveNameOnly = async () => {
|
||||
if (!organizationId) return;
|
||||
setIsSaving(true);
|
||||
const name = form.getValues("organizationName")?.trim() || "";
|
||||
|
||||
const result = await updateOrganizationName(organizationId, name);
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (result?.error || result?.errors) {
|
||||
const errorMsg =
|
||||
result.errors?.[0]?.detail ?? result.error ?? "Failed to update name";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: errorMsg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "Organization name updated successfully.",
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
|
||||
event.preventDefault();
|
||||
|
||||
if (intent === ORG_WIZARD_INTENT.EDIT_NAME) {
|
||||
void handleSaveNameOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
handleContinueToAccess();
|
||||
return;
|
||||
}
|
||||
@@ -251,6 +311,8 @@ export function OrgSetupForm({
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
isReadOnly={isReadOnlyOrgId}
|
||||
isDisabled={isReadOnlyOrgId}
|
||||
/>
|
||||
|
||||
<WizardInputField
|
||||
|
||||
@@ -3,10 +3,17 @@ 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";
|
||||
import {
|
||||
PROVIDERS_GROUP_KIND,
|
||||
PROVIDERS_ROW_TYPE,
|
||||
} from "@/types/providers-table";
|
||||
|
||||
const checkConnectionProviderMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/actions/organizations/organizations", () => ({
|
||||
updateOrganizationName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers/providers", () => ({
|
||||
checkConnectionProvider: checkConnectionProviderMock,
|
||||
}));
|
||||
@@ -15,14 +22,18 @@ vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../forms", () => ({
|
||||
EditForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../forms/delete-form", () => ({
|
||||
DeleteForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../forms/delete-organization-form", () => ({
|
||||
DeleteOrganizationForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../forms/edit-name-form", () => ({
|
||||
EditNameForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
@@ -76,6 +87,63 @@ const createRow = () =>
|
||||
},
|
||||
}) as Row<any>;
|
||||
|
||||
const createOrgRow = () =>
|
||||
({
|
||||
original: {
|
||||
id: "org-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
|
||||
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION,
|
||||
name: "My AWS Organization",
|
||||
externalId: "o-abc123def4",
|
||||
parentExternalId: null,
|
||||
organizationId: "org-1",
|
||||
providerCount: 3,
|
||||
subRows: [
|
||||
{
|
||||
id: "provider-child-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
type: "providers",
|
||||
attributes: { provider: "aws", uid: "111", alias: null },
|
||||
relationships: {
|
||||
secret: { data: { id: "secret-1", type: "secrets" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "provider-child-2",
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
type: "providers",
|
||||
attributes: { provider: "aws", uid: "222", alias: null },
|
||||
relationships: { secret: { data: null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as Row<any>;
|
||||
|
||||
const createOuRow = () =>
|
||||
({
|
||||
original: {
|
||||
id: "ou-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.ORGANIZATION,
|
||||
groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT,
|
||||
name: "Production OU",
|
||||
externalId: "ou-abc123",
|
||||
parentExternalId: "o-abc123def4",
|
||||
organizationId: "org-1",
|
||||
providerCount: 2,
|
||||
subRows: [
|
||||
{
|
||||
id: "provider-ou-child-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
type: "providers",
|
||||
attributes: { provider: "aws", uid: "333", alias: null },
|
||||
relationships: {
|
||||
secret: { data: { id: "secret-2", type: "secrets" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as Row<any>;
|
||||
|
||||
describe("DataTableRowActions", () => {
|
||||
it("renders the exact phase 1 menu actions for provider rows", async () => {
|
||||
// Given
|
||||
@@ -100,4 +168,122 @@ describe("DataTableRowActions", () => {
|
||||
expect(screen.getByText("Delete Provider")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all 4 organization actions for org rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOrgRow()}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText("Edit Organization Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update Credentials")).toBeInTheDocument();
|
||||
// 1 of 2 child providers has a secret
|
||||
expect(screen.getByText("Test Connections (1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete Organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Delete Organization with destructive styling for org rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOrgRow()}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
const deleteItem = screen.getByText("Delete Organization");
|
||||
// Destructive items are rendered with error text color
|
||||
const menuItem = deleteItem.closest("[role='menuitem']");
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
expect(menuItem).toHaveClass("text-text-error-primary");
|
||||
});
|
||||
|
||||
it("renders only Test Connections and Delete for OU rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOuRow()}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText("Test Connections (1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete Organization Unit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows selected provider count in Test Connections when org row has active selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOrgRow()}
|
||||
hasSelection={true}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={["provider-child-1", "provider-standalone"]}
|
||||
onClearSelection={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Should show count of selected testable providers (2), not all org children (1)
|
||||
expect(screen.getByText("Test Connections (2)")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows selected provider count in Test Connections when OU row has active selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOuRow()}
|
||||
hasSelection={true}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={["provider-ou-child-1", "provider-standalone"]}
|
||||
onClearSelection={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Should show count of selected testable providers (2), not all OU children (1)
|
||||
expect(screen.getByText("Test Connections (2)")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Edit Organization Name or Update Credentials for OU rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createOuRow()}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(
|
||||
screen.queryByText("Edit Organization Name"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,14 @@ import { Row } from "@tanstack/react-table";
|
||||
import { KeyRound, Pencil, Rocket, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { updateOrganizationName } from "@/actions/organizations/organizations";
|
||||
import { updateProvider } from "@/actions/providers";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import {
|
||||
ORG_WIZARD_INTENT,
|
||||
OrgWizardInitialData,
|
||||
} from "@/components/providers/wizard/types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -16,14 +22,19 @@ import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { testProviderConnection } from "@/lib/provider-helpers";
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
||||
import {
|
||||
isProvidersOrganizationRow,
|
||||
PROVIDERS_GROUP_KIND,
|
||||
PROVIDERS_ROW_TYPE,
|
||||
ProvidersOrganizationRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
|
||||
import { EditForm } from "../forms";
|
||||
import { DeleteForm } from "../forms/delete-form";
|
||||
import { DeleteOrganizationForm } from "../forms/delete-organization-form";
|
||||
import { EditNameForm } from "../forms/edit-name-form";
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<ProvidersTableRow>;
|
||||
@@ -37,6 +48,165 @@ interface DataTableRowActionsProps {
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) {
|
||||
if (row.relationships.secret.data) {
|
||||
ids.push(row.id);
|
||||
}
|
||||
} else if (row.subRows) {
|
||||
ids.push(...collectTestableChildProviderIds(row.subRows));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
interface OrgGroupDropdownActionsProps {
|
||||
rowData: ProvidersOrganizationRow;
|
||||
loading: boolean;
|
||||
hasSelection: boolean;
|
||||
testableProviderIds: string[];
|
||||
childTestableIds: string[];
|
||||
onClearSelection: () => void;
|
||||
onBulkTest: (ids: string[]) => Promise<void>;
|
||||
onTestChildConnections: () => Promise<void>;
|
||||
}
|
||||
|
||||
function OrgGroupDropdownActions({
|
||||
rowData,
|
||||
loading,
|
||||
hasSelection,
|
||||
testableProviderIds,
|
||||
childTestableIds,
|
||||
onClearSelection,
|
||||
onBulkTest,
|
||||
onTestChildConnections,
|
||||
}: OrgGroupDropdownActionsProps) {
|
||||
const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false);
|
||||
const [isEditNameOpen, setIsEditNameOpen] = useState(false);
|
||||
const [isOrgWizardOpen, setIsOrgWizardOpen] = useState(false);
|
||||
const [orgWizardData, setOrgWizardData] =
|
||||
useState<OrgWizardInitialData | null>(null);
|
||||
|
||||
const isOrgKind = rowData.groupKind === PROVIDERS_GROUP_KIND.ORGANIZATION;
|
||||
const testIds = hasSelection ? testableProviderIds : childTestableIds;
|
||||
const testCount = testIds.length;
|
||||
const entityLabel = isOrgKind ? "organization" : "organizational unit";
|
||||
|
||||
const openOrgWizardAt = (
|
||||
targetStep: OrgWizardInitialData["targetStep"],
|
||||
targetPhase: OrgWizardInitialData["targetPhase"],
|
||||
intent?: OrgWizardInitialData["intent"],
|
||||
) => {
|
||||
setOrgWizardData({
|
||||
organizationId: rowData.id,
|
||||
organizationName: rowData.name,
|
||||
externalId: rowData.externalId ?? "",
|
||||
targetStep,
|
||||
targetPhase,
|
||||
intent,
|
||||
});
|
||||
setIsOrgWizardOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOrgKind && (
|
||||
<>
|
||||
<Modal
|
||||
open={isEditNameOpen}
|
||||
onOpenChange={setIsEditNameOpen}
|
||||
title="Edit Organization Name"
|
||||
>
|
||||
<EditNameForm
|
||||
currentValue={rowData.name}
|
||||
label="Name"
|
||||
successMessage="The organization name was updated successfully."
|
||||
helperText="If left blank, Prowler will use the name stored in AWS."
|
||||
setIsOpen={setIsEditNameOpen}
|
||||
onSave={(name) => updateOrganizationName(rowData.id, name)}
|
||||
/>
|
||||
</Modal>
|
||||
<ProviderWizardModal
|
||||
open={isOrgWizardOpen}
|
||||
onOpenChange={setIsOrgWizardOpen}
|
||||
orgInitialData={orgWizardData ?? undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
open={isDeleteOrgOpen}
|
||||
onOpenChange={setIsDeleteOrgOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description={`This action cannot be undone. This will permanently delete this ${entityLabel} and all associated data.`}
|
||||
>
|
||||
<DeleteOrganizationForm
|
||||
id={rowData.id}
|
||||
name={rowData.name}
|
||||
variant={rowData.groupKind}
|
||||
setIsOpen={setIsDeleteOrgOpen}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{isOrgKind && (
|
||||
<>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Organization Name"
|
||||
onSelect={() => setIsEditNameOpen(true)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label="Update Credentials"
|
||||
onSelect={() =>
|
||||
openOrgWizardAt(
|
||||
ORG_WIZARD_STEP.SETUP,
|
||||
ORG_SETUP_PHASE.ACCESS,
|
||||
ORG_WIZARD_INTENT.EDIT_CREDENTIALS,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
label={loading ? "Testing..." : `Test Connections (${testCount})`}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (hasSelection) {
|
||||
onBulkTest(testableProviderIds);
|
||||
onClearSelection();
|
||||
} else {
|
||||
onTestChildConnections();
|
||||
}
|
||||
}}
|
||||
disabled={testCount === 0 || loading}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label={
|
||||
isOrgKind ? "Delete Organization" : "Delete Organization Unit"
|
||||
}
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOrgOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableRowActions({
|
||||
row,
|
||||
hasSelection,
|
||||
@@ -60,41 +230,46 @@ export function DataTableRowActions({
|
||||
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
|
||||
const hasSecret = Boolean(provider?.relationships.secret.data);
|
||||
|
||||
const orgGroupKind = isOrganizationRow ? rowData.groupKind : null;
|
||||
const childTestableIds = isOrganizationRow
|
||||
? collectTestableChildProviderIds(rowData.subRows)
|
||||
: [];
|
||||
|
||||
const handleBulkTest = async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
setLoading(true);
|
||||
|
||||
const results = await runWithConcurrencyLimit(ids, 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);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
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);
|
||||
await handleBulkTest(testableProviderIds);
|
||||
onClearSelection();
|
||||
} else {
|
||||
// Single: test only this provider
|
||||
@@ -118,6 +293,10 @@ export function DataTableRowActions({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestChildConnections = async () => {
|
||||
await handleBulkTest(childTestableIds);
|
||||
};
|
||||
|
||||
// When this row is part of the selection, only show "Test Connection"
|
||||
if (hasSelection && isRowSelected) {
|
||||
const bulkCount =
|
||||
@@ -139,16 +318,30 @@ export function DataTableRowActions({
|
||||
e.preventDefault();
|
||||
handleTestConnection();
|
||||
}}
|
||||
disabled={
|
||||
isOrganizationRow || testableProviderIds.length === 0 || loading
|
||||
}
|
||||
disabled={testableProviderIds.length === 0 || loading}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal mode: all actions
|
||||
// Organization / Organization Unit row actions
|
||||
if (isProvidersOrganizationRow(rowData) && orgGroupKind) {
|
||||
return (
|
||||
<OrgGroupDropdownActions
|
||||
rowData={rowData}
|
||||
loading={loading}
|
||||
hasSelection={hasSelection}
|
||||
testableProviderIds={testableProviderIds}
|
||||
childTestableIds={childTestableIds}
|
||||
onClearSelection={onClearSelection}
|
||||
onBulkTest={handleBulkTest}
|
||||
onTestChildConnections={handleTestChildConnections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider row actions (unchanged)
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -157,10 +350,26 @@ export function DataTableRowActions({
|
||||
title="Edit Provider Alias"
|
||||
>
|
||||
{provider && (
|
||||
<EditForm
|
||||
providerId={providerId}
|
||||
providerAlias={providerAlias ?? undefined}
|
||||
<EditNameForm
|
||||
currentValue={providerAlias ?? ""}
|
||||
label="Alias"
|
||||
successMessage="The provider was updated successfully."
|
||||
setIsOpen={setIsEditOpen}
|
||||
validate={(alias) => {
|
||||
if (alias !== "" && alias.length < 3) {
|
||||
return "The alias must be empty or have at least 3 characters.";
|
||||
}
|
||||
if (alias === (providerAlias ?? "")) {
|
||||
return "The new alias must be different from the current one.";
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
onSave={async (alias) => {
|
||||
const formData = new FormData();
|
||||
formData.append("providerId", providerId);
|
||||
formData.append("providerAlias", alias);
|
||||
return updateProvider(formData);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -201,13 +410,11 @@ export function DataTableRowActions({
|
||||
icon={<Pencil />}
|
||||
label="Edit Provider Alias"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
disabled={isOrganizationRow}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label="Update Credentials"
|
||||
onSelect={() => setIsWizardOpen(true)}
|
||||
disabled={isOrganizationRow}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Rocket />}
|
||||
@@ -216,7 +423,7 @@ export function DataTableRowActions({
|
||||
e.preventDefault();
|
||||
handleTestConnection();
|
||||
}}
|
||||
disabled={isOrganizationRow || !hasSecret || loading}
|
||||
disabled={!hasSecret || loading}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
@@ -224,7 +431,6 @@ export function DataTableRowActions({
|
||||
label="Delete Provider"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
disabled={isOrganizationRow}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
WIZARD_FOOTER_ACTION_TYPE,
|
||||
WizardFooterConfig,
|
||||
} from "../steps/footer-controls";
|
||||
import type { ProviderWizardInitialData } from "../types";
|
||||
import type { OrgWizardInitialData, ProviderWizardInitialData } from "../types";
|
||||
|
||||
const WIZARD_VARIANT = {
|
||||
PROVIDER: "provider",
|
||||
@@ -48,12 +48,14 @@ interface UseProviderWizardControllerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
}
|
||||
|
||||
export function useProviderWizardController({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
}: UseProviderWizardControllerProps) {
|
||||
const initialProviderId = initialData?.providerId ?? null;
|
||||
const initialProviderType = initialData?.providerType ?? null;
|
||||
@@ -90,7 +92,7 @@ export function useProviderWizardController({
|
||||
mode,
|
||||
providerType,
|
||||
} = useProviderWizardStore();
|
||||
const { reset: resetOrgWizard } = useOrgSetupStore();
|
||||
const { reset: resetOrgWizard, setOrganization } = useOrgSetupStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -103,6 +105,21 @@ export function useProviderWizardController({
|
||||
}
|
||||
hasHydratedForCurrentOpenRef.current = true;
|
||||
|
||||
if (orgInitialData) {
|
||||
setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS);
|
||||
resetOrgWizard();
|
||||
setOrganization(
|
||||
orgInitialData.organizationId,
|
||||
orgInitialData.organizationName,
|
||||
orgInitialData.externalId,
|
||||
);
|
||||
setOrgCurrentStep(orgInitialData.targetStep);
|
||||
setOrgSetupPhase(orgInitialData.targetPhase);
|
||||
setFooterConfig(EMPTY_FOOTER_CONFIG);
|
||||
setProviderTypeHint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialProviderId && initialProviderType && initialProviderUid) {
|
||||
setWizardVariant(WIZARD_VARIANT.PROVIDER);
|
||||
setProvider({
|
||||
@@ -144,14 +161,18 @@ export function useProviderWizardController({
|
||||
initialSecretId,
|
||||
initialVia,
|
||||
open,
|
||||
orgInitialData,
|
||||
resetOrgWizard,
|
||||
resetProviderWizard,
|
||||
setMode,
|
||||
setOrganization,
|
||||
setProvider,
|
||||
setSecretId,
|
||||
setVia,
|
||||
]);
|
||||
|
||||
const isOrgDirectEntry = Boolean(orgInitialData);
|
||||
|
||||
const handleClose = () => {
|
||||
resetProviderWizard();
|
||||
resetOrgWizard();
|
||||
@@ -212,6 +233,7 @@ export function useProviderWizardController({
|
||||
handleClose,
|
||||
handleDialogOpenChange,
|
||||
handleTestSuccess,
|
||||
isOrgDirectEntry,
|
||||
isProviderFlow,
|
||||
modalTitle,
|
||||
openOrganizationsFlow,
|
||||
|
||||
@@ -22,19 +22,21 @@ import { CredentialsStep } from "./steps/credentials-step";
|
||||
import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls";
|
||||
import { LaunchStep } from "./steps/launch-step";
|
||||
import { TestConnectionStep } from "./steps/test-connection-step";
|
||||
import type { ProviderWizardInitialData } from "./types";
|
||||
import type { OrgWizardInitialData, ProviderWizardInitialData } from "./types";
|
||||
import { WizardStepper } from "./wizard-stepper";
|
||||
|
||||
interface ProviderWizardModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
}
|
||||
|
||||
export function ProviderWizardModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
}: ProviderWizardModalProps) {
|
||||
const {
|
||||
backToProviderFlow,
|
||||
@@ -43,6 +45,7 @@ export function ProviderWizardModal({
|
||||
handleClose,
|
||||
handleDialogOpenChange,
|
||||
handleTestSuccess,
|
||||
isOrgDirectEntry,
|
||||
isProviderFlow,
|
||||
modalTitle,
|
||||
openOrganizationsFlow,
|
||||
@@ -59,6 +62,7 @@ export function ProviderWizardModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
});
|
||||
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
|
||||
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
|
||||
@@ -149,13 +153,23 @@ export function ProviderWizardModal({
|
||||
|
||||
{!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && (
|
||||
<OrgSetupForm
|
||||
onBack={backToProviderFlow}
|
||||
onBack={isOrgDirectEntry ? handleClose : backToProviderFlow}
|
||||
onClose={handleClose}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
onPhaseChange={setOrgSetupPhase}
|
||||
initialPhase={orgSetupPhase}
|
||||
initialValues={
|
||||
orgInitialData
|
||||
? {
|
||||
organizationName: orgInitialData.organizationName,
|
||||
awsOrgId: orgInitialData.externalId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
intent={orgInitialData?.intent}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OrgSetupPhase, OrgWizardStep } from "@/types/organizations";
|
||||
import { ProviderWizardMode } from "@/types/provider-wizard";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
@@ -10,3 +11,21 @@ export interface ProviderWizardInitialData {
|
||||
via?: string | null;
|
||||
mode?: ProviderWizardMode;
|
||||
}
|
||||
|
||||
export const ORG_WIZARD_INTENT = {
|
||||
FULL: "full",
|
||||
EDIT_NAME: "edit-name",
|
||||
EDIT_CREDENTIALS: "edit-credentials",
|
||||
} as const;
|
||||
|
||||
export type OrgWizardIntent =
|
||||
(typeof ORG_WIZARD_INTENT)[keyof typeof ORG_WIZARD_INTENT];
|
||||
|
||||
export interface OrgWizardInitialData {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
externalId: string;
|
||||
targetStep: OrgWizardStep;
|
||||
targetPhase: OrgSetupPhase;
|
||||
intent?: OrgWizardIntent;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ export type ProvidersGroupKind =
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user