diff --git a/ui/actions/organizations/organizations.ts b/ui/actions/organizations/organizations.ts index 6d0d0e6afd..9c3caf1080 100644 --- a/ui/actions/organizations/organizations.ts +++ b/ui/actions/organizations/organizations.ts @@ -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 diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index a937bcd89a..2bf4b4edd1 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -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, @@ -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( diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index 5781020354..781fb1fc6f 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -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( } } -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, diff --git a/ui/components/providers/forms/delete-organization-form.tsx b/ui/components/providers/forms/delete-organization-form.tsx new file mode 100644 index 0000000000..fd9434280f --- /dev/null +++ b/ui/components/providers/forms/delete-organization-form.tsx @@ -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>; +} + +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 ( +
+ + + +
+ ); +} diff --git a/ui/components/providers/forms/edit-form.tsx b/ui/components/providers/forms/edit-form.tsx deleted file mode 100644 index 6ecab34368..0000000000 --- a/ui/components/providers/forms/edit-form.tsx +++ /dev/null @@ -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>; -}) => { - const formSchema = editProviderFormSchema(providerAlias ?? ""); - - const form = useForm>({ - 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) => { - 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 ( -
- -
- Current alias: {providerAlias} -
-
- -
- - - - - - ); -}; diff --git a/ui/components/providers/forms/edit-name-form.tsx b/ui/components/providers/forms/edit-name-form.tsx new file mode 100644 index 0000000000..5a2f1d2943 --- /dev/null +++ b/ui/components/providers/forms/edit-name-form.tsx @@ -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>; + onSave: (value: string) => Promise; +} + +export function EditNameForm({ + currentValue, + label, + successMessage, + placeholder, + helperText, + validate, + setIsOpen, + onSave, +}: EditNameFormProps) { + const [value, setValue] = useState(currentValue); + const [error, setError] = useState(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; + + 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 ( +
+
+ Current {label.toLowerCase()}:{" "} + {currentValue || "—"} +
+
+ + { + setValue(e.target.value); + if (error) setError(null); + }} + placeholder={placeholder ?? currentValue} + disabled={isLoading} + aria-invalid={!!error} + /> + {error &&

{error}

} + {helperText && !error && ( +

{helperText}

+ )} +
+ +
+ + +
+
+ ); +} diff --git a/ui/components/providers/forms/index.ts b/ui/components/providers/forms/index.ts index a081952ade..8a119f2aa9 100644 --- a/ui/components/providers/forms/index.ts +++ b/ui/components/providers/forms/index.ts @@ -1,2 +1,2 @@ export * from "./delete-form"; -export * from "./edit-form"; +export * from "./edit-name-form"; diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index 3990ffb435..3c480e3c48 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -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; +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(initialPhase); + const [isSaving, setIsSaving] = useState(false); const formId = "org-wizard-setup-form"; + const isReadOnlyOrgId = Boolean(initialValues?.awsOrgId); + const form = useForm({ 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) => { 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} /> 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; +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; + +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; + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + await user.click(screen.getByRole("button")); + + expect( + screen.queryByText("Edit Organization Name"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); + }); }); diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 18aea03937..bf9f1c5dd5 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -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; @@ -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; + onTestChildConnections: () => Promise; +} + +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(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 && ( + <> + + updateOrganizationName(rowData.id, name)} + /> + + + + )} + + + + +
+ + + + } + > + {isOrgKind && ( + <> + } + label="Edit Organization Name" + onSelect={() => setIsEditNameOpen(true)} + /> + } + label="Update Credentials" + onSelect={() => + openOrgWizardAt( + ORG_WIZARD_STEP.SETUP, + ORG_SETUP_PHASE.ACCESS, + ORG_WIZARD_INTENT.EDIT_CREDENTIALS, + ) + } + /> + + )} + } + label={loading ? "Testing..." : `Test Connections (${testCount})`} + onSelect={(e) => { + e.preventDefault(); + if (hasSelection) { + onBulkTest(testableProviderIds); + onClearSelection(); + } else { + onTestChildConnections(); + } + }} + disabled={testCount === 0 || loading} + /> + + } + label={ + isOrgKind ? "Delete Organization" : "Delete Organization Unit" + } + destructive + onSelect={() => setIsDeleteOrgOpen(true)} + /> + + +
+ + ); +} + 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} /> ); } - // Normal mode: all actions + // Organization / Organization Unit row actions + if (isProvidersOrganizationRow(rowData) && orgGroupKind) { + return ( + + ); + } + + // Provider row actions (unchanged) return ( <> {provider && ( - { + 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); + }} /> )} @@ -201,13 +410,11 @@ export function DataTableRowActions({ icon={} label="Edit Provider Alias" onSelect={() => setIsEditOpen(true)} - disabled={isOrganizationRow} /> } label="Update Credentials" onSelect={() => setIsWizardOpen(true)} - disabled={isOrganizationRow} /> } @@ -216,7 +423,7 @@ export function DataTableRowActions({ e.preventDefault(); handleTestConnection(); }} - disabled={isOrganizationRow || !hasSecret || loading} + disabled={!hasSecret || loading} /> setIsDeleteOpen(true)} - disabled={isOrganizationRow} /> diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index a5b6d6e188..111427131e 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -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, diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 76b2a6ea77..9157ca74ce 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -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 && ( { setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); }} onFooterChange={setFooterConfig} onPhaseChange={setOrgSetupPhase} initialPhase={orgSetupPhase} + initialValues={ + orgInitialData + ? { + organizationName: orgInitialData.organizationName, + awsOrgId: orgInitialData.externalId, + } + : undefined + } + intent={orgInitialData?.intent} /> )} diff --git a/ui/components/providers/wizard/types.ts b/ui/components/providers/wizard/types.ts index ab48b7fc70..92df95dbbb 100644 --- a/ui/components/providers/wizard/types.ts +++ b/ui/components/providers/wizard/types.ts @@ -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; +} diff --git a/ui/types/providers-table.ts b/ui/types/providers-table.ts index cad6f37ec7..553355b75b 100644 --- a/ui/types/providers-table.ts +++ b/ui/types/providers-table.ts @@ -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;