refactor(ui): extract wizard and org hooks from components

This commit is contained in:
alejandrobailo
2026-02-24 12:04:28 +01:00
parent 8399557128
commit 2f74f6fb2e
7 changed files with 1180 additions and 894 deletions
@@ -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<string>,
selectableAccountIdSet: Set<string>,
selectedNodeIds: Set<string>,
): 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>,
): string[] {
const selectedAccountIdSet = new Set(selectedAccountIds);
const selectedNodeIds = new Set<string>();
for (const rootNode of treeData) {
collectFullySelectedNodeIds(
rootNode,
selectedAccountIdSet,
selectableAccountIdSet,
selectedNodeIds,
);
}
return [...selectedAccountIds, ...Array.from(selectedNodeIds)];
}
function buildTreeWithConnectionState(
nodes: TreeDataItem[],
selectedAccountIdsSet: Set<string>,
accountToProviderMap: Map<string, string>,
connectionResults: Record<string, ConnectionTestStatus>,
connectionErrors: Record<string, string>,
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<string | null>(null);
const [accountToProviderMap, setAccountToProviderMap] = useState<
Map<string, string>
>(new Map());
const isMountedRef = useRef(true);
const hasAppliedRef = useRef(false);
const lastAppliedSelectionKeyRef = useRef<string>("");
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,
};
}
@@ -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<void> {
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<string | null>(null);
const isMountedRef = useRef(true);
const discoveryAbortControllerRef = useRef<AbortController | null>(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<DiscoveryResult | null> => {
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,
};
}
@@ -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<string>,
selectableAccountIdSet: Set<string>,
selectedNodeIds: Set<string>,
): 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>,
): string[] {
const selectedAccountIdSet = new Set(selectedAccountIds);
const selectedNodeIds = new Set<string>();
for (const rootNode of treeData) {
collectFullySelectedNodeIds(
rootNode,
selectedAccountIdSet,
selectableAccountIdSet,
selectedNodeIds,
);
}
return [...selectedAccountIds, ...Array.from(selectedNodeIds)];
}
function buildTreeWithConnectionState(
nodes: TreeDataItem[],
selectedAccountIdsSet: Set<string>,
accountToProviderMap: Map<string, string>,
connectionResults: Record<string, ConnectionTestStatus>,
connectionErrors: Record<string, string>,
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<string | null>(null);
const [accountToProviderMap, setAccountToProviderMap] = useState<
Map<string, string>
>(new Map());
const hasAppliedRef = useRef(false);
const startTestingActionRef = useRef<() => void>(() => {});
const treeData = discoveryResult ? buildOrgTreeData(discoveryResult) : [];
const accountLookup: Map<string, DiscoveredAccount> = discoveryResult
? buildAccountLookup(discoveryResult)
: new Map<string, DiscoveredAccount>();
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 (
<div className="flex min-h-0 flex-1 flex-col gap-5">
{/* Header */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<AWSProviderBadge size={32} />
@@ -556,7 +103,6 @@ export function OrgAccountSelection({
</Alert>
)}
{/* Tree */}
<div className="border-border-neutral-secondary min-h-0 flex-1 overflow-y-auto rounded-md border p-2">
<TreeView
data={treeDataWithConnectionState}
@@ -7,19 +7,6 @@ import { FormEvent, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import {
createOrganization,
createOrganizationSecret,
getDiscovery,
listOrganizationsByExternalId,
listOrganizationSecretsByOrganizationId,
triggerDiscovery,
updateOrganizationSecret,
} from "@/actions/organizations/organizations";
import {
buildOrgTreeData,
getSelectableAccountIds,
} from "@/actions/organizations/organizations.adapter";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import {
WIZARD_FOOTER_ACTION_TYPE,
@@ -31,16 +18,9 @@ import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import { Input } from "@/components/shadcn/input/input";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
import {
DISCOVERY_STATUS,
DiscoveryResult,
ORG_SETUP_PHASE,
OrgSetupPhase,
} from "@/types/organizations";
import { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations";
const DISCOVERY_POLL_INTERVAL_MS = 3000;
const DISCOVERY_MAX_RETRIES = 60;
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
const orgSetupSchema = z.object({
organizationName: z.string().trim().optional(),
@@ -83,16 +63,9 @@ export function OrgSetupForm({
initialPhase = ORG_SETUP_PHASE.DETAILS,
}: OrgSetupFormProps) {
const { data: session } = useSession();
const [apiError, setApiError] = useState<string | null>(null);
const [isExternalIdCopied, setIsExternalIdCopied] = useState(false);
const stackSetExternalId = session?.tenantId ?? "";
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(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<DiscoveryResult | null> => {
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(() => {
@@ -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<WizardVariant>(
WIZARD_VARIANT.PROVIDER,
);
const [currentStep, setCurrentStep] = useState<ProviderWizardStep>(
PROVIDER_WIZARD_STEP.CONNECT,
);
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
);
const [orgSetupPhase, setOrgSetupPhase] = useState<OrgSetupPhase>(
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,
};
}
@@ -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<WizardVariant>(
WIZARD_VARIANT.PROVIDER,
);
const [currentStep, setCurrentStep] = useState<ProviderWizardStep>(
PROVIDER_WIZARD_STEP.CONNECT,
);
const [orgCurrentStep, setOrgCurrentStep] = useState<OrgWizardStep>(
ORG_WIZARD_STEP.SETUP,
);
const [footerConfig, setFooterConfig] =
useState<WizardFooterConfig>(EMPTY_FOOTER_CONFIG);
const [providerTypeHint, setProviderTypeHint] = useState<ProviderType | null>(
null,
);
const [orgSetupPhase, setOrgSetupPhase] = useState<OrgSetupPhase>(
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 (
<Modal
open={open}
@@ -242,7 +72,7 @@ export function ProviderWizardModal({
>
<DialogHeader className="gap-2 p-0">
<DialogTitle className="text-lg font-semibold">
Adding A Cloud Provider
{modalTitle}
</DialogTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<Info className="size-4 shrink-0" />
+12
View File
@@ -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;
}