mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-01 05:37:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
135
ui/components/providers/organizations/org-account-tree-item.tsx
Normal file
135
ui/components/providers/organizations/org-account-tree-item.tsx
Normal 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 };
|
||||
279
ui/components/providers/organizations/org-connection-test.tsx
Normal file
279
ui/components/providers/organizations/org-connection-test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
ui/components/providers/organizations/org-discovery-loader.tsx
Normal file
125
ui/components/providers/organizations/org-discovery-loader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
ui/components/providers/organizations/org-launch-scan.tsx
Normal file
102
ui/components/providers/organizations/org-launch-scan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user