mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui): Security Hub (#8552)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,3 +75,6 @@ node_modules
|
||||
|
||||
# Persistent data
|
||||
_data/
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
deleteIntegration,
|
||||
getIntegration,
|
||||
getIntegrations,
|
||||
pollConnectionTestStatus,
|
||||
testIntegrationConnection,
|
||||
updateIntegration,
|
||||
} from "./integrations";
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
};
|
||||
|
||||
90
ui/app/(prowler)/integrations/aws-security-hub/page.tsx
Normal file
90
ui/app/(prowler)/integrations/aws-security-hub/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
3
ui/components/integrations/shared/index.ts
Normal file
3
ui/components/integrations/shared/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { IntegrationActionButtons } from "./integration-action-buttons";
|
||||
export { IntegrationCardHeader } from "./integration-card-header";
|
||||
export { IntegrationSkeleton } from "./integration-skeleton";
|
||||
100
ui/components/integrations/shared/integration-action-buttons.tsx
Normal file
100
ui/components/integrations/shared/integration-action-buttons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
96
ui/components/integrations/shared/integration-skeleton.tsx
Normal file
96
ui/components/integrations/shared/integration-skeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
294
ui/components/providers/enhanced-provider-selector.tsx
Normal file
294
ui/components/providers/enhanced-provider-selector.tsx
Normal 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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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¶m_ExternalId=${externalId}${bucketName ? `¶m_EnableS3Integration=true¶m_S3IntegrationBucketName=${bucketName}` : ""}`,
|
||||
terraform:
|
||||
"https://github.com/prowler-cloud/prowler/tree/master/permissions/templates/terraform",
|
||||
};
|
||||
};
|
||||
|
||||
161
ui/lib/integrations/test-connection-helper.ts
Normal file
161
ui/lib/integrations/test-connection-helper.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user