diff --git a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts new file mode 100644 index 0000000000..afed286560 --- /dev/null +++ b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts @@ -0,0 +1,544 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { applyDiscovery } from "@/actions/organizations/organizations"; +import { getOuIdsForSelectedAccounts } from "@/actions/organizations/organizations.adapter"; +import { + checkConnectionProvider, + getProvider, +} from "@/actions/providers/providers"; +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "@/components/providers/wizard/steps/footer-controls"; +import { useOrgSetupStore } from "@/store/organizations/store"; +import { + CONNECTION_TEST_STATUS, + ConnectionTestStatus, +} from "@/types/organizations"; +import { TREE_ITEM_STATUS, TreeDataItem } from "@/types/tree"; + +import { + buildAccountToProviderMap, + canAdvanceToLaunchStep, + getLaunchableProviderIds, + pollConnectionTask, + runWithConcurrencyLimit, +} from "../org-account-selection.utils"; + +interface SelectionState { + hasSelectableDescendants: boolean; + allSelectableDescendantsSelected: boolean; +} + +function collectFullySelectedNodeIds( + node: TreeDataItem, + selectedAccountIdSet: Set, + selectableAccountIdSet: Set, + selectedNodeIds: Set, +): SelectionState { + if (selectableAccountIdSet.has(node.id)) { + return { + hasSelectableDescendants: true, + allSelectableDescendantsSelected: selectedAccountIdSet.has(node.id), + }; + } + + const children = node.children ?? []; + let hasSelectableDescendants = false; + let allSelectableDescendantsSelected = true; + + for (const child of children) { + const childSelectionState = collectFullySelectedNodeIds( + child, + selectedAccountIdSet, + selectableAccountIdSet, + selectedNodeIds, + ); + + if (!childSelectionState.hasSelectableDescendants) { + continue; + } + + hasSelectableDescendants = true; + allSelectableDescendantsSelected = + allSelectableDescendantsSelected && + childSelectionState.allSelectableDescendantsSelected; + } + + if (hasSelectableDescendants && allSelectableDescendantsSelected) { + selectedNodeIds.add(node.id); + } + + return { + hasSelectableDescendants, + allSelectableDescendantsSelected, + }; +} + +function buildTreeSelectedIds( + treeData: TreeDataItem[], + selectedAccountIds: string[], + selectableAccountIdSet: Set, +): string[] { + const selectedAccountIdSet = new Set(selectedAccountIds); + const selectedNodeIds = new Set(); + + for (const rootNode of treeData) { + collectFullySelectedNodeIds( + rootNode, + selectedAccountIdSet, + selectableAccountIdSet, + selectedNodeIds, + ); + } + + return [...selectedAccountIds, ...Array.from(selectedNodeIds)]; +} + +function buildTreeWithConnectionState( + nodes: TreeDataItem[], + selectedAccountIdsSet: Set, + accountToProviderMap: Map, + connectionResults: Record, + connectionErrors: Record, + showPendingState: boolean, +): TreeDataItem[] { + return nodes.map((node) => { + const children = node.children + ? buildTreeWithConnectionState( + node.children, + selectedAccountIdsSet, + accountToProviderMap, + connectionResults, + connectionErrors, + showPendingState, + ) + : undefined; + + let isLoading = node.isLoading; + let status = node.status; + let errorMessage = node.errorMessage; + + if (selectedAccountIdsSet.has(node.id)) { + const providerId = accountToProviderMap.get(node.id); + const connectionStatus = providerId + ? connectionResults[providerId] + : undefined; + + if (connectionStatus === CONNECTION_TEST_STATUS.SUCCESS) { + isLoading = false; + status = TREE_ITEM_STATUS.SUCCESS; + errorMessage = undefined; + } else if (connectionStatus === CONNECTION_TEST_STATUS.ERROR) { + isLoading = false; + status = TREE_ITEM_STATUS.ERROR; + errorMessage = + (providerId && connectionErrors[providerId]) || "Connection failed."; + } else if ( + showPendingState || + connectionStatus === CONNECTION_TEST_STATUS.PENDING + ) { + isLoading = true; + status = undefined; + errorMessage = undefined; + } + } + + return { + ...node, + children, + isLoading, + status, + errorMessage, + }; + }); +} + +function extractErrorMessage(response: unknown, fallback: string): string { + if (!response || typeof response !== "object") { + return fallback; + } + + const responseRecord = response as { + error?: string; + errors?: Array<{ detail?: string }>; + }; + const detailedError = responseRecord.errors?.[0]?.detail; + return detailedError || responseRecord.error || fallback; +} + +function getSelectionKey(ids: string[]) { + return [...ids].sort().join(","); +} + +interface UseOrgAccountSelectionFlowProps { + onBack: () => void; + onNext: () => void; + onSkip: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +export function useOrgAccountSelectionFlow({ + onBack, + onNext, + onSkip, + onFooterChange, +}: UseOrgAccountSelectionFlowProps) { + const { + organizationId, + organizationExternalId, + discoveryId, + discoveryResult, + treeData, + accountLookup, + selectableAccountIds, + selectableAccountIdSet, + selectedAccountIds, + accountAliases, + createdProviderIds, + connectionResults, + connectionErrors, + setSelectedAccountIds, + setAccountAlias, + setCreatedProviderIds, + clearValidationState, + setConnectionError, + setConnectionResult, + } = useOrgSetupStore(); + + const [isTestingView, setIsTestingView] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [applyError, setApplyError] = useState(null); + const [accountToProviderMap, setAccountToProviderMap] = useState< + Map + >(new Map()); + const isMountedRef = useRef(true); + const hasAppliedRef = useRef(false); + const lastAppliedSelectionKeyRef = useRef(""); + const startTestingActionRef = useRef<() => void>(() => {}); + + const sanitizedSelectedAccountIds = selectedAccountIds.filter((id) => + selectableAccountIdSet.has(id), + ); + const selectedAccountKey = getSelectionKey(sanitizedSelectedAccountIds); + const selectedIdsForTree = buildTreeSelectedIds( + treeData, + sanitizedSelectedAccountIds, + selectableAccountIdSet, + ); + const selectedAccountIdSet = new Set(sanitizedSelectedAccountIds); + const selectedCount = sanitizedSelectedAccountIds.length; + const totalAccounts = selectableAccountIds.length; + const hasConnectionErrors = Object.values(connectionResults).some( + (status) => status === CONNECTION_TEST_STATUS.ERROR, + ); + const launchableProviderIds = getLaunchableProviderIds( + createdProviderIds, + connectionResults, + ); + const canAdvanceToLaunch = canAdvanceToLaunchStep( + createdProviderIds, + connectionResults, + ); + const showHeaderHelperText = !isTestingView || isApplying || isTesting; + const treeDataWithConnectionState = isTestingView + ? buildTreeWithConnectionState( + treeData, + selectedAccountIdSet, + accountToProviderMap, + connectionResults, + connectionErrors, + isApplying || isTesting, + ) + : treeData; + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + const testAllConnections = async (providerIds: string[]) => { + setIsTesting(true); + + for (const id of providerIds) { + setConnectionResult(id, CONNECTION_TEST_STATUS.PENDING); + setConnectionError(id, null); + } + + await runWithConcurrencyLimit(providerIds, 5, async (providerId) => { + if (!isMountedRef.current) { + return; + } + + try { + const formData = new FormData(); + formData.set("providerId", providerId); + + const checkResult = await checkConnectionProvider(formData); + if (!isMountedRef.current) { + return; + } + + if (checkResult?.error || checkResult?.errors?.length) { + setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR); + setConnectionError( + providerId, + extractErrorMessage(checkResult, "Connection test failed."), + ); + return; + } + + const taskId = checkResult?.data?.id; + if (!taskId) { + setConnectionResult(providerId, CONNECTION_TEST_STATUS.SUCCESS); + setConnectionError(providerId, null); + return; + } + + const taskResult = await pollConnectionTask(taskId); + if (!isMountedRef.current) { + return; + } + setConnectionResult( + providerId, + taskResult.success + ? CONNECTION_TEST_STATUS.SUCCESS + : CONNECTION_TEST_STATUS.ERROR, + ); + setConnectionError( + providerId, + taskResult.success + ? null + : taskResult.error || "Connection failed for this account.", + ); + } catch { + if (!isMountedRef.current) { + return; + } + setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR); + setConnectionError( + providerId, + "Unexpected error during connection test.", + ); + } + }); + + if (!isMountedRef.current) { + return; + } + setIsTesting(false); + + const latestResults = useOrgSetupStore.getState().connectionResults; + const allPassed = + providerIds.length > 0 && + providerIds.every( + (providerId) => + latestResults[providerId] === CONNECTION_TEST_STATUS.SUCCESS, + ); + + if (allPassed) { + onNext(); + } + }; + + const handleApplyAndTest = async () => { + if (!organizationId || !discoveryId || !discoveryResult) { + return; + } + + setApplyError(null); + setIsApplying(true); + + const accounts = sanitizedSelectedAccountIds.map((id) => ({ + id, + ...(accountAliases[id] ? { alias: accountAliases[id] } : {}), + })); + const ouIds = getOuIdsForSelectedAccounts( + discoveryResult, + sanitizedSelectedAccountIds, + ); + const organizationalUnits = ouIds.map((id) => ({ id })); + + const result = await applyDiscovery( + organizationId, + discoveryId, + accounts, + organizationalUnits, + ); + if (!isMountedRef.current) { + return; + } + + if (result?.error || result?.errors?.length) { + setApplyError(extractErrorMessage(result, "Failed to apply discovery.")); + setIsApplying(false); + hasAppliedRef.current = false; + return; + } + + const providerIds: string[] = + result.data?.relationships?.providers?.data?.map( + (provider: { id: string }) => provider.id, + ) ?? []; + + setCreatedProviderIds(providerIds); + const mapping = await buildAccountToProviderMap({ + selectedAccountIds: sanitizedSelectedAccountIds, + providerIds, + applyResult: result, + resolveProviderUidById: async (providerId) => { + const providerFormData = new FormData(); + providerFormData.set("id", providerId); + const providerResponse = await getProvider(providerFormData); + + if (providerResponse?.error || providerResponse?.errors?.length) { + return null; + } + + return typeof providerResponse?.data?.attributes?.uid === "string" + ? providerResponse.data.attributes.uid + : null; + }, + }); + if (!isMountedRef.current) { + return; + } + + setAccountToProviderMap(mapping); + setIsApplying(false); + lastAppliedSelectionKeyRef.current = selectedAccountKey; + + await testAllConnections(providerIds); + }; + + const handleStartTesting = () => { + setIsTestingView(true); + + if (applyError) { + setApplyError(null); + hasAppliedRef.current = false; + lastAppliedSelectionKeyRef.current = ""; + } + + const shouldApplySelection = + !hasAppliedRef.current || + lastAppliedSelectionKeyRef.current !== selectedAccountKey; + + if (shouldApplySelection) { + hasAppliedRef.current = true; + void handleApplyAndTest(); + return; + } + + const failedProviderIds = createdProviderIds.filter( + (providerId) => + connectionResults[providerId] === CONNECTION_TEST_STATUS.ERROR, + ); + const providerIdsToTest = + failedProviderIds.length > 0 ? failedProviderIds : createdProviderIds; + void testAllConnections(providerIdsToTest); + }; + startTestingActionRef.current = handleStartTesting; + + useEffect(() => { + if (!isTestingView) { + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showSecondaryAction: false, + secondaryActionLabel: "", + secondaryActionVariant: "outline", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + showAction: true, + actionLabel: "Test Connections", + actionDisabled: selectedCount === 0, + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: () => { + startTestingActionRef.current(); + }, + }); + return; + } + + const canRetry = hasConnectionErrors || Boolean(applyError); + + onFooterChange({ + showBack: true, + backLabel: "Back", + backDisabled: isApplying || isTesting, + onBack: () => setIsTestingView(false), + showSecondaryAction: true, + secondaryActionLabel: "Skip Connection Validation", + secondaryActionDisabled: isApplying || isTesting || !canAdvanceToLaunch, + secondaryActionVariant: "link", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onSecondaryAction: () => { + setCreatedProviderIds(launchableProviderIds); + onSkip(); + }, + showAction: isApplying || isTesting || canRetry, + actionLabel: "Test Connections", + actionDisabled: isApplying || isTesting || !canRetry, + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: canRetry + ? () => { + startTestingActionRef.current(); + } + : undefined, + }); + }, [ + applyError, + hasConnectionErrors, + isApplying, + isTesting, + isTestingView, + launchableProviderIds, + onBack, + onFooterChange, + onSkip, + selectedCount, + canAdvanceToLaunch, + setCreatedProviderIds, + ]); + + const handleTreeSelectionChange = (ids: string[]) => { + const filteredIds = ids.filter((id) => selectableAccountIdSet.has(id)); + const nextSelectedAccountKey = getSelectionKey(filteredIds); + + if (nextSelectedAccountKey !== selectedAccountKey) { + hasAppliedRef.current = false; + lastAppliedSelectionKeyRef.current = ""; + setApplyError(null); + setAccountToProviderMap(new Map()); + clearValidationState(); + } + + setSelectedAccountIds(filteredIds); + }; + + return { + accountAliases, + accountLookup, + applyError, + canAdvanceToLaunch, + discoveryResult, + handleTreeSelectionChange, + hasConnectionErrors, + isTesting, + isTestingView, + organizationExternalId, + selectedCount, + selectedIdsForTree, + setAccountAlias, + showHeaderHelperText, + totalAccounts, + treeDataWithConnectionState, + }; +} diff --git a/ui/components/providers/organizations/hooks/use-org-setup-submission.ts b/ui/components/providers/organizations/hooks/use-org-setup-submission.ts new file mode 100644 index 0000000000..40eeee885e --- /dev/null +++ b/ui/components/providers/organizations/hooks/use-org-setup-submission.ts @@ -0,0 +1,320 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { + createOrganization, + createOrganizationSecret, + getDiscovery, + listOrganizationsByExternalId, + listOrganizationSecretsByOrganizationId, + triggerDiscovery, + updateOrganizationSecret, +} from "@/actions/organizations/organizations"; +import { getSelectableAccountIds } from "@/actions/organizations/organizations.adapter"; +import { useOrgSetupStore } from "@/store/organizations/store"; +import { DISCOVERY_STATUS, DiscoveryResult } from "@/types/organizations"; + +const DISCOVERY_POLL_INTERVAL_MS = 3000; +const DISCOVERY_MAX_RETRIES = 60; + +function sleepWithAbort(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + + const timeoutId = window.setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + window.clearTimeout(timeoutId); + resolve(); + }, + { once: true }, + ); + }); +} + +interface OrgSetupSubmissionData { + organizationName?: string; + awsOrgId: string; + roleArn: string; +} + +interface UseOrgSetupSubmissionProps { + stackSetExternalId: string; + onNext: () => void; + setFieldError: ( + field: "awsOrgId" | "organizationName", + message: string, + ) => void; +} + +interface ServerErrorResult { + error?: string; + errors?: Array<{ detail: string; source?: { pointer: string } }>; +} + +export function useOrgSetupSubmission({ + stackSetExternalId, + onNext, + setFieldError, +}: UseOrgSetupSubmissionProps) { + const [apiError, setApiError] = useState(null); + const isMountedRef = useRef(true); + const discoveryAbortControllerRef = useRef(null); + const { + setOrganization, + setDiscovery, + setSelectedAccountIds, + clearValidationState, + } = useOrgSetupStore(); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + discoveryAbortControllerRef.current?.abort(); + }; + }, []); + + const handleServerError = (result: ServerErrorResult, context: string) => { + if (!isMountedRef.current) { + return; + } + + if (result.errors?.length) { + for (const err of result.errors) { + const pointer = err.source?.pointer ?? ""; + + if (pointer.includes("external_id") && context === "Organization") { + setFieldError("awsOrgId", err.detail); + setApiError(err.detail); + } else if (pointer.includes("name")) { + setFieldError("organizationName", err.detail); + } else { + setApiError(err.detail); + } + } + } else { + setApiError(result.error ?? `Failed to create ${context}`); + } + }; + + const pollDiscoveryResult = async ( + organizationId: string, + discoveryId: string, + signal: AbortSignal, + ): Promise => { + for (let attempt = 0; attempt < DISCOVERY_MAX_RETRIES; attempt += 1) { + if (signal.aborted || !isMountedRef.current) { + return null; + } + + const result = await getDiscovery(organizationId, discoveryId); + if (signal.aborted || !isMountedRef.current) { + return null; + } + + if (result?.error) { + setApiError( + `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${result.error}`, + ); + return null; + } + + const status = result.data.attributes.status; + + if (status === DISCOVERY_STATUS.SUCCEEDED) { + return result.data.attributes.result as DiscoveryResult; + } + + if (status === DISCOVERY_STATUS.FAILED) { + const backendError = result.data.attributes.error; + setApiError( + backendError + ? `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${backendError}` + : "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.", + ); + return null; + } + + await sleepWithAbort(DISCOVERY_POLL_INTERVAL_MS, signal); + } + + if (signal.aborted || !isMountedRef.current) { + return null; + } + + setApiError( + "Authentication timed out. Please verify the credentials and try again.", + ); + return null; + }; + + const submitOrganizationSetup = async (data: OrgSetupSubmissionData) => { + discoveryAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + discoveryAbortControllerRef.current = abortController; + const isCancelled = () => + !isMountedRef.current || abortController.signal.aborted; + const setApiErrorIfActive = (message: string) => { + if (!isCancelled()) { + setApiError(message); + } + }; + + try { + if (!isCancelled()) { + setApiError(null); + } + clearValidationState(); + + const resolvedOrganizationName = + data.organizationName?.trim() || data.awsOrgId; + + const existingOrganizationsResult = await listOrganizationsByExternalId( + data.awsOrgId, + ); + if (isCancelled()) { + return; + } + + if (existingOrganizationsResult?.error) { + setApiErrorIfActive(existingOrganizationsResult.error); + return; + } + + const existingOrganization = Array.isArray( + existingOrganizationsResult?.data, + ) + ? existingOrganizationsResult.data.find( + (organization: { + id: string; + attributes?: { external_id?: string; org_type?: string }; + }) => + organization?.attributes?.external_id === data.awsOrgId && + organization?.attributes?.org_type === "aws", + ) + : null; + + let orgId = existingOrganization?.id as string | undefined; + + if (!orgId) { + const orgFormData = new FormData(); + orgFormData.set("name", resolvedOrganizationName); + orgFormData.set("externalId", data.awsOrgId); + + const orgResult = await createOrganization(orgFormData); + if (isCancelled()) { + return; + } + + if (orgResult?.error || orgResult?.errors?.length) { + handleServerError(orgResult, "Organization"); + return; + } + + orgId = orgResult.data.id; + } + + if (!orgId) { + setApiErrorIfActive( + "Unable to resolve organization ID for authentication.", + ); + return; + } + + const organizationNameForStore = + existingOrganization?.attributes?.name ?? resolvedOrganizationName; + setOrganization(orgId, organizationNameForStore, data.awsOrgId); + + const existingSecretsResult = + await listOrganizationSecretsByOrganizationId(orgId); + if (isCancelled()) { + return; + } + + if (existingSecretsResult?.error) { + setApiErrorIfActive(existingSecretsResult.error); + return; + } + + const existingSecretId = + Array.isArray(existingSecretsResult?.data) && + existingSecretsResult.data.length > 0 + ? (existingSecretsResult.data[0]?.id as string | undefined) + : undefined; + + let secretResult; + if (existingSecretId) { + const patchSecretFormData = new FormData(); + patchSecretFormData.set("organizationSecretId", existingSecretId); + patchSecretFormData.set("roleArn", data.roleArn); + patchSecretFormData.set("externalId", stackSetExternalId); + secretResult = await updateOrganizationSecret(patchSecretFormData); + } else { + const createSecretFormData = new FormData(); + createSecretFormData.set("organizationId", orgId); + createSecretFormData.set("roleArn", data.roleArn); + createSecretFormData.set("externalId", stackSetExternalId); + secretResult = await createOrganizationSecret(createSecretFormData); + } + if (isCancelled()) { + return; + } + + if (secretResult?.error) { + handleServerError(secretResult, "Secret"); + return; + } + + const discoveryResult = await triggerDiscovery(orgId); + if (isCancelled()) { + return; + } + + if (discoveryResult?.error) { + setApiErrorIfActive(discoveryResult.error); + return; + } + + const discoveryId = discoveryResult.data.id; + const resolvedDiscoveryResult = await pollDiscoveryResult( + orgId, + discoveryId, + abortController.signal, + ); + + if (!resolvedDiscoveryResult || isCancelled()) { + return; + } + + const selectableAccountIds = getSelectableAccountIds( + resolvedDiscoveryResult, + ); + setDiscovery(discoveryId, resolvedDiscoveryResult); + setSelectedAccountIds(selectableAccountIds); + onNext(); + } catch { + if (!isCancelled()) { + setApiError( + "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.", + ); + } + } finally { + if (discoveryAbortControllerRef.current === abortController) { + discoveryAbortControllerRef.current = null; + } + } + }; + + return { + apiError, + setApiError, + submitOrganizationSetup, + }; +} diff --git a/ui/components/providers/organizations/org-account-selection.tsx b/ui/components/providers/organizations/org-account-selection.tsx index 484495162d..e9d6e1826a 100644 --- a/ui/components/providers/organizations/org-account-selection.tsx +++ b/ui/components/providers/organizations/org-account-selection.tsx @@ -1,41 +1,13 @@ "use client"; import { AlertTriangle } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { applyDiscovery } from "@/actions/organizations/organizations"; -import { - buildAccountLookup, - buildOrgTreeData, - getOuIdsForSelectedAccounts, - getSelectableAccountIds, -} from "@/actions/organizations/organizations.adapter"; -import { - checkConnectionProvider, - getProvider, -} from "@/actions/providers/providers"; import { AWSProviderBadge } from "@/components/icons/providers-badge"; -import { - WIZARD_FOOTER_ACTION_TYPE, - WizardFooterConfig, -} from "@/components/providers/wizard/steps/footer-controls"; +import { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { TreeView } from "@/components/shadcn/tree-view"; -import { useOrgSetupStore } from "@/store/organizations/store"; -import { - CONNECTION_TEST_STATUS, - ConnectionTestStatus, - DiscoveredAccount, -} from "@/types/organizations"; -import { TREE_ITEM_STATUS, TreeDataItem } from "@/types/tree"; -import { - buildAccountToProviderMap, - canAdvanceToLaunchStep, - getLaunchableProviderIds, - pollConnectionTask, - runWithConcurrencyLimit, -} from "./org-account-selection.utils"; +import { useOrgAccountSelectionFlow } from "./hooks/use-org-account-selection-flow"; import { OrgAccountTreeItem, TREE_ITEM_MODE } from "./org-account-tree-item"; interface OrgAccountSelectionProps { @@ -45,148 +17,6 @@ interface OrgAccountSelectionProps { onFooterChange: (config: WizardFooterConfig) => void; } -interface SelectionState { - hasSelectableDescendants: boolean; - allSelectableDescendantsSelected: boolean; -} - -function collectFullySelectedNodeIds( - node: TreeDataItem, - selectedAccountIdSet: Set, - selectableAccountIdSet: Set, - selectedNodeIds: Set, -): SelectionState { - if (selectableAccountIdSet.has(node.id)) { - return { - hasSelectableDescendants: true, - allSelectableDescendantsSelected: selectedAccountIdSet.has(node.id), - }; - } - - const children = node.children ?? []; - let hasSelectableDescendants = false; - let allSelectableDescendantsSelected = true; - - for (const child of children) { - const childSelectionState = collectFullySelectedNodeIds( - child, - selectedAccountIdSet, - selectableAccountIdSet, - selectedNodeIds, - ); - - if (!childSelectionState.hasSelectableDescendants) { - continue; - } - - hasSelectableDescendants = true; - allSelectableDescendantsSelected = - allSelectableDescendantsSelected && - childSelectionState.allSelectableDescendantsSelected; - } - - if (hasSelectableDescendants && allSelectableDescendantsSelected) { - selectedNodeIds.add(node.id); - } - - return { - hasSelectableDescendants, - allSelectableDescendantsSelected, - }; -} - -function buildTreeSelectedIds( - treeData: TreeDataItem[], - selectedAccountIds: string[], - selectableAccountIdSet: Set, -): string[] { - const selectedAccountIdSet = new Set(selectedAccountIds); - const selectedNodeIds = new Set(); - - for (const rootNode of treeData) { - collectFullySelectedNodeIds( - rootNode, - selectedAccountIdSet, - selectableAccountIdSet, - selectedNodeIds, - ); - } - - return [...selectedAccountIds, ...Array.from(selectedNodeIds)]; -} - -function buildTreeWithConnectionState( - nodes: TreeDataItem[], - selectedAccountIdsSet: Set, - accountToProviderMap: Map, - connectionResults: Record, - connectionErrors: Record, - showPendingState: boolean, -): TreeDataItem[] { - return nodes.map((node) => { - const children = node.children - ? buildTreeWithConnectionState( - node.children, - selectedAccountIdsSet, - accountToProviderMap, - connectionResults, - connectionErrors, - showPendingState, - ) - : undefined; - - let isLoading = node.isLoading; - let status = node.status; - let errorMessage = node.errorMessage; - - if (selectedAccountIdsSet.has(node.id)) { - const providerId = accountToProviderMap.get(node.id); - const connectionStatus = providerId - ? connectionResults[providerId] - : undefined; - - if (connectionStatus === CONNECTION_TEST_STATUS.SUCCESS) { - isLoading = false; - status = TREE_ITEM_STATUS.SUCCESS; - errorMessage = undefined; - } else if (connectionStatus === CONNECTION_TEST_STATUS.ERROR) { - isLoading = false; - status = TREE_ITEM_STATUS.ERROR; - errorMessage = - (providerId && connectionErrors[providerId]) || "Connection failed."; - } else if ( - showPendingState || - connectionStatus === CONNECTION_TEST_STATUS.PENDING - ) { - isLoading = true; - status = undefined; - errorMessage = undefined; - } - } - - return { - ...node, - children, - isLoading, - status, - errorMessage, - }; - }); -} - -function extractErrorMessage(response: unknown, fallback: string): string { - if (!response || typeof response !== "object") { - return fallback; - } - - const responseRecord = response as { - error?: string; - errors?: Array<{ detail?: string }>; - }; - const detailedError = responseRecord.errors?.[0]?.detail; - return detailedError || responseRecord.error || fallback; -} - export function OrgAccountSelection({ onBack, onNext, @@ -194,310 +24,28 @@ export function OrgAccountSelection({ onFooterChange, }: OrgAccountSelectionProps) { const { - organizationId, - organizationExternalId, - discoveryId, - discoveryResult, - selectedAccountIds, accountAliases, - createdProviderIds, - connectionResults, - connectionErrors, - setSelectedAccountIds, - setAccountAlias, - setCreatedProviderIds, - setConnectionError, - setConnectionResult, - } = useOrgSetupStore(); - const [isTestingView, setIsTestingView] = useState(false); - const [isApplying, setIsApplying] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [applyError, setApplyError] = useState(null); - const [accountToProviderMap, setAccountToProviderMap] = useState< - Map - >(new Map()); - const hasAppliedRef = useRef(false); - const startTestingActionRef = useRef<() => void>(() => {}); - - const treeData = discoveryResult ? buildOrgTreeData(discoveryResult) : []; - const accountLookup: Map = discoveryResult - ? buildAccountLookup(discoveryResult) - : new Map(); - const selectableAccountIds = discoveryResult - ? getSelectableAccountIds(discoveryResult) - : []; - const selectableAccountIdSet = new Set(selectableAccountIds); - const sanitizedSelectedAccountIds = selectedAccountIds.filter((id) => - selectableAccountIdSet.has(id), - ); - const selectedIdsForTree = buildTreeSelectedIds( - treeData, - sanitizedSelectedAccountIds, - selectableAccountIdSet, - ); - const selectedAccountIdSet = new Set(sanitizedSelectedAccountIds); - const selectedCount = sanitizedSelectedAccountIds.length; - const totalAccounts = selectableAccountIds.length; - const hasConnectionErrors = Object.values(connectionResults).some( - (status) => status === CONNECTION_TEST_STATUS.ERROR, - ); - const launchableProviderIds = getLaunchableProviderIds( - createdProviderIds, - connectionResults, - ); - const canAdvanceToLaunch = canAdvanceToLaunchStep( - createdProviderIds, - connectionResults, - ); - const showHeaderHelperText = !isTestingView || isApplying || isTesting; - const treeDataWithConnectionState = isTestingView - ? buildTreeWithConnectionState( - treeData, - selectedAccountIdSet, - accountToProviderMap, - connectionResults, - connectionErrors, - isApplying || isTesting, - ) - : treeData; - - useEffect(() => { - if (!discoveryResult) { - return; - } - - if (sanitizedSelectedAccountIds.length === selectedAccountIds.length) { - return; - } - - setSelectedAccountIds(sanitizedSelectedAccountIds); - }, [ - discoveryResult, - sanitizedSelectedAccountIds, - selectedAccountIds, - setSelectedAccountIds, - ]); - - const testAllConnections = async (providerIds: string[]) => { - setIsTesting(true); - - for (const id of providerIds) { - setConnectionResult(id, CONNECTION_TEST_STATUS.PENDING); - setConnectionError(id, null); - } - - await runWithConcurrencyLimit(providerIds, 5, async (providerId) => { - try { - const formData = new FormData(); - formData.set("providerId", providerId); - - const checkResult = await checkConnectionProvider(formData); - if (checkResult?.error || checkResult?.errors?.length) { - setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR); - setConnectionError( - providerId, - extractErrorMessage(checkResult, "Connection test failed."), - ); - return; - } - - const taskId = checkResult?.data?.id; - if (!taskId) { - setConnectionResult(providerId, CONNECTION_TEST_STATUS.SUCCESS); - setConnectionError(providerId, null); - return; - } - - const taskResult = await pollConnectionTask(taskId); - setConnectionResult( - providerId, - taskResult.success - ? CONNECTION_TEST_STATUS.SUCCESS - : CONNECTION_TEST_STATUS.ERROR, - ); - setConnectionError( - providerId, - taskResult.success - ? null - : taskResult.error || "Connection failed for this account.", - ); - } catch { - setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR); - setConnectionError( - providerId, - "Unexpected error during connection test.", - ); - } - }); - - setIsTesting(false); - - const latestResults = useOrgSetupStore.getState().connectionResults; - const allPassed = - providerIds.length > 0 && - providerIds.every( - (providerId) => - latestResults[providerId] === CONNECTION_TEST_STATUS.SUCCESS, - ); - - if (allPassed) { - onNext(); - } - }; - - const handleApplyAndTest = async () => { - if (!organizationId || !discoveryId || !discoveryResult) { - return; - } - - setApplyError(null); - setIsApplying(true); - - const accounts = sanitizedSelectedAccountIds.map((id) => ({ - id, - ...(accountAliases[id] ? { alias: accountAliases[id] } : {}), - })); - const ouIds = getOuIdsForSelectedAccounts( - discoveryResult, - sanitizedSelectedAccountIds, - ); - const organizationalUnits = ouIds.map((id) => ({ id })); - - const result = await applyDiscovery( - organizationId, - discoveryId, - accounts, - organizationalUnits, - ); - - if (result?.error || result?.errors?.length) { - setApplyError(extractErrorMessage(result, "Failed to apply discovery.")); - setIsApplying(false); - hasAppliedRef.current = false; - return; - } - - const providerIds: string[] = - result.data?.relationships?.providers?.data?.map( - (provider: { id: string }) => provider.id, - ) ?? []; - - setCreatedProviderIds(providerIds); - const mapping = await buildAccountToProviderMap({ - selectedAccountIds: sanitizedSelectedAccountIds, - providerIds, - applyResult: result, - resolveProviderUidById: async (providerId) => { - const providerFormData = new FormData(); - providerFormData.set("id", providerId); - const providerResponse = await getProvider(providerFormData); - - if (providerResponse?.error || providerResponse?.errors?.length) { - return null; - } - - return typeof providerResponse?.data?.attributes?.uid === "string" - ? providerResponse.data.attributes.uid - : null; - }, - }); - setAccountToProviderMap(mapping); - setIsApplying(false); - - await testAllConnections(providerIds); - }; - - const handleStartTesting = () => { - setIsTestingView(true); - - if (applyError) { - setApplyError(null); - hasAppliedRef.current = false; - } - - if (!hasAppliedRef.current) { - hasAppliedRef.current = true; - void handleApplyAndTest(); - return; - } - - const failedProviderIds = createdProviderIds.filter( - (providerId) => - connectionResults[providerId] === CONNECTION_TEST_STATUS.ERROR, - ); - const providerIdsToTest = - failedProviderIds.length > 0 ? failedProviderIds : createdProviderIds; - void testAllConnections(providerIdsToTest); - }; - startTestingActionRef.current = handleStartTesting; - - useEffect(() => { - if (!isTestingView) { - onFooterChange({ - showBack: true, - backLabel: "Back", - onBack, - showSecondaryAction: false, - secondaryActionLabel: "", - secondaryActionVariant: "outline", - secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - showAction: true, - actionLabel: "Test Connections", - actionDisabled: selectedCount === 0, - actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - onAction: () => { - startTestingActionRef.current(); - }, - }); - return; - } - - const canRetry = hasConnectionErrors || Boolean(applyError); - - onFooterChange({ - showBack: true, - backLabel: "Back", - backDisabled: isApplying || isTesting, - onBack: () => setIsTestingView(false), - showSecondaryAction: true, - secondaryActionLabel: "Skip Connection Validation", - secondaryActionDisabled: isApplying || isTesting || !canAdvanceToLaunch, - secondaryActionVariant: "link", - secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - onSecondaryAction: () => { - setCreatedProviderIds(launchableProviderIds); - onSkip(); - }, - showAction: isApplying || isTesting || canRetry, - actionLabel: "Test Connections", - actionDisabled: isApplying || isTesting || !canRetry, - actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - onAction: canRetry - ? () => { - startTestingActionRef.current(); - } - : undefined, - }); - }, [ + accountLookup, applyError, - connectionErrors, + canAdvanceToLaunch, + discoveryResult, + handleTreeSelectionChange, hasConnectionErrors, - isApplying, isTesting, isTestingView, - launchableProviderIds, - onBack, - onFooterChange, - onSkip, + organizationExternalId, selectedCount, - canAdvanceToLaunch, - setConnectionError, - setCreatedProviderIds, - ]); - - const handleTreeSelectionChange = (ids: string[]) => { - setSelectedAccountIds(ids.filter((id) => selectableAccountIdSet.has(id))); - }; + selectedIdsForTree, + setAccountAlias, + showHeaderHelperText, + totalAccounts, + treeDataWithConnectionState, + } = useOrgAccountSelectionFlow({ + onBack, + onNext, + onSkip, + onFooterChange, + }); if (!discoveryResult) { return ( @@ -509,7 +57,6 @@ export function OrgAccountSelection({ return (
- {/* Header */}
@@ -556,7 +103,6 @@ export function OrgAccountSelection({ )} - {/* Tree */}
(null); const [isExternalIdCopied, setIsExternalIdCopied] = useState(false); const stackSetExternalId = session?.tenantId ?? ""; const [setupPhase, setSetupPhase] = useState(initialPhase); - const { - setOrganization, - setDiscovery, - setSelectedAccountIds, - clearValidationState, - } = useOrgSetupStore(); const formId = "org-wizard-setup-form"; const { @@ -124,6 +97,15 @@ export function OrgSetupForm({ stackSetExternalId && getAWSCredentialsTemplateLinks(stackSetExternalId).cloudformationQuickLink; + const { apiError, setApiError, submitOrganizationSetup } = + useOrgSetupSubmission({ + stackSetExternalId, + onNext, + setFieldError: (field, message) => { + setError(field, { message }); + }, + }); + useEffect(() => { onPhaseChange(setupPhase); }, [onPhaseChange, setupPhase]); @@ -187,199 +169,7 @@ export function OrgSetupForm({ return; } - void handleSubmit(onSubmit)(event); - }; - - const onSubmit = async (data: OrgSetupFormData) => { - try { - setApiError(null); - clearValidationState(); - const resolvedOrganizationName = - data.organizationName?.trim() || data.awsOrgId; - - // Step 1: Resolve existing organization by external_id. If missing, create it. - const existingOrganizationsResult = await listOrganizationsByExternalId( - data.awsOrgId, - ); - - if (existingOrganizationsResult?.error) { - setApiError(existingOrganizationsResult.error); - return; - } - - const existingOrganization = Array.isArray( - existingOrganizationsResult?.data, - ) - ? existingOrganizationsResult.data.find( - (organization: { - id: string; - attributes?: { external_id?: string; org_type?: string }; - }) => - organization?.attributes?.external_id === data.awsOrgId && - organization?.attributes?.org_type === "aws", - ) - : null; - - let orgId = existingOrganization?.id as string | undefined; - - if (!orgId) { - const orgFormData = new FormData(); - orgFormData.set("name", resolvedOrganizationName); - orgFormData.set("externalId", data.awsOrgId); - - const orgResult = await createOrganization(orgFormData); - - if (orgResult?.error) { - handleServerError(orgResult, "Organization"); - return; - } - - orgId = orgResult.data.id; - } - - if (!orgId) { - setApiError("Unable to resolve organization ID for authentication."); - return; - } - - const organizationNameForStore = - existingOrganization?.attributes?.name ?? resolvedOrganizationName; - setOrganization(orgId, organizationNameForStore, data.awsOrgId); - - // Step 2: Create or update organization secret. - const existingSecretsResult = - await listOrganizationSecretsByOrganizationId(orgId); - - if (existingSecretsResult?.error) { - setApiError(existingSecretsResult.error); - return; - } - - const existingSecretId = - Array.isArray(existingSecretsResult?.data) && - existingSecretsResult.data.length > 0 - ? (existingSecretsResult.data[0]?.id as string | undefined) - : undefined; - - let secretResult; - if (existingSecretId) { - const patchSecretFormData = new FormData(); - patchSecretFormData.set("organizationSecretId", existingSecretId); - patchSecretFormData.set("roleArn", data.roleArn); - patchSecretFormData.set("externalId", stackSetExternalId); - secretResult = await updateOrganizationSecret(patchSecretFormData); - } else { - const createSecretFormData = new FormData(); - createSecretFormData.set("organizationId", orgId); - createSecretFormData.set("roleArn", data.roleArn); - createSecretFormData.set("externalId", stackSetExternalId); - secretResult = await createOrganizationSecret(createSecretFormData); - } - - if (secretResult?.error) { - handleServerError(secretResult, "Secret"); - return; - } - - // Step 3: Trigger Discovery - const discoveryResult = await triggerDiscovery(orgId); - - if (discoveryResult?.error) { - setApiError(discoveryResult.error); - return; - } - - const discoveryId = discoveryResult.data.id; - - const resolvedDiscoveryResult = await pollDiscoveryResult( - orgId, - discoveryId, - ); - - if (!resolvedDiscoveryResult) { - return; - } - - const selectableAccountIds = getSelectableAccountIds( - resolvedDiscoveryResult, - ); - buildOrgTreeData(resolvedDiscoveryResult); - setDiscovery(discoveryId, resolvedDiscoveryResult); - setSelectedAccountIds(selectableAccountIds); - - // Discovery succeeded; advance to next wizard step. - onNext(); - } catch { - setApiError( - "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.", - ); - } - }; - - const pollDiscoveryResult = async ( - organizationId: string, - discoveryId: string, - ): Promise => { - for (let attempt = 0; attempt < DISCOVERY_MAX_RETRIES; attempt += 1) { - const result = await getDiscovery(organizationId, discoveryId); - - if (result?.error) { - setApiError( - `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${result.error}`, - ); - return null; - } - - const status = result.data.attributes.status; - - if (status === DISCOVERY_STATUS.SUCCEEDED) { - return result.data.attributes.result as DiscoveryResult; - } - - if (status === DISCOVERY_STATUS.FAILED) { - const backendError = result.data.attributes.error; - setApiError( - backendError - ? `Authentication failed. Please verify the StackSet deployment and Role ARN, then try again. ${backendError}` - : "Authentication failed. Please verify the StackSet deployment and Role ARN, then try again.", - ); - return null; - } - - await new Promise((resolve) => - setTimeout(resolve, DISCOVERY_POLL_INTERVAL_MS), - ); - } - - setApiError( - "Authentication timed out. Please verify the credentials and try again.", - ); - return null; - }; - - const handleServerError = ( - result: { - error?: string; - errors?: Array<{ detail: string; source?: { pointer: string } }>; - }, - context: string, - ) => { - if (result.errors?.length) { - for (const err of result.errors) { - const pointer = err.source?.pointer ?? ""; - - if (pointer.includes("external_id") && context === "Organization") { - setError("awsOrgId", { message: err.detail }); - setApiError(err.detail); - } else if (pointer.includes("name")) { - setError("organizationName", { message: err.detail }); - } else { - setApiError(err.detail); - } - } - } else { - setApiError(result.error ?? `Failed to create ${context}`); - } + void handleSubmit((data) => submitOrganizationSetup(data))(event); }; useEffect(() => { diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts new file mode 100644 index 0000000000..f6675bd4f0 --- /dev/null +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -0,0 +1,244 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { getProviderHelpText } from "@/lib/external-urls"; +import { useOrgSetupStore } from "@/store/organizations/store"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import { + ORG_SETUP_PHASE, + ORG_WIZARD_STEP, + OrgSetupPhase, + OrgWizardStep, +} from "@/types/organizations"; +import { + PROVIDER_WIZARD_MODE, + PROVIDER_WIZARD_STEP, + ProviderWizardStep, +} from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +import { getProviderWizardModalTitle } from "../provider-wizard-modal.utils"; +import { + WIZARD_FOOTER_ACTION_TYPE, + WizardFooterConfig, +} from "../steps/footer-controls"; +import type { ProviderWizardInitialData } from "../types"; + +const WIZARD_VARIANT = { + PROVIDER: "provider", + ORGANIZATIONS: "organizations", +} as const; + +type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT]; + +const EMPTY_FOOTER_CONFIG: WizardFooterConfig = { + showBack: false, + backLabel: "Back", + showSecondaryAction: false, + secondaryActionLabel: "", + secondaryActionVariant: "outline", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + showAction: false, + actionLabel: "Next", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, +}; + +interface UseProviderWizardControllerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData?: ProviderWizardInitialData; +} + +export function useProviderWizardController({ + open, + onOpenChange, + initialData, +}: UseProviderWizardControllerProps) { + const initialProviderId = initialData?.providerId ?? null; + const initialProviderType = initialData?.providerType ?? null; + const initialProviderUid = initialData?.providerUid ?? null; + const initialProviderAlias = initialData?.providerAlias ?? null; + const initialSecretId = initialData?.secretId ?? null; + const initialVia = initialData?.via ?? null; + const initialMode = initialData?.mode ?? null; + const router = useRouter(); + const [wizardVariant, setWizardVariant] = useState( + WIZARD_VARIANT.PROVIDER, + ); + const [currentStep, setCurrentStep] = useState( + PROVIDER_WIZARD_STEP.CONNECT, + ); + const [orgCurrentStep, setOrgCurrentStep] = useState( + ORG_WIZARD_STEP.SETUP, + ); + const [footerConfig, setFooterConfig] = + useState(EMPTY_FOOTER_CONFIG); + const [providerTypeHint, setProviderTypeHint] = useState( + null, + ); + const [orgSetupPhase, setOrgSetupPhase] = useState( + ORG_SETUP_PHASE.DETAILS, + ); + + const { + reset: resetProviderWizard, + setProvider, + setVia, + setSecretId, + setMode, + mode, + providerType, + } = useProviderWizardStore(); + const { reset: resetOrgWizard } = useOrgSetupStore(); + + useEffect(() => { + if (!open) { + return; + } + + if (initialProviderId && initialProviderType && initialProviderUid) { + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setProvider({ + id: initialProviderId, + type: initialProviderType, + uid: initialProviderUid, + alias: initialProviderAlias, + }); + setVia(initialVia); + setSecretId(initialSecretId); + setMode( + initialMode || + (initialSecretId + ? PROVIDER_WIZARD_MODE.UPDATE + : PROVIDER_WIZARD_MODE.ADD), + ); + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(initialProviderType); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + return; + } + + resetProviderWizard(); + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + }, [ + initialMode, + initialProviderAlias, + initialProviderId, + initialProviderType, + initialProviderUid, + initialSecretId, + initialVia, + open, + resetOrgWizard, + resetProviderWizard, + setMode, + setProvider, + setSecretId, + setVia, + ]); + + const handleClose = () => { + resetProviderWizard(); + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + onOpenChange(false); + }; + + const handleDialogOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + onOpenChange(true); + return; + } + handleClose(); + }; + + const handleTestSuccess = () => { + if (mode === PROVIDER_WIZARD_MODE.UPDATE) { + handleClose(); + return; + } + + setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); + }; + + const openOrganizationsFlow = () => { + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS); + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + }; + + const backToProviderFlow = () => { + resetOrgWizard(); + setWizardVariant(WIZARD_VARIANT.PROVIDER); + setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); + setFooterConfig(EMPTY_FOOTER_CONFIG); + setProviderTypeHint(null); + setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); + }; + + const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER; + const docsLink = getProviderHelpText( + isProviderFlow ? (providerTypeHint ?? providerType ?? "") : "aws", + ).link; + const resolvedFooterConfig: WizardFooterConfig = + isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH + ? { + showBack: true, + backLabel: "Back", + onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST), + showSecondaryAction: false, + secondaryActionLabel: "", + secondaryActionVariant: "outline", + secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + showAction: true, + actionLabel: "Go to scans", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + onAction: () => { + handleClose(); + router.push("/scans"); + }, + } + : footerConfig; + const modalTitle = getProviderWizardModalTitle(mode); + + return { + currentStep, + docsLink, + footerConfig, + handleClose, + handleDialogOpenChange, + handleTestSuccess, + isProviderFlow, + modalTitle, + openOrganizationsFlow, + orgCurrentStep, + orgSetupPhase, + providerTypeHint, + resolvedFooterConfig, + setCurrentStep, + setFooterConfig, + setOrgCurrentStep, + setOrgSetupPhase, + setProviderTypeHint, + backToProviderFlow, + wizardVariant, + }; +} diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index ae9a8393f3..71cc88b8d2 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -1,8 +1,6 @@ "use client"; import { ExternalLink, Info } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection"; import { OrgLaunchScan } from "@/components/providers/organizations/org-launch-scan"; @@ -11,63 +9,19 @@ import { Button } from "@/components/shadcn/button/button"; import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal"; import { useScrollHint } from "@/hooks/use-scroll-hint"; -import { getProviderHelpText } from "@/lib"; -import { useOrgSetupStore } from "@/store/organizations/store"; -import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { - ORG_SETUP_PHASE, - ORG_WIZARD_STEP, - OrgSetupPhase, - OrgWizardStep, -} from "@/types/organizations"; -import { - PROVIDER_WIZARD_MODE, - PROVIDER_WIZARD_STEP, - ProviderWizardMode, - ProviderWizardStep, -} from "@/types/provider-wizard"; -import { ProviderType } from "@/types/providers"; +import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; +import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard"; +import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { getOrganizationsStepperOffset } from "./provider-wizard-modal.utils"; import { ConnectStep } from "./steps/connect-step"; import { CredentialsStep } from "./steps/credentials-step"; -import { - WIZARD_FOOTER_ACTION_TYPE, - WizardFooterConfig, -} from "./steps/footer-controls"; +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 { WizardStepper } from "./wizard-stepper"; -const WIZARD_VARIANT = { - PROVIDER: "provider", - ORGANIZATIONS: "organizations", -} as const; - -type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT]; - -const EMPTY_FOOTER_CONFIG: WizardFooterConfig = { - showBack: false, - backLabel: "Back", - showSecondaryAction: false, - secondaryActionLabel: "", - secondaryActionVariant: "outline", - secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - showAction: false, - actionLabel: "Next", - actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, -}; - -export interface ProviderWizardInitialData { - providerId: string; - providerType: ProviderType; - providerUid: string; - providerAlias: string | null; - secretId?: string | null; - via?: string | null; - mode?: ProviderWizardMode; -} - interface ProviderWizardModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -79,160 +33,36 @@ export function ProviderWizardModal({ onOpenChange, initialData, }: ProviderWizardModalProps) { - const router = useRouter(); - const [wizardVariant, setWizardVariant] = useState( - WIZARD_VARIANT.PROVIDER, - ); - const [currentStep, setCurrentStep] = useState( - PROVIDER_WIZARD_STEP.CONNECT, - ); - const [orgCurrentStep, setOrgCurrentStep] = useState( - ORG_WIZARD_STEP.SETUP, - ); - const [footerConfig, setFooterConfig] = - useState(EMPTY_FOOTER_CONFIG); - const [providerTypeHint, setProviderTypeHint] = useState( - null, - ); - const [orgSetupPhase, setOrgSetupPhase] = useState( - ORG_SETUP_PHASE.DETAILS, - ); + const { + backToProviderFlow, + currentStep, + docsLink, + handleClose, + handleDialogOpenChange, + handleTestSuccess, + isProviderFlow, + modalTitle, + openOrganizationsFlow, + orgCurrentStep, + orgSetupPhase, + resolvedFooterConfig, + setCurrentStep, + setFooterConfig, + setOrgCurrentStep, + setOrgSetupPhase, + setProviderTypeHint, + wizardVariant, + } = useProviderWizardController({ + open, + onOpenChange, + initialData, + }); const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`; const { containerRef, showScrollHint, handleScroll } = useScrollHint({ enabled: open, refreshToken: scrollHintRefreshToken, }); - const { - reset: resetProviderWizard, - setProvider, - setVia, - setSecretId, - setMode, - mode, - providerType, - } = useProviderWizardStore(); - const { reset: resetOrgWizard } = useOrgSetupStore(); - - useEffect(() => { - if (!open) { - return; - } - - if (initialData) { - setWizardVariant(WIZARD_VARIANT.PROVIDER); - setProvider({ - id: initialData.providerId, - type: initialData.providerType, - uid: initialData.providerUid, - alias: initialData.providerAlias, - }); - setVia(initialData.via || null); - setSecretId(initialData.secretId || null); - setMode( - initialData.mode || - (initialData.secretId - ? PROVIDER_WIZARD_MODE.UPDATE - : PROVIDER_WIZARD_MODE.ADD), - ); - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); - setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); - setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(initialData.providerType); - setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); - return; - } - - resetProviderWizard(); - resetOrgWizard(); - setWizardVariant(WIZARD_VARIANT.PROVIDER); - setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); - setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); - setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(null); - setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); - }, [ - initialData, - open, - resetOrgWizard, - resetProviderWizard, - setMode, - setProvider, - setSecretId, - setVia, - ]); - - const handleClose = () => { - resetProviderWizard(); - resetOrgWizard(); - setWizardVariant(WIZARD_VARIANT.PROVIDER); - setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); - setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); - setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(null); - setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); - onOpenChange(false); - }; - - const handleDialogOpenChange = (nextOpen: boolean) => { - if (nextOpen) { - onOpenChange(true); - return; - } - handleClose(); - }; - - const handleTestSuccess = () => { - if (mode === PROVIDER_WIZARD_MODE.UPDATE) { - handleClose(); - return; - } - - setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); - }; - - const openOrganizationsFlow = () => { - resetOrgWizard(); - setWizardVariant(WIZARD_VARIANT.ORGANIZATIONS); - setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); - setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(null); - setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); - }; - - const backToProviderFlow = () => { - resetOrgWizard(); - setWizardVariant(WIZARD_VARIANT.PROVIDER); - setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT); - setFooterConfig(EMPTY_FOOTER_CONFIG); - setProviderTypeHint(null); - setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); - }; - - const isProviderFlow = wizardVariant === WIZARD_VARIANT.PROVIDER; - const docsLink = getProviderHelpText( - isProviderFlow ? (providerTypeHint ?? providerType ?? "") : "aws", - ).link; - const resolvedFooterConfig: WizardFooterConfig = - isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.LAUNCH - ? { - showBack: true, - backLabel: "Back", - onBack: () => setCurrentStep(PROVIDER_WIZARD_STEP.TEST), - showSecondaryAction: false, - secondaryActionLabel: "", - secondaryActionVariant: "outline", - secondaryActionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - showAction: true, - actionLabel: "Go to scans", - actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - onAction: () => { - handleClose(); - router.push("/scans"); - }, - } - : footerConfig; - return ( - Adding A Cloud Provider + {modalTitle}
diff --git a/ui/components/providers/wizard/types.ts b/ui/components/providers/wizard/types.ts new file mode 100644 index 0000000000..ab48b7fc70 --- /dev/null +++ b/ui/components/providers/wizard/types.ts @@ -0,0 +1,12 @@ +import { ProviderWizardMode } from "@/types/provider-wizard"; +import { ProviderType } from "@/types/providers"; + +export interface ProviderWizardInitialData { + providerId: string; + providerType: ProviderType; + providerUid: string; + providerAlias: string | null; + secretId?: string | null; + via?: string | null; + mode?: ProviderWizardMode; +}