mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 04:21:52 +00:00
refactor(ui): extract wizard and org hooks from components
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user