feat(ui): add organization-specific actions to providers table dropdown (#10317)

This commit is contained in:
Alejandro Bailo
2026-03-16 10:32:12 +01:00
committed by GitHub
parent 22f79edec5
commit 4cd3b09818
14 changed files with 919 additions and 347 deletions

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View 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>
);
}

View File

@@ -1,2 +1,2 @@
export * from "./delete-form";
export * from "./edit-form";
export * from "./edit-name-form";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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