feat(ui): Security Hub (#8552)

This commit is contained in:
Alejandro Bailo
2025-08-26 14:30:45 +02:00
committed by GitHub
parent 3b42eb3818
commit dab0cea2dd
27 changed files with 2169 additions and 542 deletions

3
.gitignore vendored
View File

@@ -75,3 +75,6 @@ node_modules
# Persistent data
_data/
# Claude
CLAUDE.md

2
ui/.gitignore vendored
View File

@@ -33,4 +33,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Security Hub integration [(#8552)](https://github.com/prowler-cloud/prowler/pull/8552)
- `Cloud Provider` type filter to providers page [(#8473)](https://github.com/prowler-cloud/prowler/pull/8473)
- New menu item under Configuration section for quick access to the Mutelist [(#8444)](https://github.com/prowler-cloud/prowler/pull/8444)
- Resource agent to Lighthouse for querying resource information [(#8509)](https://github.com/prowler-cloud/prowler/pull/8509)

View File

@@ -3,6 +3,7 @@ export {
deleteIntegration,
getIntegration,
getIntegrations,
pollConnectionTestStatus,
testIntegrationConnection,
updateIntegration,
} from "./integrations";

View File

@@ -9,7 +9,8 @@ import {
parseStringify,
} from "@/lib";
import { getTask } from "../task";
import { getTask } from "@/actions/task";
import { IntegrationType } from "@/types/integrations";
export const getIntegrations = async (searchParams?: URLSearchParams) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -59,7 +60,7 @@ export const getIntegration = async (id: string) => {
export const createIntegration = async (
formData: FormData,
): Promise<{ success: string; testConnection?: any } | { error: string }> => {
): Promise<{ success: string; integrationId?: string } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations`);
@@ -97,25 +98,33 @@ export const createIntegration = async (
const responseData = await response.json();
const integrationId = responseData.data.id;
const testResult = await testIntegrationConnection(integrationId);
// Revalidate the appropriate page based on integration type
if (integration_type === "amazon_s3") {
revalidatePath("/integrations/amazon-s3");
} else if (integration_type === "aws_security_hub") {
revalidatePath("/integrations/aws-security-hub");
}
return {
success: "Integration created successfully!",
testConnection: testResult,
integrationId,
};
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to create S3 integration: ${response.statusText}`;
`Unable to create integration: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};
export const updateIntegration = async (id: string, formData: FormData) => {
export const updateIntegration = async (
id: string,
formData: FormData,
): Promise<{ success: string; integrationId?: string } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
@@ -172,33 +181,33 @@ export const updateIntegration = async (id: string, formData: FormData) => {
});
if (response.ok) {
revalidatePath("/integrations/s3");
// Only test connection if credentials or configuration were updated
if (credentials || configuration) {
const testResult = await testIntegrationConnection(id);
return {
success: "Integration updated successfully!",
testConnection: testResult,
};
} else {
return {
success: "Integration updated successfully!",
};
// Revalidate the appropriate page based on integration type
if (integration_type === "amazon_s3") {
revalidatePath("/integrations/amazon-s3");
} else if (integration_type === "aws_security_hub") {
revalidatePath("/integrations/aws-security-hub");
}
return {
success: "Integration updated successfully!",
integrationId: id,
};
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to update S3 integration: ${response.statusText}`;
`Unable to update integration: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};
export const deleteIntegration = async (id: string) => {
export const deleteIntegration = async (
id: string,
integration_type: IntegrationType,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
@@ -206,14 +215,20 @@ export const deleteIntegration = async (id: string) => {
const response = await fetch(url.toString(), { method: "DELETE", headers });
if (response.ok) {
revalidatePath("/integrations/s3");
// Revalidate the appropriate page based on integration type
if (integration_type === "amazon_s3") {
revalidatePath("/integrations/amazon-s3");
} else if (integration_type === "aws_security_hub") {
revalidatePath("/integrations/aws-security-hub");
}
return { success: "Integration deleted successfully!" };
}
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to delete S3 integration: ${response.statusText}`;
`Unable to delete integration: ${response.statusText}`;
return { error: errorMessage };
} catch (error) {
return handleApiError(error);
@@ -269,7 +284,10 @@ const pollTaskUntilComplete = async (taskId: string): Promise<any> => {
return { error: "Connection test timeout. Test took too long to complete." };
};
export const testIntegrationConnection = async (id: string) => {
export const testIntegrationConnection = async (
id: string,
waitForCompletion = true,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}/connection`);
@@ -281,10 +299,22 @@ export const testIntegrationConnection = async (id: string) => {
const taskId = data?.data?.id;
if (taskId) {
// If waitForCompletion is false, return immediately with task started status
if (!waitForCompletion) {
return {
success: true,
message:
"Connection test started. It may take some time to complete.",
taskId,
data: parseStringify(data),
};
}
// Poll the task until completion
const pollResult = await pollTaskUntilComplete(taskId);
revalidatePath("/integrations/s3");
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
if (pollResult.error) {
return { error: pollResult.error };
@@ -292,17 +322,20 @@ export const testIntegrationConnection = async (id: string) => {
if (pollResult.success) {
return {
success: "Connection test completed successfully!",
message: pollResult.message,
success: true,
message:
pollResult.message || "Connection test completed successfully!",
data: parseStringify(data),
};
} else {
return {
success: false,
error: pollResult.message || "Connection test failed.",
};
}
} else {
return {
success: false,
error: "Failed to start connection test. No task ID received.",
};
}
@@ -311,9 +344,37 @@ export const testIntegrationConnection = async (id: string) => {
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.errors?.[0]?.detail ||
`Unable to test S3 integration connection: ${response.statusText}`;
return { error: errorMessage };
`Unable to test integration connection: ${response.statusText}`;
return { success: false, error: errorMessage };
} catch (error) {
return handleApiError(error);
}
};
export const pollConnectionTestStatus = async (taskId: string) => {
try {
const pollResult = await pollTaskUntilComplete(taskId);
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
if (pollResult.error) {
return { success: false, error: pollResult.error };
}
if (pollResult.success) {
return {
success: true,
message:
pollResult.message || "Connection test completed successfully!",
};
} else {
return {
success: false,
error: pollResult.message || "Connection test failed.",
};
}
} catch (error) {
return { success: false, error: "Failed to check connection test status." };
}
};

View File

@@ -0,0 +1,90 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers";
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
import { ContentLayout } from "@/components/ui";
interface SecurityHubIntegrationsProps {
searchParams: { [key: string]: string | string[] | undefined };
}
export default async function SecurityHubIntegrations({
searchParams,
}: SecurityHubIntegrationsProps) {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString();
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
const urlSearchParams = new URLSearchParams();
urlSearchParams.set("filter[integration_type]", "aws_security_hub");
urlSearchParams.set("page[number]", page.toString());
urlSearchParams.set("page[size]", pageSize.toString());
if (sort) {
urlSearchParams.set("sort", sort);
}
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && key !== "filter[integration_type]") {
const stringValue = Array.isArray(value) ? value[0] : String(value);
urlSearchParams.set(key, stringValue);
}
});
const [integrations, providers] = await Promise.all([
getIntegrations(urlSearchParams),
getProviders({ pageSize: 100 }),
]);
const securityHubIntegrations = integrations?.data || [];
const availableProviders = providers?.data || [];
const metadata = integrations?.meta;
return (
<ContentLayout title="AWS Security Hub">
<div className="space-y-6">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Configure AWS Security Hub integration to automatically send your
security findings for centralized monitoring and compliance.
</p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Features:
</h3>
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 dark:text-gray-300 md:grid-cols-2">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Automated findings export
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Multi-region support
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Send failed findings only
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Archive previous findings
</li>
</ul>
</div>
</div>
<SecurityHubIntegrationsManager
integrations={securityHubIntegrations}
providers={availableProviders}
metadata={metadata}
/>
</div>
</ContentLayout>
);
}

View File

@@ -1,6 +1,9 @@
import React from "react";
import { S3IntegrationCard } from "@/components/integrations";
import {
S3IntegrationCard,
SecurityHubIntegrationCard,
} from "@/components/integrations";
import { ContentLayout } from "@/components/ui";
export default async function Integrations() {
@@ -17,6 +20,9 @@ export default async function Integrations() {
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
</div>
</div>
</ContentLayout>

View File

@@ -1,7 +1,10 @@
export * from "../providers/provider-selector";
export * from "../providers/enhanced-provider-selector";
export * from "./s3/s3-integration-card";
export * from "./s3/s3-integration-form";
export * from "./s3/s3-integrations-manager";
export * from "./s3/skeleton-s3-integration-card";
export * from "./saml/saml-config-form";
export * from "./saml/saml-integration-card";
export * from "./security-hub/security-hub-integration-card";
export * from "./security-hub/security-hub-integration-form";
export * from "./security-hub/security-hub-integrations-manager";
export * from "./shared";

View File

@@ -37,7 +37,7 @@ export const S3IntegrationCard = () => {
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
asLink="/integrations/s3"
asLink="/integrations/amazon-s3"
ariaLabel="Manage S3 integrations"
>
Manage

View File

@@ -8,7 +8,7 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { ProviderSelector } from "@/components/providers/provider-selector";
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
@@ -27,7 +27,7 @@ import { ProviderProps } from "@/types/providers";
interface S3IntegrationFormProps {
integration?: IntegrationProps | null;
providers: ProviderProps[];
onSuccess: () => void;
onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void;
onCancel: () => void;
editMode?: "configuration" | "credentials" | null; // null means creating new
}
@@ -75,15 +75,9 @@ export const S3IntegrationForm = ({
aws_access_key_id: "",
aws_secret_access_key: "",
aws_session_token: "",
// For credentials editing, show current values as placeholders but require new input
role_arn: isEditingCredentials
? ""
: integration?.attributes.configuration.credentials?.role_arn || "",
role_arn: "",
// External ID always defaults to tenantId, even when editing credentials
external_id:
integration?.attributes.configuration.credentials?.external_id ||
session?.tenantId ||
"",
external_id: session?.tenantId || "",
role_session_name: "",
session_duration: "",
show_role_section: false,
@@ -211,10 +205,16 @@ export const S3IntegrationForm = ({
try {
let result;
let shouldTestConnection = false;
if (isEditing && integration) {
result = await updateIntegration(integration.id, formData);
// Test connection if we're editing credentials or configuration (S3 needs both)
shouldTestConnection = isEditingCredentials || isEditingConfig;
} else {
result = await createIntegration(formData);
// Always test connection for new integrations
shouldTestConnection = true;
}
if ("success" in result) {
@@ -223,23 +223,8 @@ export const S3IntegrationForm = ({
description: `S3 integration ${isEditing ? "updated" : "created"} successfully.`,
});
if ("testConnection" in result) {
if (result.testConnection.success) {
toast({
title: "Connection test started!",
description:
"Connection test started. It may take some time to complete.",
});
} else if (result.testConnection.error) {
toast({
variant: "destructive",
title: "Connection test failed",
description: result.testConnection.error,
});
}
}
onSuccess();
// Pass the integration ID and whether to test connection to the success callback
onSuccess(result.integrationId, shouldTestConnection);
} else if ("error" in result) {
const errorMessage = result.error;
@@ -270,6 +255,7 @@ export const S3IntegrationForm = ({
const templateLinks = getAWSCredentialsTemplateLinks(
externalId,
bucketName,
"amazon_s3",
);
return (
@@ -278,7 +264,7 @@ export const S3IntegrationForm = ({
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="s3-integration"
type="integrations"
/>
);
}
@@ -289,13 +275,15 @@ export const S3IntegrationForm = ({
<>
{/* Provider Selection */}
<div className="space-y-4">
<ProviderSelector
<EnhancedProviderSelector
control={form.control}
name="providers"
providers={providers}
label="Cloud Providers"
placeholder="Select providers to integrate with"
isInvalid={!!form.formState.errors.providers}
selectionMode="multiple"
enableSearch={providers.length > 10}
/>
</div>

View File

@@ -1,14 +1,8 @@
"use client";
import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react";
import { Card, CardBody, CardHeader } from "@nextui-org/react";
import { format } from "date-fns";
import {
PlusIcon,
Power,
SettingsIcon,
TestTube,
Trash2Icon,
} from "lucide-react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import {
@@ -17,15 +11,20 @@ import {
updateIntegration,
} from "@/actions/integrations";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
import {
IntegrationActionButtons,
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { useToast } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
import { S3IntegrationForm } from "./s3-integration-form";
import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card";
interface S3IntegrationsManagerProps {
integrations: IntegrationProps[];
@@ -78,7 +77,7 @@ export const S3IntegrationsManager = ({
const handleDeleteIntegration = async (id: string) => {
setIsDeleting(id);
try {
const result = await deleteIntegration(id);
const result = await deleteIntegration(id, "amazon_s3");
if (result.success) {
toast({
@@ -173,11 +172,34 @@ export const S3IntegrationsManager = ({
setEditMode(null);
};
const handleFormSuccess = () => {
const handleFormSuccess = async (
integrationId?: string,
shouldTestConnection?: boolean,
) => {
// Close the modal immediately
setIsModalOpen(false);
setEditingIntegration(null);
setEditMode(null);
setIsOperationLoading(true);
// Set testing state for server-triggered test connections
if (integrationId && shouldTestConnection) {
setIsTesting(integrationId);
}
// Trigger test connection if needed
triggerTestConnectionWithDelay(
integrationId,
shouldTestConnection,
"s3",
toast,
200,
() => {
// Clear testing state when server-triggered test completes
setIsTesting(null);
},
);
// Reset loading state after a short delay to show the skeleton briefly
setTimeout(() => {
setIsOperationLoading(false);
@@ -274,44 +296,33 @@ export const S3IntegrationsManager = ({
{/* Integrations List */}
{isOperationLoading ? (
<S3IntegrationCardSkeleton
<IntegrationSkeleton
variant="manager"
count={integrations.length || 1}
icon={<AmazonS3Icon size={32} />}
title="Amazon S3"
subtitle="Export security findings to Amazon S3 buckets."
/>
) : integrations.length > 0 ? (
<div className="grid gap-4">
{integrations.map((integration) => (
<Card key={integration.id} className="dark:bg-gray-800">
<CardHeader className="pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={32} />
<div>
<h4 className="text-md font-semibold">
{integration.attributes.configuration.bucket_name ||
"Unknown Bucket"}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-300">
Output directory:{" "}
{integration.attributes.configuration
.output_directory ||
integration.attributes.configuration.path ||
"/"}
</p>
</div>
</div>
<Chip
size="sm"
color={
integration.attributes.connected ? "success" : "danger"
}
variant="flat"
>
{integration.attributes.connected
? "Connected"
: "Disconnected"}
</Chip>
</div>
<IntegrationCardHeader
icon={<AmazonS3Icon size={32} />}
title={
integration.attributes.configuration.bucket_name ||
"Unknown Bucket"
}
subtitle={`Output directory: ${
integration.attributes.configuration.output_directory ||
integration.attributes.configuration.path ||
"/"
}`}
connectionStatus={{
connected: integration.attributes.connected,
}}
/>
</CardHeader>
<CardBody className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -328,68 +339,15 @@ export const S3IntegrationsManager = ({
</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<CustomButton
size="sm"
variant="bordered"
startContent={<TestTube size={14} />}
onPress={() => handleTestConnection(integration.id)}
isLoading={isTesting === integration.id}
isDisabled={!integration.attributes.enabled}
ariaLabel="Test connection"
className="w-full sm:w-auto"
>
Test
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => handleEditConfiguration(integration)}
ariaLabel="Edit configuration"
className="w-full sm:w-auto"
>
Config
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => handleEditCredentials(integration)}
ariaLabel="Edit credentials"
className="w-full sm:w-auto"
>
Credentials
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
color={
integration.attributes.enabled ? "warning" : "primary"
}
startContent={<Power size={14} />}
onPress={() => handleToggleEnabled(integration)}
ariaLabel={
integration.attributes.enabled
? "Disable integration"
: "Enable integration"
}
className="w-full sm:w-auto"
>
{integration.attributes.enabled ? "Disable" : "Enable"}
</CustomButton>
<CustomButton
size="sm"
color="danger"
variant="bordered"
startContent={<Trash2Icon size={14} />}
onPress={() => handleOpenDeleteModal(integration)}
ariaLabel="Delete integration"
className="w-full sm:w-auto"
>
Delete
</CustomButton>
</div>
<IntegrationActionButtons
integration={integration}
onTestConnection={handleTestConnection}
onEditConfiguration={handleEditConfiguration}
onEditCredentials={handleEditCredentials}
onToggleEnabled={handleToggleEnabled}
onDelete={handleOpenDeleteModal}
isTesting={isTesting === integration.id}
/>
</div>
</CardBody>
</Card>

View File

@@ -1,95 +0,0 @@
"use client";
import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
interface S3IntegrationCardSkeletonProps {
variant?: "main" | "manager";
count?: number;
}
export const S3IntegrationCardSkeleton = ({
variant = "main",
count = 1,
}: S3IntegrationCardSkeletonProps) => {
if (variant === "main") {
return (
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="gap-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold">Amazon S3</h4>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500">
Export security findings to Amazon S3 buckets.
</p>
<Skeleton className="h-3 w-16 rounded" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
<div className="space-y-2">
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-48 rounded" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
))}
</div>
</div>
</CardBody>
</Card>
);
}
// Manager variant - for individual cards in S3IntegrationsManager
return (
<div className="grid gap-4">
{Array.from({ length: count }).map((_, index) => (
<Card key={index} className="dark:bg-prowler-blue-400">
<CardHeader className="pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
<AmazonS3Icon size={32} />
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-40 rounded" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-3 w-48 rounded" />
<Skeleton className="h-3 w-36 rounded" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded" />
</div>
</div>
</CardBody>
</Card>
))}
</div>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import { Card, CardBody, CardHeader } from "@nextui-org/react";
import { SettingsIcon } from "lucide-react";
import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
export const SecurityHubIntegrationCard = () => {
return (
<Card className="dark:bg-gray-800">
<CardHeader className="gap-2">
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<AWSSecurityHubIcon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
AWS Security Hub
</h4>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="text-nowrap text-xs text-gray-500 dark:text-gray-300">
Send security findings to AWS Security Hub.
</p>
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/security-hub/"
aria-label="Learn more about Security Hub integration"
size="xs"
>
Learn more
</CustomLink>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
asLink="/integrations/aws-security-hub"
ariaLabel="Manage Security Hub integrations"
>
Manage
</CustomButton>
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Configure and manage your AWS Security Hub integrations to
automatically send security findings for centralized monitoring.
</p>
</div>
</CardBody>
</Card>
);
};

View File

@@ -0,0 +1,490 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Checkbox, Divider, Radio, RadioGroup } from "@nextui-org/react";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useMemo, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form, FormControl, FormField } from "@/components/ui/form";
import { FormButtons } from "@/components/ui/form/form-buttons";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
import {
editSecurityHubIntegrationFormSchema,
IntegrationProps,
securityHubIntegrationFormSchema,
} from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
interface SecurityHubIntegrationFormProps {
integration?: IntegrationProps | null;
providers: ProviderProps[];
existingIntegrations?: IntegrationProps[];
onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void;
onCancel: () => void;
editMode?: "configuration" | "credentials" | null;
}
export const SecurityHubIntegrationForm = ({
integration,
providers,
existingIntegrations = [],
onSuccess,
onCancel,
editMode = null,
}: SecurityHubIntegrationFormProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(
editMode === "credentials" ? 1 : 0,
);
const isEditing = !!integration;
const isCreating = !isEditing;
const isEditingConfig = editMode === "configuration";
const isEditingCredentials = editMode === "credentials";
const disabledProviderIds = useMemo(() => {
// When editing, no providers should be disabled since we're not changing it
if (isEditing) {
return [];
}
// When creating, disable providers that are already used by other Security Hub integrations
const usedProviderIds: string[] = [];
existingIntegrations.forEach((existingIntegration) => {
const providerRelationships =
existingIntegration.relationships?.providers?.data;
if (providerRelationships && providerRelationships.length > 0) {
usedProviderIds.push(providerRelationships[0].id);
}
});
return usedProviderIds;
}, [isEditing, existingIntegrations]);
const form = useForm({
resolver: zodResolver(
isEditingCredentials || isCreating
? securityHubIntegrationFormSchema
: editSecurityHubIntegrationFormSchema,
),
defaultValues: {
integration_type: "aws_security_hub" as const,
provider_id: integration?.relationships?.providers?.data?.[0]?.id || "",
send_only_fails:
integration?.attributes.configuration.send_only_fails ?? true,
archive_previous_findings:
integration?.attributes.configuration.archive_previous_findings ??
false,
use_custom_credentials: false,
enabled: integration?.attributes.enabled ?? true,
credentials_type: "access-secret-key" as const,
aws_access_key_id: "",
aws_secret_access_key: "",
aws_session_token: "",
role_arn: "",
external_id: session?.tenantId || "",
role_session_name: "",
session_duration: "",
show_role_section: false,
},
});
const isLoading = form.formState.isSubmitting;
const useCustomCredentials = form.watch("use_custom_credentials");
const providerIdValue = form.watch("provider_id");
const hasErrors = !!form.formState.errors.provider_id || !providerIdValue;
useEffect(() => {
if (!useCustomCredentials && isCreating) {
setCurrentStep(0);
}
}, [useCustomCredentials, isCreating]);
const handleNext = async (e: React.FormEvent) => {
e.preventDefault();
if (isEditingConfig || isEditingCredentials) {
return;
}
const stepFields = currentStep === 0 ? (["provider_id"] as const) : [];
const isValid = stepFields.length === 0 || (await form.trigger(stepFields));
if (isValid) {
setCurrentStep(1);
}
};
const handleBack = () => {
setCurrentStep(0);
};
const buildCredentials = (values: any) => {
const credentials: any = {};
if (values.role_arn && values.role_arn.trim() !== "") {
credentials.role_arn = values.role_arn;
credentials.external_id = values.external_id;
if (values.role_session_name)
credentials.role_session_name = values.role_session_name;
if (values.session_duration)
credentials.session_duration =
parseInt(values.session_duration, 10) || 3600;
}
if (values.credentials_type === "access-secret-key") {
credentials.aws_access_key_id = values.aws_access_key_id;
credentials.aws_secret_access_key = values.aws_secret_access_key;
if (values.aws_session_token)
credentials.aws_session_token = values.aws_session_token;
}
return credentials;
};
const buildConfiguration = (values: any) => {
const configuration: any = {};
configuration.send_only_fails = values.send_only_fails ?? true;
configuration.archive_previous_findings =
values.archive_previous_findings ?? false;
return configuration;
};
const buildFormData = (values: any) => {
const formData = new FormData();
formData.append("integration_type", values.integration_type);
if (isEditingConfig) {
const configuration = buildConfiguration(values);
if (Object.keys(configuration).length > 0) {
formData.append("configuration", JSON.stringify(configuration));
}
} else if (isEditingCredentials) {
// When editing credentials, check if using custom credentials
if (!values.use_custom_credentials) {
// Use provider credentials - send empty object
formData.append("credentials", JSON.stringify({}));
} else {
// Use custom credentials
const credentials = buildCredentials(values);
formData.append("credentials", JSON.stringify(credentials));
}
} else {
const configuration = buildConfiguration(values);
formData.append("configuration", JSON.stringify(configuration));
if (values.use_custom_credentials) {
const credentials = buildCredentials(values);
formData.append("credentials", JSON.stringify(credentials));
} else {
formData.append("credentials", JSON.stringify({}));
}
formData.append("enabled", JSON.stringify(values.enabled ?? true));
// Send provider_id as an array for consistency with the action
formData.append("providers", JSON.stringify([values.provider_id]));
}
return formData;
};
const onSubmit = async (values: any) => {
const formData = buildFormData(values);
try {
let result;
let shouldTestConnection = false;
if (isEditing && integration) {
result = await updateIntegration(integration.id, formData);
// Test connection ONLY if we're editing credentials (Security Hub doesn't need test for config changes)
shouldTestConnection = isEditingCredentials;
} else {
result = await createIntegration(formData);
// Always test connection for new integrations
shouldTestConnection = true;
}
if ("success" in result) {
toast({
title: "Success!",
description: `Security Hub integration ${isEditing ? "updated" : "created"} successfully.`,
});
// Pass the integration ID and whether to test connection to the success callback
onSuccess(result.integrationId, shouldTestConnection);
} else if ("error" in result) {
const errorMessage = result.error;
toast({
variant: "destructive",
title: "Security Hub Integration Error",
description: errorMessage,
});
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unexpected error occurred";
toast({
variant: "destructive",
title: "Connection Error",
description: `${errorMessage}. Please check your network connection and try again.`,
});
}
};
const renderStepContent = () => {
if (isEditingCredentials) {
// When editing credentials, show the credential type selector first
return (
<div className="space-y-4">
<RadioGroup
size="sm"
aria-label="Credential type"
value={useCustomCredentials ? "custom" : "provider"}
onValueChange={(value) => {
form.setValue("use_custom_credentials", value === "custom", {
shouldValidate: true,
shouldDirty: true,
});
}}
>
<Radio value="provider">
<span className="text-sm">Use provider credentials</span>
</Radio>
<Radio value="custom">
<span className="text-sm">Use custom credentials</span>
</Radio>
</RadioGroup>
{useCustomCredentials && (
<>
<Divider />
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={
form.getValues("external_id") || session?.tenantId || ""
}
templateLinks={getAWSCredentialsTemplateLinks(
form.getValues("external_id") || session?.tenantId || "",
undefined,
"aws_security_hub",
)}
type="integrations"
/>
</>
)}
</div>
);
}
if (currentStep === 1 && useCustomCredentials) {
const externalId =
form.getValues("external_id") || session?.tenantId || "";
const templateLinks = getAWSCredentialsTemplateLinks(
externalId,
undefined,
"aws_security_hub",
);
return (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="integrations"
/>
);
}
if (isEditingConfig || currentStep === 0) {
return (
<>
{!isEditingConfig && (
<>
<div className="space-y-4">
<EnhancedProviderSelector
control={form.control}
name="provider_id"
providers={providers}
label="AWS Provider"
placeholder="Search and select an AWS provider"
isInvalid={!!form.formState.errors.provider_id}
selectionMode="single"
providerType="aws"
enableSearch={true}
disabledProviderIds={disabledProviderIds}
/>
</div>
<Divider />
</>
)}
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="send_only_fails"
render={({ field }) => (
<FormControl>
<Checkbox
isSelected={field.value}
onValueChange={field.onChange}
size="sm"
>
<span className="text-sm">Send only Failed Findings</span>
</Checkbox>
</FormControl>
)}
/>
<FormField
control={form.control}
name="archive_previous_findings"
render={({ field }) => (
<FormControl>
<Checkbox
isSelected={field.value}
onValueChange={field.onChange}
size="sm"
>
<span className="text-sm">Archive previous findings</span>
</Checkbox>
</FormControl>
)}
/>
{isCreating && (
<FormField
control={form.control}
name="use_custom_credentials"
render={({ field }) => (
<FormControl>
<Checkbox
isSelected={field.value}
onValueChange={field.onChange}
size="sm"
>
<span className="text-sm">Use custom credentials</span>
</Checkbox>
</FormControl>
)}
/>
)}
</div>
</>
);
}
return null;
};
const renderStepButtons = () => {
if (isEditingConfig || isEditingCredentials) {
const updateText = isEditingConfig
? "Update Configuration"
: "Update Credentials";
const loadingText = isEditingConfig
? "Updating Configuration..."
: "Updating Credentials...";
return (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText={updateText}
cancelText="Cancel"
loadingText={loadingText}
isDisabled={isLoading}
/>
);
}
if (currentStep === 0 && !useCustomCredentials) {
return (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText="Create Integration"
cancelText="Cancel"
loadingText="Creating..."
isDisabled={isLoading || hasErrors}
/>
);
}
if (currentStep === 0 && useCustomCredentials) {
return (
<FormButtons
setIsOpen={() => {}}
onCancel={onCancel}
submitText="Next"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={isLoading || hasErrors}
rightIcon={<ArrowRightIcon size={24} />}
/>
);
}
return (
<FormButtons
setIsOpen={() => {}}
onCancel={handleBack}
submitText="Create Integration"
cancelText="Back"
loadingText="Creating..."
leftIcon={<ArrowLeftIcon size={24} />}
isDisabled={isLoading}
/>
);
};
return (
<Form {...form}>
<form
onSubmit={
isEditingConfig ||
isEditingCredentials ||
(currentStep === 0 && !useCustomCredentials)
? form.handleSubmit(onSubmit)
: currentStep === 0
? handleNext
: form.handleSubmit(onSubmit)
}
className="flex flex-col space-y-6"
>
<div className="flex flex-col space-y-4">
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="flex items-center gap-2 text-sm text-default-500">
Need help configuring your AWS Security Hub integration?
</p>
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/security-hub/"
target="_blank"
size="sm"
>
Read the docs
</CustomLink>
</div>
{renderStepContent()}
</div>
{renderStepButtons()}
</form>
</Form>
);
};

View File

@@ -0,0 +1,445 @@
"use client";
import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react";
import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import {
deleteIntegration,
testIntegrationConnection,
updateIntegration,
} from "@/actions/integrations";
import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices";
import {
IntegrationActionButtons,
IntegrationCardHeader,
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { useToast } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
import { SecurityHubIntegrationForm } from "./security-hub-integration-form";
interface SecurityHubIntegrationsManagerProps {
integrations: IntegrationProps[];
providers: ProviderProps[];
metadata?: MetaDataProps;
}
export const SecurityHubIntegrationsManager = ({
integrations,
providers,
metadata,
}: SecurityHubIntegrationsManagerProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingIntegration, setEditingIntegration] =
useState<IntegrationProps | null>(null);
const [editMode, setEditMode] = useState<
"configuration" | "credentials" | null
>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState<string | null>(null);
const [isOperationLoading, setIsOperationLoading] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [integrationToDelete, setIntegrationToDelete] =
useState<IntegrationProps | null>(null);
const { toast } = useToast();
const handleAddIntegration = () => {
setEditingIntegration(null);
setEditMode(null);
setIsModalOpen(true);
};
const handleEditConfiguration = (integration: IntegrationProps) => {
setEditingIntegration(integration);
setEditMode("configuration");
setIsModalOpen(true);
};
const handleEditCredentials = (integration: IntegrationProps) => {
setEditingIntegration(integration);
setEditMode("credentials");
setIsModalOpen(true);
};
const handleOpenDeleteModal = (integration: IntegrationProps) => {
setIntegrationToDelete(integration);
setIsDeleteOpen(true);
};
const handleDeleteIntegration = async (id: string) => {
setIsDeleting(id);
try {
const result = await deleteIntegration(id, "aws_security_hub");
if (result.success) {
toast({
title: "Success!",
description: "Security Hub integration deleted successfully.",
});
} else if (result.error) {
toast({
variant: "destructive",
title: "Delete Failed",
description: result.error,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
"Failed to delete Security Hub integration. Please try again.",
});
} finally {
setIsDeleting(null);
setIsDeleteOpen(false);
setIntegrationToDelete(null);
}
};
const handleTestConnection = async (id: string) => {
setIsTesting(id);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
toast({
title: "Connection test successful!",
description:
result.message || "Connection test completed successfully.",
});
} else if (result.error) {
toast({
variant: "destructive",
title: "Connection test failed",
description: result.error,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(null);
}
};
const handleToggleEnabled = async (integration: IntegrationProps) => {
try {
const newEnabledState = !integration.attributes.enabled;
const formData = new FormData();
formData.append(
"integration_type",
integration.attributes.integration_type,
);
formData.append("enabled", JSON.stringify(newEnabledState));
const result = await updateIntegration(integration.id, formData);
if (result && "success" in result) {
toast({
title: "Success!",
description: `Integration ${newEnabledState ? "enabled" : "disabled"} successfully.`,
});
// If enabling, trigger test connection automatically
if (newEnabledState) {
setIsTesting(integration.id);
triggerTestConnectionWithDelay(
integration.id,
true,
"security_hub",
toast,
500,
() => {
setIsTesting(null);
},
);
}
} else if (result && "error" in result) {
toast({
variant: "destructive",
title: "Toggle Failed",
description: result.error,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to toggle integration. Please try again.",
});
}
};
const handleModalClose = () => {
setIsModalOpen(false);
setEditingIntegration(null);
setEditMode(null);
};
const handleFormSuccess = async (
integrationId?: string,
shouldTestConnection?: boolean,
) => {
// Close the modal immediately
setIsModalOpen(false);
setEditingIntegration(null);
setEditMode(null);
setIsOperationLoading(true);
// Set testing state for server-triggered test connections
if (integrationId && shouldTestConnection) {
setIsTesting(integrationId);
}
// Trigger test connection if needed
triggerTestConnectionWithDelay(
integrationId,
shouldTestConnection,
"security_hub",
toast,
200,
() => {
// Clear testing state when server-triggered test completes
setIsTesting(null);
},
);
// Reset loading state after a short delay to show the skeleton briefly
setTimeout(() => {
setIsOperationLoading(false);
}, 1500);
};
const getProviderDetails = (integration: IntegrationProps) => {
const providerRelationships = integration.relationships?.providers?.data;
if (!providerRelationships || providerRelationships.length === 0) {
return { displayName: "Unknown Account", accountId: null };
}
// Security Hub should only have one provider
const providerId = providerRelationships[0].id;
const provider = providers.find((p) => p.id === providerId);
if (!provider) {
return { displayName: "Unknown Account", accountId: null };
}
return {
displayName: provider.attributes.alias || provider.attributes.uid,
accountId: provider.attributes.uid,
alias: provider.attributes.alias,
};
};
const getEnabledRegions = (integration: IntegrationProps) => {
const regions = integration.attributes.configuration.regions;
if (!regions || typeof regions !== "object") return [];
return Object.entries(regions)
.filter(([_, enabled]) => enabled === true)
.map(([region]) => region)
.sort();
};
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Delete Security Hub Integration"
description="This action cannot be undone. This will permanently delete your Security Hub integration."
>
<div className="flex w-full justify-center space-x-6">
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
onPress={() => {
setIsDeleteOpen(false);
setIntegrationToDelete(null);
}}
isDisabled={isDeleting !== null}
>
<span>Cancel</span>
</CustomButton>
<CustomButton
type="button"
ariaLabel="Delete"
className="w-full"
variant="solid"
color="danger"
size="lg"
isLoading={isDeleting !== null}
startContent={!isDeleting && <Trash2Icon size={24} />}
onPress={() =>
integrationToDelete &&
handleDeleteIntegration(integrationToDelete.id)
}
>
{isDeleting ? "Deleting..." : "Delete"}
</CustomButton>
</div>
</CustomAlertModal>
<CustomAlertModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
title={
editMode === "configuration"
? "Edit Configuration"
: editMode === "credentials"
? "Edit Credentials"
: editingIntegration
? "Edit Security Hub Integration"
: "Add Security Hub Integration"
}
>
<SecurityHubIntegrationForm
integration={editingIntegration}
providers={providers}
existingIntegrations={integrations}
onSuccess={handleFormSuccess}
onCancel={handleModalClose}
editMode={editMode}
/>
</CustomAlertModal>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">
Configured Security Hub Integrations
</h3>
<p className="text-sm text-gray-500 dark:text-gray-300">
{integrations.length === 0
? "Not configured yet"
: `${integrations.length} integration${integrations.length !== 1 ? "s" : ""} configured`}
</p>
</div>
<CustomButton
color="action"
startContent={<PlusIcon size={16} />}
onPress={handleAddIntegration}
ariaLabel="Add integration"
>
Add Integration
</CustomButton>
</div>
{isOperationLoading ? (
<IntegrationSkeleton
variant="manager"
count={integrations.length || 1}
icon={<AWSSecurityHubIcon size={32} />}
title="AWS Security Hub"
subtitle="Send security findings to AWS Security Hub."
/>
) : integrations.length > 0 ? (
<div className="grid gap-4">
{integrations.map((integration) => {
const enabledRegions = getEnabledRegions(integration);
const providerDetails = getProviderDetails(integration);
return (
<Card key={integration.id} className="dark:bg-gray-800">
<CardHeader className="pb-2">
<IntegrationCardHeader
icon={<AWSSecurityHubIcon size={32} />}
title={providerDetails.displayName}
subtitle={
providerDetails.accountId && providerDetails.alias
? `Account ID: ${providerDetails.accountId}`
: "AWS Security Hub Integration"
}
chips={[
{
label: integration.attributes.configuration
.send_only_fails
? "Failed Only"
: "All Findings",
},
{
label: integration.attributes.configuration
.archive_previous_findings
? "Archive Previous"
: "Keep Previous",
},
]}
connectionStatus={{
connected: integration.attributes.connected,
}}
/>
</CardHeader>
<CardBody className="pt-0">
<div className="flex flex-col gap-3">
{enabledRegions.length > 0 && (
<div className="flex flex-wrap gap-1">
{enabledRegions.map((region) => (
<Chip
key={region}
size="sm"
variant="flat"
className="bg-default-100"
>
{region}
</Chip>
))}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{integration.attributes.updated_at && (
<p>
<span className="font-medium">Last updated:</span>{" "}
{format(
new Date(integration.attributes.updated_at),
"yyyy/MM/dd",
)}
</p>
)}
</div>
<IntegrationActionButtons
integration={integration}
onTestConnection={handleTestConnection}
onEditConfiguration={handleEditConfiguration}
onEditCredentials={handleEditCredentials}
onToggleEnabled={handleToggleEnabled}
onDelete={handleOpenDeleteModal}
isTesting={isTesting === integration.id}
/>
</div>
</div>
</CardBody>
</Card>
);
})}
</div>
) : null}
{metadata && integrations.length > 0 && (
<div className="mt-6">
<DataTablePagination metadata={metadata} />
</div>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,3 @@
export { IntegrationActionButtons } from "./integration-action-buttons";
export { IntegrationCardHeader } from "./integration-card-header";
export { IntegrationSkeleton } from "./integration-skeleton";

View File

@@ -0,0 +1,100 @@
"use client";
import {
LockIcon,
Power,
SettingsIcon,
TestTube,
Trash2Icon,
} from "lucide-react";
import { CustomButton } from "@/components/ui/custom";
import { IntegrationProps } from "@/types/integrations";
interface IntegrationActionButtonsProps {
integration: IntegrationProps;
onTestConnection: (id: string) => void;
onEditConfiguration: (integration: IntegrationProps) => void;
onEditCredentials: (integration: IntegrationProps) => void;
onToggleEnabled: (integration: IntegrationProps) => void;
onDelete: (integration: IntegrationProps) => void;
isTesting?: boolean;
showCredentialsButton?: boolean;
}
export const IntegrationActionButtons = ({
integration,
onTestConnection,
onEditConfiguration,
onEditCredentials,
onToggleEnabled,
onDelete,
isTesting = false,
showCredentialsButton = true,
}: IntegrationActionButtonsProps) => {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<CustomButton
size="sm"
variant="bordered"
startContent={<TestTube size={14} />}
onPress={() => onTestConnection(integration.id)}
isLoading={isTesting}
isDisabled={!integration.attributes.enabled || isTesting}
ariaLabel="Test connection"
className="w-full sm:w-auto"
>
Test
</CustomButton>
<CustomButton
size="sm"
variant="bordered"
startContent={<SettingsIcon size={14} />}
onPress={() => onEditConfiguration(integration)}
ariaLabel="Edit configuration"
className="w-full sm:w-auto"
>
Config
</CustomButton>
{showCredentialsButton && (
<CustomButton
size="sm"
variant="bordered"
startContent={<LockIcon size={14} />}
onPress={() => onEditCredentials(integration)}
ariaLabel="Edit credentials"
className="w-full sm:w-auto"
>
Credentials
</CustomButton>
)}
<CustomButton
size="sm"
variant="bordered"
color={integration.attributes.enabled ? "warning" : "primary"}
startContent={<Power size={14} />}
onPress={() => onToggleEnabled(integration)}
isDisabled={isTesting}
ariaLabel={
integration.attributes.enabled
? "Disable integration"
: "Enable integration"
}
className="w-full sm:w-auto"
>
{integration.attributes.enabled ? "Disable" : "Enable"}
</CustomButton>
<CustomButton
size="sm"
color="danger"
variant="bordered"
startContent={<Trash2Icon size={14} />}
onPress={() => onDelete(integration)}
ariaLabel="Delete integration"
className="w-full sm:w-auto"
>
Delete
</CustomButton>
</div>
);
};

View File

@@ -0,0 +1,74 @@
"use client";
import { Chip } from "@nextui-org/react";
import { ReactNode } from "react";
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
chips?: Array<{
label: string;
color?:
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow";
}>;
connectionStatus?: {
connected: boolean;
label?: string;
};
}
export const IntegrationCardHeader = ({
icon,
title,
subtitle,
chips = [],
connectionStatus,
}: IntegrationCardHeaderProps) => {
return (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{icon}
<div>
<h4 className="text-md font-semibold">{title}</h4>
{subtitle && (
<p className="text-xs text-gray-500 dark:text-gray-300">
{subtitle}
</p>
)}
</div>
</div>
{(chips.length > 0 || connectionStatus) && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip, index) => (
<Chip
key={index}
size="sm"
variant={chip.variant || "flat"}
color={chip.color || "default"}
className="text-xs"
>
{chip.label}
</Chip>
))}
{connectionStatus && (
<Chip
size="sm"
color={connectionStatus.connected ? "success" : "danger"}
variant="flat"
>
{connectionStatus.label ||
(connectionStatus.connected ? "Connected" : "Disconnected")}
</Chip>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,96 @@
"use client";
import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react";
import { ReactNode } from "react";
interface IntegrationSkeletonProps {
variant?: "main" | "manager";
count?: number;
icon: ReactNode;
title?: string;
subtitle?: string;
}
export const IntegrationSkeleton = ({
variant = "main",
count = 1,
icon,
title = "Integration",
subtitle = "Loading integration details...",
}: IntegrationSkeletonProps) => {
if (variant === "main") {
return (
<Card className="dark:bg-prowler-blue-400">
<CardHeader className="gap-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{icon}
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold">{title}</h4>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500">{subtitle}</p>
<Skeleton className="h-4 w-20 rounded" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-24 rounded" />
</div>
</div>
</CardHeader>
<CardBody>
<div className="flex flex-col gap-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
</CardBody>
</Card>
);
}
// Manager variant - for individual cards in integration managers
return (
<div className="grid gap-4">
{Array.from({ length: count }).map((_, index) => (
<Card key={index} className="dark:bg-prowler-blue-400">
<CardHeader className="pb-2">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{icon}
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-40 rounded" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="flex flex-col gap-3">
{/* Region chips skeleton */}
<div className="flex flex-wrap gap-1">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="w-18 h-6 rounded-full" />
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-3 w-32 rounded" />
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Skeleton className="h-7 w-16 rounded" />
<Skeleton className="h-7 w-20 rounded" />
<Skeleton className="h-7 w-24 rounded" />
<Skeleton className="h-7 w-20 rounded" />
<Skeleton className="h-7 w-20 rounded" />
</div>
</div>
</div>
</CardBody>
</Card>
))}
</div>
);
};

View File

@@ -0,0 +1,294 @@
"use client";
import { Button, Input, Select, SelectItem } from "@nextui-org/react";
import { CheckSquare, Search, Square } from "lucide-react";
import { useMemo, useState } from "react";
import { Control } from "react-hook-form";
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
import { ProviderProps, ProviderType } from "@/types/providers";
const providerTypeLabels: Record<ProviderType, string> = {
aws: "Amazon Web Services",
gcp: "Google Cloud Platform",
azure: "Microsoft Azure",
m365: "Microsoft 365",
kubernetes: "Kubernetes",
github: "GitHub",
};
interface EnhancedProviderSelectorProps {
control: Control<any>;
name: string;
providers: ProviderProps[];
label?: string;
placeholder?: string;
isInvalid?: boolean;
showFormMessage?: boolean;
selectionMode?: "single" | "multiple";
providerType?: ProviderType;
enableSearch?: boolean;
disabledProviderIds?: string[];
}
export const EnhancedProviderSelector = ({
control,
name,
providers,
label = "Provider",
placeholder = "Select provider",
isInvalid = false,
showFormMessage = true,
selectionMode = "single",
providerType,
enableSearch = false,
disabledProviderIds = [],
}: EnhancedProviderSelectorProps) => {
const [searchValue, setSearchValue] = useState("");
const filteredProviders = useMemo(() => {
let filtered = providers;
// Filter by provider type if specified
if (providerType) {
filtered = filtered.filter((p) => p.attributes.provider === providerType);
}
// Filter by search value
if (searchValue && enableSearch) {
const lowerSearch = searchValue.toLowerCase();
filtered = filtered.filter((p) => {
const displayName = p.attributes.alias || p.attributes.uid;
const typeLabel = providerTypeLabels[p.attributes.provider];
return (
displayName.toLowerCase().includes(lowerSearch) ||
typeLabel.toLowerCase().includes(lowerSearch)
);
});
}
// Sort providers
return filtered.sort((a, b) => {
const typeComparison = a.attributes.provider.localeCompare(
b.attributes.provider,
);
if (typeComparison !== 0) return typeComparison;
const nameA = a.attributes.alias || a.attributes.uid;
const nameB = b.attributes.alias || b.attributes.uid;
return nameA.localeCompare(nameB);
});
}, [providers, providerType, searchValue, enableSearch]);
return (
<FormField
control={control}
name={name}
render={({ field: { onChange, value, onBlur } }) => {
const isMultiple = selectionMode === "multiple";
const selectedIds = isMultiple ? value || [] : value ? [value] : [];
const allProviderIds = filteredProviders
.filter((p) => !disabledProviderIds.includes(p.id))
.map((p) => p.id);
const isAllSelected =
isMultiple &&
allProviderIds.length > 0 &&
allProviderIds.every((id) => selectedIds.includes(id));
const handleSelectAll = () => {
if (isAllSelected) {
onChange([]);
} else {
onChange(allProviderIds);
}
};
const handleSelectionChange = (keys: any) => {
if (isMultiple) {
const selectedArray = Array.from(keys);
onChange(selectedArray);
} else {
const selectedValue = Array.from(keys)[0];
onChange(selectedValue || "");
}
};
return (
<>
<FormControl>
<div className="space-y-2">
{isMultiple && filteredProviders.length > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-default-700">
{label}
</span>
<Button
size="sm"
variant="light"
onPress={handleSelectAll}
startContent={
isAllSelected ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)
}
className="h-7 text-xs"
>
{isAllSelected ? "Deselect All" : "Select All"}
</Button>
</div>
)}
<Select
label={label}
placeholder={placeholder}
selectionMode={isMultiple ? "multiple" : "single"}
selectedKeys={
new Set(isMultiple ? value || [] : value ? [value] : [])
}
onSelectionChange={handleSelectionChange}
onBlur={onBlur}
variant="bordered"
labelPlacement="inside"
isRequired={false}
isInvalid={isInvalid}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
listbox: "gap-0",
label:
"tracking-tight font-light !text-default-500 text-xs !z-0",
value: "text-default-500 text-small dark:text-gray-300",
}}
renderValue={(items) => {
if (!isMultiple && value) {
const provider = providers.find((p) => p.id === value);
if (provider) {
const displayName =
provider.attributes.alias || provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
if (items.length === 0) {
return (
<span className="text-default-500">{placeholder}</span>
);
}
if (isMultiple) {
if (items.length === 1) {
const provider = providers.find(
(p) => p.id === items[0].key,
);
if (provider) {
const displayName =
provider.attributes.alias ||
provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
return (
<span className="text-small">
{items.length} provider{items.length !== 1 ? "s" : ""}{" "}
selected
</span>
);
}
return null;
}}
listboxProps={{
topContent: enableSearch ? (
<div className="sticky top-0 z-10 bg-content1 py-2 dark:bg-gray-800">
<Input
isClearable
placeholder="Search providers..."
size="sm"
variant="bordered"
startContent={<Search size={16} />}
value={searchValue}
onValueChange={setSearchValue}
onClear={() => setSearchValue("")}
classNames={{
inputWrapper:
"border-default-200 bg-transparent hover:bg-default-100/50 dark:bg-transparent dark:hover:bg-default-100/20",
input: "text-small",
clearButton: "text-default-400",
}}
/>
</div>
) : null,
}}
>
{filteredProviders.map((provider) => {
const providerType = provider.attributes.provider;
const displayName =
provider.attributes.alias || provider.attributes.uid;
const typeLabel = providerTypeLabels[providerType];
const isDisabled = disabledProviderIds.includes(
provider.id,
);
return (
<SelectItem
key={provider.id}
textValue={`${displayName} ${typeLabel}`}
className={`py-2 ${isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""}`}
>
<div className="flex w-full items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-small font-medium">
{displayName}
</div>
<div className="truncate text-tiny text-default-500">
{typeLabel}
{isDisabled && (
<span className="ml-2 text-danger">
(Already used)
</span>
)}
</div>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
provider.attributes.connection.connected
? "bg-success"
: "bg-danger"
}`}
title={
provider.attributes.connection.connected
? "Connected"
: "Disconnected"
}
/>
</div>
</div>
</SelectItem>
);
})}
</Select>
</div>
</FormControl>
{showFormMessage && (
<FormMessage className="max-w-full text-xs text-system-error dark:text-system-error" />
)}
</>
);
}}
/>
);
};

View File

@@ -1,201 +0,0 @@
"use client";
import { Button, Select, SelectItem } from "@nextui-org/react";
import { CheckSquare, Square } from "lucide-react";
import { Control } from "react-hook-form";
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
import { ProviderProps, ProviderType } from "@/types/providers";
const providerTypeLabels: Record<ProviderType, string> = {
aws: "Amazon Web Services",
gcp: "Google Cloud Platform",
azure: "Microsoft Azure",
m365: "Microsoft 365",
kubernetes: "Kubernetes",
github: "GitHub",
};
interface ProviderSelectorProps {
control: Control<any>;
name: string;
providers: ProviderProps[];
label?: string;
placeholder?: string;
isInvalid?: boolean;
showFormMessage?: boolean;
}
export const ProviderSelector = ({
control,
name,
providers,
label = "Providers",
placeholder = "Select providers",
isInvalid = false,
showFormMessage = true,
}: ProviderSelectorProps) => {
// Sort providers by type and then by name for better organization
const sortedProviders = [...providers].sort((a, b) => {
const typeComparison = a.attributes.provider.localeCompare(
b.attributes.provider,
);
if (typeComparison !== 0) return typeComparison;
const nameA = a.attributes.alias || a.attributes.uid;
const nameB = b.attributes.alias || b.attributes.uid;
return nameA.localeCompare(nameB);
});
return (
<FormField
control={control}
name={name}
render={({ field: { onChange, value, onBlur } }) => {
const selectedIds = value || [];
const allProviderIds = sortedProviders.map((p) => p.id);
const isAllSelected =
allProviderIds.length > 0 &&
allProviderIds.every((id) => selectedIds.includes(id));
const handleSelectAll = () => {
if (isAllSelected) {
onChange([]);
} else {
onChange(allProviderIds);
}
};
return (
<>
<FormControl>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-default-700">
{label}
</span>
{sortedProviders.length > 1 && (
<Button
size="sm"
variant="light"
onPress={handleSelectAll}
startContent={
isAllSelected ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)
}
className="h-7 text-xs"
>
{isAllSelected ? "Deselect All" : "Select All"}
</Button>
)}
</div>
<Select
label={label}
placeholder={placeholder}
selectionMode="multiple"
selectedKeys={new Set(value || [])}
onSelectionChange={(keys) => {
const selectedArray = Array.from(keys);
onChange(selectedArray);
}}
onBlur={onBlur}
variant="bordered"
labelPlacement="inside"
isRequired={false}
isInvalid={isInvalid}
classNames={{
trigger: "min-h-12",
popoverContent: "dark:bg-gray-800",
listboxWrapper: "max-h-[300px] dark:bg-gray-800",
listbox: "gap-0",
label:
"tracking-tight font-light !text-default-500 text-xs !z-0",
value: "text-default-500 text-small dark:text-gray-300",
}}
renderValue={(items) => {
if (items.length === 0) {
return (
<span className="text-default-500">{placeholder}</span>
);
}
if (items.length === 1) {
const provider = providers.find(
(p) => p.id === items[0].key,
);
if (provider) {
const displayName =
provider.attributes.alias || provider.attributes.uid;
return (
<div className="flex items-center gap-2">
<span className="truncate">{displayName}</span>
</div>
);
}
}
return (
<span className="text-small">
{items.length} provider{items.length !== 1 ? "s" : ""}{" "}
selected
</span>
);
}}
>
{sortedProviders.map((provider) => {
const providerType = provider.attributes.provider;
const displayName =
provider.attributes.alias || provider.attributes.uid;
const typeLabel = providerTypeLabels[providerType];
return (
<SelectItem
key={provider.id}
textValue={`${displayName} ${typeLabel}`}
className="py-2"
>
<div className="flex w-full items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-small font-medium">
{displayName}
</div>
<div className="truncate text-tiny text-default-500">
{typeLabel}
</div>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${
provider.attributes.connection.connected
? "bg-success"
: "bg-danger"
}`}
title={
provider.attributes.connection.connected
? "Connected"
: "Disconnected"
}
/>
</div>
</div>
</SelectItem>
);
})}
</Select>
</div>
</FormControl>
{showFormMessage && (
<FormMessage className="max-w-full text-xs text-system-error dark:text-system-error" />
)}
</>
);
}}
/>
);
};

View File

@@ -11,7 +11,7 @@ interface CredentialsRoleHelperProps {
cloudformationQuickLink: string;
terraform: string;
};
type?: "providers" | "s3-integration";
type?: "providers" | "integrations";
}
export const CredentialsRoleHelper = ({
@@ -24,7 +24,7 @@ export const CredentialsRoleHelper = ({
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
A <strong>read-only IAM role</strong> must be manually created
{type === "s3-integration" ? " or updated" : ""}.
{type === "integrations" ? " or updated" : ""}.
</p>
<CustomButton

View File

@@ -22,7 +22,7 @@ export const AWSRoleCredentialsForm = ({
cloudformationQuickLink: string;
terraform: string;
};
type?: "providers" | "s3-integration";
type?: "providers" | "integrations";
}) => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const defaultCredentialsType = isCloudEnv
@@ -142,9 +142,6 @@ export const AWSRoleCredentialsForm = ({
isInvalid={
!!control._formState.errors[
ProviderCredentialFields.AWS_SECRET_ACCESS_KEY
] ||
!!control._formState.errors[
ProviderCredentialFields.AWS_ACCESS_KEY_ID
]
}
/>

View File

@@ -1,3 +1,5 @@
import { IntegrationType } from "../types/integrations";
export const getProviderHelpText = (provider: string) => {
switch (provider) {
case "aws":
@@ -41,12 +43,46 @@ export const getProviderHelpText = (provider: string) => {
export const getAWSCredentialsTemplateLinks = (
externalId: string,
bucketName?: string,
) => {
integrationType?: IntegrationType,
): {
cloudformation: string;
terraform: string;
cloudformationQuickLink: string;
} => {
let links = {};
if (integrationType === undefined) {
links = {
cloudformation:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml",
terraform:
"https://github.com/prowler-cloud/prowler/tree/master/permissions/templates/terraform",
};
}
if (integrationType === "aws_security_hub") {
links = {
cloudformation:
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/",
terraform:
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/#terraform",
};
}
if (integrationType === "amazon_s3") {
links = {
cloudformation:
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/",
terraform:
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-s3-integration/#terraform",
};
}
return {
cloudformation:
"https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml",
...(links as {
cloudformation: string;
terraform: string;
}),
cloudformationQuickLink: `https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler&param_ExternalId=${externalId}${bucketName ? `&param_EnableS3Integration=true&param_S3IntegrationBucketName=${bucketName}` : ""}`,
terraform:
"https://github.com/prowler-cloud/prowler/tree/master/permissions/templates/terraform",
};
};

View File

@@ -0,0 +1,161 @@
import {
pollConnectionTestStatus,
testIntegrationConnection,
} from "@/actions/integrations";
// Integration configuration type
export interface IntegrationMessages {
testingMessage: string;
successMessage: string;
errorMessage: string;
}
// Configuration map for integration-specific messages
const INTEGRATION_CONFIG: Record<string, IntegrationMessages> = {
"amazon-s3": {
testingMessage: "Testing connection to Amazon S3 bucket...",
successMessage: "Successfully connected to Amazon S3 bucket.",
errorMessage: "Failed to connect to Amazon S3 bucket.",
},
"aws-security-hub": {
testingMessage: "Testing connection to AWS Security Hub...",
successMessage: "Successfully connected to AWS Security Hub.",
errorMessage: "Failed to connect to AWS Security Hub.",
},
// Legacy mappings for backward compatibility
s3: {
testingMessage: "Testing connection to Amazon S3 bucket...",
successMessage: "Successfully connected to Amazon S3 bucket.",
errorMessage: "Failed to connect to Amazon S3 bucket.",
},
security_hub: {
testingMessage: "Testing connection to AWS Security Hub...",
successMessage: "Successfully connected to AWS Security Hub.",
errorMessage: "Failed to connect to AWS Security Hub.",
},
// Add new integrations here as needed
};
// Helper function to register new integration types
export const registerIntegrationType = (
type: string,
messages: IntegrationMessages,
): void => {
INTEGRATION_CONFIG[type] = messages;
};
// Helper function to get supported integration types
export const getSupportedIntegrationTypes = (): string[] => {
return Object.keys(INTEGRATION_CONFIG);
};
interface TestConnectionOptions {
integrationId: string;
integrationType: string;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
onStart?: () => void;
onComplete?: () => void;
}
export const runTestConnection = async ({
integrationId,
integrationType,
onSuccess,
onError,
onStart,
onComplete,
}: TestConnectionOptions) => {
try {
// Start the test without waiting for completion
const result = await testIntegrationConnection(integrationId, false);
if (!result || (!result.success && !result.error)) {
onError?.("Connection test could not be started. Please try again.");
onComplete?.();
return;
}
if (result.error) {
onError?.(result.error);
onComplete?.();
return;
}
if (!result.taskId) {
onError?.("Failed to start connection test. No task ID received.");
onComplete?.();
return;
}
// Notify that test has started
onStart?.();
// Poll for the test completion
const pollResult = await pollConnectionTestStatus(result.taskId);
if (pollResult.success) {
const config = INTEGRATION_CONFIG[integrationType];
const defaultMessage =
config?.successMessage ||
`Successfully connected to ${integrationType}.`;
onSuccess?.(pollResult.message || defaultMessage);
} else {
const config = INTEGRATION_CONFIG[integrationType];
const defaultError =
config?.errorMessage || `Failed to connect to ${integrationType}.`;
onError?.(pollResult.error || defaultError);
}
} catch (error) {
onError?.(
"Failed to start connection test. You can try manually using the Test Connection button.",
);
} finally {
onComplete?.();
}
};
export const triggerTestConnectionWithDelay = (
integrationId: string | undefined,
shouldTestConnection: boolean | undefined,
integrationType: string,
toast: any,
delay = 200,
onComplete?: () => void,
) => {
if (!integrationId || !shouldTestConnection) {
onComplete?.();
return;
}
setTimeout(() => {
runTestConnection({
integrationId,
integrationType,
onStart: () => {
const config = INTEGRATION_CONFIG[integrationType];
const description =
config?.testingMessage ||
`Testing connection to ${integrationType}...`;
toast({
title: "Connection test started!",
description,
});
},
onSuccess: (message) => {
toast({
title: "Connection test successful!",
description: message,
});
},
onError: (message) => {
toast({
variant: "destructive",
title: "Connection test failed",
description: message,
});
},
onComplete,
});
}, delay);
};

View File

@@ -32,49 +32,92 @@ export interface IntegrationProps {
links: { self: string };
}
const baseS3IntegrationSchema = z.object({
integration_type: z.literal("amazon_s3"),
bucket_name: z.string().min(1, "Bucket name is required"),
output_directory: z.string().min(1, "Output directory is required"),
providers: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
// AWS Credentials fields compatible with AWSCredentialsRole
// Shared AWS credential fields schema
const awsCredentialFields = {
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),
aws_access_key_id: z.string().optional(),
aws_secret_access_key: z.string().optional(),
aws_session_token: z.string().optional(),
// IAM Role fields
role_arn: z.string().optional(),
external_id: z.string().optional(),
role_session_name: z.string().optional(),
session_duration: z.string().optional(),
// Hidden field to track if role section is shown
show_role_section: z.boolean().optional(),
});
};
const s3IntegrationValidation = (data: any, ctx: z.RefinementCtx) => {
// If using access-secret-key, require AWS credentials (for create form)
if (data.credentials_type === "access-secret-key") {
// Shared validation helper for AWS credentials (create mode)
const validateAwsCredentialsCreate = (
data: any,
ctx: z.RefinementCtx,
requireCredentials: boolean = true,
) => {
if (data.credentials_type === "access-secret-key" && requireCredentials) {
if (!data.aws_access_key_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Access Key ID is required when using access and secret key",
message: "AWS Access Key ID is required when using access and secret key",
path: ["aws_access_key_id"],
});
}
if (!data.aws_secret_access_key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Secret Access Key is required when using access and secret key",
message: "AWS Secret Access Key is required when using access and secret key",
path: ["aws_secret_access_key"],
});
}
}
};
// When role section is shown, both role_arn and external_id are required
if (data.show_role_section === true) {
// Shared validation helper for AWS credentials (edit mode)
const validateAwsCredentialsEdit = (data: any, ctx: z.RefinementCtx) => {
if (data.credentials_type === "access-secret-key") {
const hasAccessKey = !!data.aws_access_key_id;
const hasSecretKey = !!data.aws_secret_access_key;
if (hasAccessKey && !hasSecretKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS Secret Access Key is required when providing Access Key ID",
path: ["aws_secret_access_key"],
});
}
if (hasSecretKey && !hasAccessKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS Access Key ID is required when providing Secret Access Key",
path: ["aws_access_key_id"],
});
}
}
};
// Shared validation helper for IAM Role fields
const validateIamRole = (
data: any,
ctx: z.RefinementCtx,
checkShowSection: boolean = true,
) => {
const shouldValidate = checkShowSection ? data.show_role_section === true : true;
if (shouldValidate && data.role_arn) {
if (data.role_arn.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Role ARN is required",
path: ["role_arn"],
});
} else if (!data.external_id || data.external_id.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "External ID is required when using Role ARN",
path: ["external_id"],
});
}
}
if (checkShowSection && data.show_role_section === true) {
if (!data.role_arn || data.role_arn.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -92,49 +135,15 @@ const s3IntegrationValidation = (data: any, ctx: z.RefinementCtx) => {
}
};
const s3IntegrationEditValidation = (data: any, ctx: z.RefinementCtx) => {
// If using access-secret-key, and credentials are provided, require both
if (data.credentials_type === "access-secret-key") {
const hasAccessKey = !!data.aws_access_key_id;
const hasSecretKey = !!data.aws_secret_access_key;
if (hasAccessKey && !hasSecretKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Secret Access Key is required when providing Access Key ID",
path: ["aws_secret_access_key"],
});
}
if (hasSecretKey && !hasAccessKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AWS Access Key ID is required when providing Secret Access Key",
path: ["aws_access_key_id"],
});
}
}
// When role section is shown (editing credentials with role), both fields are required
if (data.show_role_section === true) {
if (data.role_arn && data.role_arn.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Role ARN is required",
path: ["role_arn"],
});
}
if (data.external_id && data.external_id.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "External ID is required",
path: ["external_id"],
});
}
}
};
// S3 Integration Schemas
const baseS3IntegrationSchema = z.object({
integration_type: z.literal("amazon_s3"),
bucket_name: z.string().min(1, "Bucket name is required"),
output_directory: z.string().min(1, "Output directory is required"),
providers: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
...awsCredentialFields,
});
export const s3IntegrationFormSchema = baseS3IntegrationSchema
.extend({
@@ -143,18 +152,67 @@ export const s3IntegrationFormSchema = baseS3IntegrationSchema
.enum(["aws-sdk-default", "access-secret-key"])
.default("aws-sdk-default"),
})
.superRefine(s3IntegrationValidation);
.superRefine((data, ctx) => {
validateAwsCredentialsCreate(data, ctx);
validateIamRole(data, ctx);
});
export const editS3IntegrationFormSchema = baseS3IntegrationSchema
.extend({
bucket_name: z.string().min(1, "Bucket name is required").optional(),
output_directory: z
.string()
.min(1, "Output directory is required")
.optional(),
output_directory: z.string().min(1, "Output directory is required").optional(),
providers: z.array(z.string()).optional(),
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(),
})
.superRefine((data, ctx) => {
validateAwsCredentialsEdit(data, ctx);
validateIamRole(data, ctx);
});
// Security Hub Integration Schemas
const baseSecurityHubIntegrationSchema = z.object({
integration_type: z.literal("aws_security_hub"),
provider_id: z.string().min(1, "AWS Provider is required"),
send_only_fails: z.boolean().optional(),
archive_previous_findings: z.boolean().optional(),
use_custom_credentials: z.boolean().optional(),
enabled: z.boolean().optional(),
...awsCredentialFields,
});
export const securityHubIntegrationFormSchema = baseSecurityHubIntegrationSchema
.extend({
enabled: z.boolean().default(true),
send_only_fails: z.boolean().default(true),
archive_previous_findings: z.boolean().default(false),
use_custom_credentials: z.boolean().default(false),
credentials_type: z
.enum(["aws-sdk-default", "access-secret-key"])
.optional(),
.default("aws-sdk-default"),
})
.superRefine(s3IntegrationEditValidation);
.superRefine((data, ctx) => {
if (data.use_custom_credentials) {
validateAwsCredentialsCreate(data, ctx);
validateIamRole(data, ctx);
}
// Always validate role if role_arn is provided
if (!data.use_custom_credentials && data.role_arn) {
validateIamRole(data, ctx, false);
}
});
export const editSecurityHubIntegrationFormSchema = baseSecurityHubIntegrationSchema
.extend({
provider_id: z.string().optional(),
send_only_fails: z.boolean().optional(),
archive_previous_findings: z.boolean().optional(),
use_custom_credentials: z.boolean().optional(),
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(),
})
.superRefine((data, ctx) => {
if (data.use_custom_credentials !== false) {
validateAwsCredentialsEdit(data, ctx);
}
// Always validate role if role_arn is provided
validateIamRole(data, ctx, false);
});