feat(ui): add discovery, account selection, and connection test steps

Add OrgDiscoveryLoader with polling for async discovery status,
OrgAccountSelection with TreeView for multi-account selection and
aliasing, OrgAccountTreeItem with custom rendering for selection and
testing modes, OrgConnectionTest that applies discovery and validates
connections concurrently, and OrgLaunchScan for scan scheduling.
This commit is contained in:
alejandrobailo
2026-02-17 09:46:30 +01:00
parent 689d044d92
commit 83bceab584
5 changed files with 740 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
buildAccountLookup,
buildOrgTreeData,
} from "@/actions/organizations/organizations.adapter";
import { Badge, Button } from "@/components/shadcn";
import { TreeView } from "@/components/shadcn/tree-view";
import { useOrgSetupStore } from "@/store/organizations/store";
import { OrgAccountTreeItem, TREE_ITEM_MODE } from "./org-account-tree-item";
interface OrgAccountSelectionProps {
onBack: () => void;
onNext: () => void;
}
export function OrgAccountSelection({
onBack,
onNext,
}: OrgAccountSelectionProps) {
const {
organizationName,
organizationExternalId,
discoveryResult,
selectedAccountIds,
accountAliases,
setSelectedAccountIds,
setAccountAlias,
} = useOrgSetupStore();
if (!discoveryResult) {
return (
<div className="text-muted-foreground py-8 text-center text-sm">
No discovery data available.
</div>
);
}
const treeData = buildOrgTreeData(discoveryResult);
const accountLookup = buildAccountLookup(discoveryResult);
const totalAccounts = discoveryResult.accounts.length;
const selectedCount = selectedAccountIds.length;
return (
<div className="flex flex-col gap-5">
{/* Header */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge variant="outline">AWS</Badge>
<span className="text-sm font-medium">{organizationName}</span>
{organizationExternalId && (
<Badge variant="secondary">{organizationExternalId}</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
Select the accounts you want to connect. {selectedCount} of{" "}
{totalAccounts} accounts selected.
</p>
</div>
{/* Tree */}
<div className="max-h-80 overflow-y-auto rounded-md border p-2">
<TreeView
data={treeData}
showCheckboxes
enableSelectChildren
expandAll
selectedIds={selectedAccountIds}
onSelectionChange={setSelectedAccountIds}
renderItem={(params) => (
<OrgAccountTreeItem
params={params}
mode={TREE_ITEM_MODE.SELECTION}
accountLookup={accountLookup}
aliases={accountAliases}
onAliasChange={setAccountAlias}
/>
)}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={onBack}>
<ChevronLeft className="mr-1 size-4" />
Back
</Button>
<Button type="button" onClick={onNext} disabled={selectedCount === 0}>
Next
<ChevronRight className="ml-1 size-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { AlertCircle, Check, Loader2 } from "lucide-react";
import { Input } from "@/components/shadcn/input/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import {
APPLY_STATUS,
CONNECTION_TEST_STATUS,
ConnectionTestStatus,
DiscoveredAccount,
} from "@/types/organizations";
import { TreeRenderItemParams } from "@/types/tree";
const TREE_ITEM_MODE = {
SELECTION: "selection",
TESTING: "testing",
} as const;
type TreeItemMode = (typeof TREE_ITEM_MODE)[keyof typeof TREE_ITEM_MODE];
interface OrgAccountTreeItemProps {
params: TreeRenderItemParams;
mode: TreeItemMode;
accountLookup: Map<string, DiscoveredAccount>;
aliases: Record<string, string>;
onAliasChange?: (accountId: string, alias: string) => void;
connectionResults?: Record<string, ConnectionTestStatus>;
accountToProviderMap?: Map<string, string>;
}
export function OrgAccountTreeItem({
params,
mode,
accountLookup,
aliases,
onAliasChange,
connectionResults,
accountToProviderMap,
}: OrgAccountTreeItemProps) {
const { item, isLeaf } = params;
const account = accountLookup.get(item.id);
// Non-leaf nodes (roots/OUs) just render their name
if (!isLeaf || !account) {
return <span className="text-sm font-medium">{item.name}</span>;
}
const isBlocked = account.registration?.apply_status === APPLY_STATUS.BLOCKED;
const blockedReasons = account.registration?.blocked_reasons ?? [];
return (
<div className="flex flex-1 items-center gap-3">
{/* Status icon for testing mode */}
{mode === TREE_ITEM_MODE.TESTING && (
<TestStatusIcon
accountId={account.id}
connectionResults={connectionResults}
accountToProviderMap={accountToProviderMap}
/>
)}
{/* Account ID */}
<span
className={cn("shrink-0 text-sm", isBlocked && "text-muted-foreground")}
>
{account.id}
</span>
{/* Name / alias input */}
{mode === TREE_ITEM_MODE.SELECTION && !isBlocked && onAliasChange ? (
<Input
className="h-7 max-w-48 text-xs"
placeholder="Name (optional)"
value={aliases[account.id] ?? account.name}
onChange={(e) => onAliasChange(account.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-muted-foreground text-xs">
{aliases[account.id] || account.name}
</span>
)}
{/* Blocked reason tooltip */}
{isBlocked && blockedReasons.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<AlertCircle className="text-destructive size-4 shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{blockedReasons.join(", ")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
);
}
interface TestStatusIconProps {
accountId: string;
connectionResults?: Record<string, ConnectionTestStatus>;
accountToProviderMap?: Map<string, string>;
}
function TestStatusIcon({
accountId,
connectionResults,
accountToProviderMap,
}: TestStatusIconProps) {
const providerId = accountToProviderMap?.get(accountId);
if (!providerId || !connectionResults) return null;
const status = connectionResults[providerId];
if (status === CONNECTION_TEST_STATUS.SUCCESS) {
return <Check className="size-4 shrink-0 text-green-500" />;
}
if (status === CONNECTION_TEST_STATUS.ERROR) {
return <AlertCircle className="text-destructive size-4 shrink-0" />;
}
// pending or undefined = loading
return (
<Loader2 className="text-muted-foreground size-4 shrink-0 animate-spin" />
);
}
export { TREE_ITEM_MODE, type TreeItemMode };

View File

@@ -0,0 +1,279 @@
"use client";
import { AlertTriangle, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { applyDiscovery } from "@/actions/organizations/organizations";
import {
buildAccountLookup,
buildOrgTreeData,
getOuIdsForSelectedAccounts,
} from "@/actions/organizations/organizations.adapter";
import { checkConnectionProvider } from "@/actions/providers/providers";
import { Badge, Button } from "@/components/shadcn";
import { TreeView } from "@/components/shadcn/tree-view";
import { checkTaskStatus } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
import { CONNECTION_TEST_STATUS } from "@/types/organizations";
import { OrgAccountTreeItem, TREE_ITEM_MODE } from "./org-account-tree-item";
interface OrgConnectionTestProps {
onBack: () => void;
onNext: () => void;
onSkip: () => void;
}
export function OrgConnectionTest({
onBack,
onNext,
onSkip,
}: OrgConnectionTestProps) {
const {
organizationId,
organizationName,
organizationExternalId,
discoveryId,
discoveryResult,
selectedAccountIds,
accountAliases,
createdProviderIds,
connectionResults,
setCreatedProviderIds,
setConnectionResult,
} = useOrgSetupStore();
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [accountToProviderMap, setAccountToProviderMap] = useState<
Map<string, string>
>(new Map());
const hasApplied = useRef(false);
// On mount: apply discovery, then test connections
useEffect(() => {
if (hasApplied.current) return;
hasApplied.current = true;
handleApplyAndTest();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleApplyAndTest = async () => {
if (!organizationId || !discoveryId || !discoveryResult) return;
setIsApplying(true);
setApplyError(null);
// Build apply payload
const accounts = selectedAccountIds.map((id) => ({
id,
...(accountAliases[id] ? { alias: accountAliases[id] } : {}),
}));
const ouIds = getOuIdsForSelectedAccounts(
discoveryResult,
selectedAccountIds,
);
const organizationalUnits = ouIds.map((id) => ({ id }));
const result = await applyDiscovery(
organizationId,
discoveryId,
accounts,
organizationalUnits,
);
if (result?.error) {
setApplyError(result.error);
setIsApplying(false);
return;
}
// Extract created provider IDs from relationships
const providerIds: string[] =
result.data?.relationships?.providers?.data?.map(
(p: { id: string }) => p.id,
) ?? [];
setCreatedProviderIds(providerIds);
// Build account -> provider mapping
// The providers are returned in the same order as the accounts submitted
const mapping = new Map<string, string>();
selectedAccountIds.forEach((accountId, index) => {
if (providerIds[index]) {
mapping.set(accountId, providerIds[index]);
}
});
setAccountToProviderMap(mapping);
setIsApplying(false);
// Now test all connections
await testAllConnections(providerIds);
};
const testAllConnections = async (providerIds: string[]) => {
setIsTesting(true);
// Initialize all as pending
for (const id of providerIds) {
setConnectionResult(id, CONNECTION_TEST_STATUS.PENDING);
}
// Test all concurrently
const testPromises = providerIds.map(async (providerId) => {
try {
const formData = new FormData();
formData.set("providerId", providerId);
const checkResult = await checkConnectionProvider(formData);
if (checkResult?.error) {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
return;
}
// Poll for task completion
const taskId = checkResult?.data?.id;
if (taskId) {
const taskResult = await checkTaskStatus(taskId);
setConnectionResult(
providerId,
taskResult.completed
? CONNECTION_TEST_STATUS.SUCCESS
: CONNECTION_TEST_STATUS.ERROR,
);
} else {
// No task returned — consider it success (connection already verified)
setConnectionResult(providerId, CONNECTION_TEST_STATUS.SUCCESS);
}
} catch {
setConnectionResult(providerId, CONNECTION_TEST_STATUS.ERROR);
}
});
await Promise.allSettled(testPromises);
setIsTesting(false);
// Check if all passed — auto-advance
const allResults = useOrgSetupStore.getState().connectionResults;
const allPassed = providerIds.every(
(id) => allResults[id] === CONNECTION_TEST_STATUS.SUCCESS,
);
if (allPassed && providerIds.length > 0) {
onNext();
}
};
const handleRetryFailed = () => {
const failedProviderIds = createdProviderIds.filter(
(id) => connectionResults[id] === CONNECTION_TEST_STATUS.ERROR,
);
testAllConnections(failedProviderIds);
};
if (!discoveryResult) return null;
const treeData = buildOrgTreeData(discoveryResult);
const accountLookup = buildAccountLookup(discoveryResult);
const hasErrors = Object.values(connectionResults).some(
(s) => s === CONNECTION_TEST_STATUS.ERROR,
);
return (
<div className="flex flex-col gap-5">
{/* Header */}
<div className="flex items-center gap-2">
<Badge variant="outline">AWS</Badge>
<span className="text-sm font-medium">{organizationName}</span>
{organizationExternalId && (
<Badge variant="secondary">{organizationExternalId}</Badge>
)}
</div>
{/* Apply error */}
{applyError && (
<div className="border-destructive/50 bg-destructive/10 text-destructive rounded-md border px-4 py-3 text-sm">
{applyError}
</div>
)}
{/* Applying state */}
{isApplying && (
<div className="flex items-center gap-3 py-8">
<Loader2 className="text-primary size-5 animate-spin" />
<span className="text-sm">
Applying discovery results and creating providers...
</span>
</div>
)}
{/* Connection test error banner */}
{hasErrors && !isTesting && (
<div className="border-destructive/50 bg-destructive/10 text-destructive flex items-start gap-3 rounded-md border px-4 py-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>
There was a problem connecting to some accounts. Ensure the Prowler
StackSet has successfully deployed then retry testing connections.
</span>
</div>
)}
{/* Tree with test results */}
{!isApplying && !applyError && (
<div className="max-h-80 overflow-y-auto rounded-md border p-2">
<TreeView
data={treeData}
expandAll
selectedIds={selectedAccountIds}
renderItem={(params) => (
<OrgAccountTreeItem
params={params}
mode={TREE_ITEM_MODE.TESTING}
accountLookup={accountLookup}
aliases={accountAliases}
connectionResults={connectionResults}
accountToProviderMap={accountToProviderMap}
/>
)}
/>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between">
<Button type="button" variant="ghost" onClick={onBack}>
Back
</Button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onSkip}
className="text-muted-foreground hover:text-foreground text-sm underline"
>
Skip Connection Validation
</button>
{hasErrors && !isTesting && (
<Button type="button" onClick={handleRetryFailed}>
<RefreshCw className="mr-2 size-4" />
Test Connections
</Button>
)}
{isTesting && (
<Button disabled>
<Loader2 className="mr-2 size-4 animate-spin" />
Testing...
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { Loader2, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getDiscovery } from "@/actions/organizations/organizations";
import {
buildOrgTreeData,
getSelectableAccountIds,
} from "@/actions/organizations/organizations.adapter";
import { Button } from "@/components/shadcn";
import { useOrgSetupStore } from "@/store/organizations/store";
import { DISCOVERY_STATUS, DiscoveryResult } from "@/types/organizations";
const POLL_INTERVAL_MS = 3000;
const MAX_RETRIES = 60;
interface OrgDiscoveryLoaderProps {
onDiscoveryComplete: () => void;
}
export function OrgDiscoveryLoader({
onDiscoveryComplete,
}: OrgDiscoveryLoaderProps) {
const { organizationId, discoveryId, setDiscovery, setSelectedAccountIds } =
useOrgSetupStore();
const [status, setStatus] = useState<string>(DISCOVERY_STATUS.PENDING);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const retryCountRef = useRef(0);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollDiscovery = async () => {
if (!organizationId || !discoveryId) return;
const result = await getDiscovery(organizationId, discoveryId);
if (result?.error) {
setStatus(DISCOVERY_STATUS.FAILED);
setErrorMessage(result.error);
return;
}
const discoveryStatus = result.data.attributes.status;
setStatus(discoveryStatus);
if (discoveryStatus === DISCOVERY_STATUS.SUCCEEDED) {
const discoveryResult = result.data.attributes.result as DiscoveryResult;
// Store discovery result
setDiscovery(discoveryId, discoveryResult);
// Pre-select all selectable accounts
const selectableIds = getSelectableAccountIds(discoveryResult);
setSelectedAccountIds(selectableIds);
// Pre-build tree data to ensure it's valid
buildOrgTreeData(discoveryResult);
onDiscoveryComplete();
return;
}
if (discoveryStatus === DISCOVERY_STATUS.FAILED) {
setErrorMessage(
result.data.attributes.error ?? "Discovery failed. Please try again.",
);
return;
}
// Still pending or running — schedule next poll
if (retryCountRef.current >= MAX_RETRIES) {
setStatus(DISCOVERY_STATUS.FAILED);
setErrorMessage("Discovery timed out. Please try again.");
return;
}
retryCountRef.current += 1;
pollRef.current = setTimeout(pollDiscovery, POLL_INTERVAL_MS);
};
useEffect(() => {
pollDiscovery();
return () => {
if (pollRef.current) clearTimeout(pollRef.current);
};
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleRetry = () => {
setStatus(DISCOVERY_STATUS.PENDING);
setErrorMessage(null);
retryCountRef.current = 0;
pollDiscovery();
};
if (status === DISCOVERY_STATUS.FAILED || errorMessage) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<div className="border-destructive/50 bg-destructive/10 text-destructive rounded-md border px-6 py-4 text-center text-sm">
{errorMessage ?? "An unknown error occurred during discovery."}
</div>
<Button variant="outline" onClick={handleRetry}>
<RefreshCw className="mr-2 size-4" />
Retry Discovery
</Button>
</div>
);
}
return (
<div className="flex flex-col items-center gap-4 py-12">
<Loader2 className="text-primary size-8 animate-spin" />
<div className="text-center">
<p className="text-sm font-medium">
Discovering your AWS Organization...
</p>
<p className="text-muted-foreground text-xs">
This may take a few moments
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { CheckCircle2, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { scheduleDaily } from "@/actions/scans/scans";
import { Badge, Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { useOrgSetupStore } from "@/store/organizations/store";
interface OrgLaunchScanProps {
onClose: () => void;
}
export function OrgLaunchScan({ onClose }: OrgLaunchScanProps) {
const router = useRouter();
const { toast } = useToast();
const {
organizationName,
organizationExternalId,
createdProviderIds,
reset,
} = useOrgSetupStore();
const [isLaunching, setIsLaunching] = useState(false);
const handleDone = () => {
reset();
onClose();
router.push("/providers");
};
const handleLaunchScan = async () => {
setIsLaunching(true);
let successCount = 0;
for (const providerId of createdProviderIds) {
const formData = new FormData();
formData.set("providerId", providerId);
const result = await scheduleDaily(formData);
if (!result?.error) {
successCount++;
}
}
setIsLaunching(false);
reset();
onClose();
router.push("/providers");
toast({
title: "Scan Launched",
description: `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.`,
});
};
return (
<div className="flex flex-col items-center gap-6 py-6">
{/* Org info */}
<div className="flex items-center gap-2">
<Badge variant="outline">AWS</Badge>
<span className="text-sm font-medium">{organizationName}</span>
{organizationExternalId && (
<Badge variant="secondary">{organizationExternalId}</Badge>
)}
</div>
{/* Success message */}
<div className="flex flex-col items-center gap-2">
<CheckCircle2 className="size-12 text-green-500" />
<h3 className="text-lg font-semibold">Accounts Connected!</h3>
<p className="text-muted-foreground text-center text-sm">
Your accounts are connected to Prowler and ready to scan!
</p>
</div>
{/* Scan schedule info */}
<div className="flex flex-col items-center gap-2">
<p className="text-muted-foreground text-sm">
Select a Prowler scan schedule for these accounts.
</p>
<div className="bg-muted/30 rounded-md border px-4 py-2 text-sm">
Scan Daily (every 24 hours)
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={handleDone}>
Done
</Button>
<Button type="button" onClick={handleLaunchScan} disabled={isLaunching}>
{isLaunching && <Loader2 className="mr-2 size-4 animate-spin" />}
{isLaunching ? "Launching..." : "Launch Scan"}
</Button>
</div>
</div>
);
}