From 8c7f9550755090d8aa780fc8fbcd2ebadf1e7fb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:53:48 -0400 Subject: [PATCH 1/2] fix(api): allow uploading excel documents for task (#1677) Co-authored-by: chasprowebdev --- apps/api/src/attachments/upload-attachment.dto.ts | 2 ++ apps/api/src/tasks/dto/upload-attachment.dto.ts | 2 ++ packages/docs/openapi.json | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/src/attachments/upload-attachment.dto.ts b/apps/api/src/attachments/upload-attachment.dto.ts index 33f2aceb0..8173ac9bf 100644 --- a/apps/api/src/attachments/upload-attachment.dto.ts +++ b/apps/api/src/attachments/upload-attachment.dto.ts @@ -18,6 +18,8 @@ const ALLOWED_FILE_TYPES = [ 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]; export class UploadAttachmentDto { diff --git a/apps/api/src/tasks/dto/upload-attachment.dto.ts b/apps/api/src/tasks/dto/upload-attachment.dto.ts index 33f2aceb0..8173ac9bf 100644 --- a/apps/api/src/tasks/dto/upload-attachment.dto.ts +++ b/apps/api/src/tasks/dto/upload-attachment.dto.ts @@ -18,6 +18,8 @@ const ALLOWED_FILE_TYPES = [ 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]; export class UploadAttachmentDto { diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 8de22c146..45206dbef 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -9281,7 +9281,9 @@ "application/pdf", "text/plain", "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ] }, "fileData": { From 49f406c0c446e0b1f74555b473a8f93037dc4fe0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:43:04 -0400 Subject: [PATCH 2/2] [dev] [Marfuen] mariano/improve-cloud-tests (#1670) * chore(cloud-tests): re-organize layout * chore(cloud-tests): add cloud connection and management features for AWS, GCP, and Azure * refactor(cloud-tests): simplify layout structure in TestsLayout component * chore(cloud-tests): remove outdated README for cloud tests feature --------- Co-authored-by: Mariano Fuentes --- apps/app/package.json | 1 + .../cloud-tests/actions/connect-cloud.ts | 121 ++++ .../cloud-tests/actions/disconnect-cloud.ts | 67 +++ .../actions/run-tests.ts | 8 +- .../actions/update-cloud-credentials.ts | 80 +++ .../actions/validate-aws-credentials.ts | 98 ++++ .../components/ChatPlaceholder.tsx | 30 + .../components/CloudConnectionCard.tsx | 173 ++++++ .../components/CloudSettingsModal.tsx | 279 +++++++++ .../cloud-tests/components/EmptyState.tsx | 547 ++++++++++++++++++ .../cloud-tests/components/FindingsTable.tsx | 155 +++++ .../cloud-tests/components/ResultsView.tsx | 196 +++++++ .../cloud-tests/components/TestsLayout.tsx | 323 +++++++++++ .../dashboard => cloud-tests}/layout.tsx | 0 .../{integrations => cloud-tests}/loading.tsx | 0 .../app/(app)/[orgId]/cloud-tests/page.tsx | 63 ++ .../app/(app)/[orgId]/integrations/layout.tsx | 3 - .../app/(app)/[orgId]/integrations/page.tsx | 43 -- .../tests/dashboard/components/TestCard.tsx | 127 ---- .../dashboard/components/TestsLayout.tsx | 297 ---------- .../(app)/[orgId]/tests/dashboard/loading.tsx | 9 - .../(app)/[orgId]/tests/dashboard/page.tsx | 91 --- .../src/app/(app)/[orgId]/tests/layout.tsx | 7 - .../src/app/(app)/[orgId]/tests/loading.tsx | 9 - apps/app/src/app/(app)/[orgId]/tests/page.tsx | 83 --- .../src/app/api/cloud-tests/findings/route.ts | 52 ++ .../app/api/cloud-tests/providers/route.ts | 33 ++ .../integrations/integration-settings.tsx | 153 ----- .../integrations/integrations-card.tsx | 543 ----------------- .../integrations/integrations-header.tsx | 11 - .../integrations/integrations-tabs.tsx | 44 -- .../integrations/integrations.server.tsx | 68 --- .../components/integrations/integrations.tsx | 135 ----- apps/app/src/components/main-menu.tsx | 15 +- .../tasks/integration/integration-results.ts | 6 + bun.lock | 203 +++++++ 36 files changed, 2432 insertions(+), 1641 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts rename apps/app/src/app/(app)/[orgId]/{tests/dashboard => cloud-tests}/actions/run-tests.ts (86%) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/FindingsTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx rename apps/app/src/app/(app)/[orgId]/{tests/dashboard => cloud-tests}/layout.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{integrations => cloud-tests}/loading.tsx (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/page.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/integrations/layout.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/integrations/page.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/dashboard/components/TestCard.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/dashboard/components/TestsLayout.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/dashboard/loading.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/dashboard/page.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/layout.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/loading.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/tests/page.tsx create mode 100644 apps/app/src/app/api/cloud-tests/findings/route.ts create mode 100644 apps/app/src/app/api/cloud-tests/providers/route.ts delete mode 100644 apps/app/src/components/integrations/integration-settings.tsx delete mode 100644 apps/app/src/components/integrations/integrations-card.tsx delete mode 100644 apps/app/src/components/integrations/integrations-header.tsx delete mode 100644 apps/app/src/components/integrations/integrations-tabs.tsx delete mode 100644 apps/app/src/components/integrations/integrations.server.tsx delete mode 100644 apps/app/src/components/integrations/integrations.tsx diff --git a/apps/app/package.json b/apps/app/package.json index a1c39c34d..a79da837f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -8,6 +8,7 @@ "@ai-sdk/provider": "^2.0.0", "@ai-sdk/react": "^2.0.60", "@ai-sdk/rsc": "^1.0.0", + "@aws-sdk/client-ec2": "^3.911.0", "@aws-sdk/client-lambda": "^3.891.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/client-sts": "^3.808.0", diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts new file mode 100644 index 000000000..878377adc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts @@ -0,0 +1,121 @@ +'use server'; + +import { encrypt } from '@/lib/encryption'; +import { getIntegrationHandler } from '@comp/integrations'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; +import { runTests } from './run-tests'; + +const connectCloudSchema = z.object({ + cloudProvider: z.enum(['aws', 'gcp', 'azure']), + credentials: z.record(z.string(), z.string()), +}); + +export const connectCloudAction = authActionClient + .inputSchema(connectCloudSchema) + .metadata({ + name: 'connect-cloud', + track: { + event: 'connect-cloud', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { cloudProvider, credentials }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + return { + success: false, + error: 'No active organization found', + }; + } + + // Validate credentials before storing + try { + const integrationHandler = getIntegrationHandler(cloudProvider); + if (!integrationHandler) { + return { + success: false, + error: 'Integration handler not found', + }; + } + + // Process credentials to the format expected by the handler + const typedCredentials = await integrationHandler.processCredentials( + credentials, + async (data: any) => data, // Pass through without encryption for validation + ); + + // Validate by attempting to fetch (this will throw if credentials are invalid) + await integrationHandler.fetch(typedCredentials); + } catch (error) { + console.error('Credential validation failed:', error); + return { + success: false, + error: + error instanceof Error + ? `Invalid credentials: ${error.message}` + : 'Failed to validate credentials. Please check your credentials and try again.', + }; + } + + // Encrypt all credential fields after validation + const encryptedCredentials: Record = {}; + for (const [key, value] of Object.entries(credentials)) { + if (value) { + encryptedCredentials[key] = await encrypt(value); + } + } + + // Check if integration already exists + const existingIntegration = await db.integration.findFirst({ + where: { + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + }, + }); + + if (existingIntegration) { + // Update existing integration + await db.integration.update({ + where: { id: existingIntegration.id }, + data: { + userSettings: encryptedCredentials as any, + lastRunAt: null, // Reset to trigger new scan + }, + }); + } else { + // Create new integration + await db.integration.create({ + data: { + name: cloudProvider.toUpperCase(), + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + userSettings: encryptedCredentials as any, + settings: {}, + }, + }); + } + + // Trigger immediate scan + await runTests(); + + // Revalidate the path + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error('Failed to connect cloud provider:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to connect cloud provider', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts new file mode 100644 index 000000000..5e5aa7c75 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/disconnect-cloud.ts @@ -0,0 +1,67 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; + +const disconnectCloudSchema = z.object({ + cloudProvider: z.enum(['aws', 'gcp', 'azure']), +}); + +export const disconnectCloudAction = authActionClient + .inputSchema(disconnectCloudSchema) + .metadata({ + name: 'disconnect-cloud', + track: { + event: 'disconnect-cloud', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { cloudProvider }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + return { + success: false, + error: 'No active organization found', + }; + } + + // Find and delete the integration + const integration = await db.integration.findFirst({ + where: { + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + }, + }); + + if (!integration) { + return { + success: false, + error: 'Cloud provider not found', + }; + } + + // Delete the integration (cascade will delete results) + await db.integration.delete({ + where: { id: integration.id }, + }); + + // Revalidate the path + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error('Failed to disconnect cloud provider:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to disconnect cloud provider', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/run-tests.ts similarity index 86% rename from apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts rename to apps/app/src/app/(app)/[orgId]/cloud-tests/actions/run-tests.ts index 378d56534..6d73e7095 100644 --- a/apps/app/src/app/(app)/[orgId]/tests/dashboard/actions/run-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/run-tests.ts @@ -32,9 +32,8 @@ export const runTests = async () => { }); const headersList = await headers(); - let path = - headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); revalidatePath(path); @@ -42,10 +41,11 @@ export const runTests = async () => { success: true, errors: null, taskId: handle.id, + publicAccessToken: handle.publicAccessToken, }; } catch (error) { console.error('Error triggering integration tests:', error); - + return { success: false, errors: [error instanceof Error ? error.message : 'Failed to trigger integration tests'], diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts new file mode 100644 index 000000000..7550d7099 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/update-cloud-credentials.ts @@ -0,0 +1,80 @@ +'use server'; + +import { encrypt } from '@/lib/encryption'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; + +const updateCloudCredentialsSchema = z.object({ + cloudProvider: z.enum(['aws', 'gcp', 'azure']), + credentials: z.record(z.string(), z.string()), +}); + +export const updateCloudCredentialsAction = authActionClient + .inputSchema(updateCloudCredentialsSchema) + .metadata({ + name: 'update-cloud-credentials', + track: { + event: 'update-cloud-credentials', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { cloudProvider, credentials }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + return { + success: false, + error: 'No active organization found', + }; + } + + // Find the integration + const integration = await db.integration.findFirst({ + where: { + integrationId: cloudProvider, + organizationId: session.activeOrganizationId, + }, + }); + + if (!integration) { + return { + success: false, + error: 'Cloud provider not found', + }; + } + + // Encrypt all credential fields + const encryptedCredentials: Record = {}; + for (const [key, value] of Object.entries(credentials)) { + if (value) { + encryptedCredentials[key] = await encrypt(value); + } + } + + // Update the integration + await db.integration.update({ + where: { id: integration.id }, + data: { + userSettings: encryptedCredentials as any, + }, + }); + + // Revalidate the path + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error('Failed to update cloud credentials:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update cloud credentials', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts new file mode 100644 index 000000000..6eb90739f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/validate-aws-credentials.ts @@ -0,0 +1,98 @@ +'use server'; + +import { DescribeRegionsCommand, EC2Client } from '@aws-sdk/client-ec2'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { z } from 'zod'; +import { authActionClient } from '../../../../../actions/safe-action'; + +const validateAwsCredentialsSchema = z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), +}); + +export const validateAwsCredentialsAction = authActionClient + .inputSchema(validateAwsCredentialsSchema) + .metadata({ + name: 'validate-aws-credentials', + track: { + event: 'validate-aws-credentials', + channel: 'cloud-tests', + }, + }) + .action(async ({ parsedInput: { accessKeyId, secretAccessKey } }) => { + try { + // First, validate credentials using STS + const stsClient = new STSClient({ + region: 'us-east-1', // Default region for validation + credentials: { + accessKeyId, + secretAccessKey, + }, + }); + + const identity = await stsClient.send(new GetCallerIdentityCommand({})); + + // Get available regions + const ec2Client = new EC2Client({ + region: 'us-east-1', + credentials: { + accessKeyId, + secretAccessKey, + }, + }); + + const regionsResponse = await ec2Client.send(new DescribeRegionsCommand({})); + + // Map of common region codes to friendly names + const regionNames: Record = { + 'us-east-1': 'US East (N. Virginia)', + 'us-east-2': 'US East (Ohio)', + 'us-west-1': 'US West (N. California)', + 'us-west-2': 'US West (Oregon)', + 'eu-west-1': 'Europe (Ireland)', + 'eu-west-2': 'Europe (London)', + 'eu-west-3': 'Europe (Paris)', + 'eu-central-1': 'Europe (Frankfurt)', + 'eu-north-1': 'Europe (Stockholm)', + 'eu-south-1': 'Europe (Milan)', + 'ap-southeast-1': 'Asia Pacific (Singapore)', + 'ap-southeast-2': 'Asia Pacific (Sydney)', + 'ap-northeast-1': 'Asia Pacific (Tokyo)', + 'ap-northeast-2': 'Asia Pacific (Seoul)', + 'ap-northeast-3': 'Asia Pacific (Osaka)', + 'ap-south-1': 'Asia Pacific (Mumbai)', + 'ap-east-1': 'Asia Pacific (Hong Kong)', + 'ca-central-1': 'Canada (Central)', + 'sa-east-1': 'South America (São Paulo)', + 'me-south-1': 'Middle East (Bahrain)', + 'af-south-1': 'Africa (Cape Town)', + }; + + const regions = (regionsResponse.Regions || []) + .filter((region) => region.RegionName) + .map((region) => { + const code = region.RegionName!; + const friendlyName = regionNames[code] || code; + return { + value: code, + label: `${friendlyName} (${code})`, + }; + }) + .sort((a, b) => a.value.localeCompare(b.value)); + + return { + success: true, + accountId: identity.Account, + regions, + }; + } catch (error) { + console.error('AWS credential validation failed:', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to validate AWS credentials. Please check your access key and secret.', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx new file mode 100644 index 000000000..33fb1691f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ChatPlaceholder.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { MessageSquare } from 'lucide-react'; + +export function ChatPlaceholder() { + return ( + + + + + AI Remediation Assistant + + Coming soon + + +
+
+ +

+ Chat with AI to automatically +
+ remediate security findings +

+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx new file mode 100644 index 000000000..fb44d3d06 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { Input } from '@comp/ui/input'; +import { Label } from '@comp/ui/label'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { connectCloudAction } from '../actions/connect-cloud'; + +interface CloudField { + id: string; + label: string; + description: string; + placeholder?: string; + helpText?: string; + type?: string; +} + +interface CloudConnectionCardProps { + cloudProvider: 'aws' | 'gcp' | 'azure'; + name: string; + shortName: string; + description: string; + fields: CloudField[]; + guideUrl?: string; + color?: string; + logoUrl?: string; + onSuccess?: () => void; +} + +export function CloudConnectionCard({ + cloudProvider, + name, + shortName, + description, + fields, + guideUrl, + color = 'from-primary to-primary', + logoUrl, + onSuccess, +}: CloudConnectionCardProps) { + const [isConnecting, setIsConnecting] = useState(false); + const [credentials, setCredentials] = useState>({}); + const [errors, setErrors] = useState>({}); + + const handleFieldChange = (fieldId: string, value: string) => { + setCredentials((prev) => ({ ...prev, [fieldId]: value })); + if (errors[fieldId]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[fieldId]; + return newErrors; + }); + } + }; + + const validateFields = (): boolean => { + const newErrors: Record = {}; + fields.forEach((field) => { + if (!credentials[field.id]?.trim()) { + newErrors[field.id] = 'Required'; + } + }); + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleConnect = async () => { + if (!validateFields()) { + toast.error('Please fill in all required fields'); + return; + } + + try { + setIsConnecting(true); + const result = await connectCloudAction({ + cloudProvider, + credentials, + }); + + if (result?.data?.success) { + toast.success(`${name} connected! Running initial scan...`); + setCredentials({}); + onSuccess?.(); + } else { + toast.error(result?.data?.error || 'Failed to connect'); + } + } catch (error) { + console.error('Connection error:', error); + toast.error('An unexpected error occurred'); + } finally { + setIsConnecting(false); + } + }; + + return ( + + +
+
+ {logoUrl && ( + {`${shortName} + )} +
+
+ {shortName} + {description} +
+
+ {guideUrl && ( + + + Setup guide + + )} +
+ + {fields.map((field) => ( +
+ + {field.type === 'textarea' ? ( +