mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
feat(ui): S3 integration (#8391)
This commit is contained in:
1 parent
eb1c1791e4
commit
9da5fb67c3
29 files changed
+2117
-177
No files matched your search
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
import { S3IntegrationCardSkeleton } from "./skeleton-s3-integration-card";
|
||||
|
||||
interface S3IntegrationCardProps {
|
||||
integrations?: IntegrationProps[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const S3IntegrationCard = ({
|
||||
integrations = [],
|
||||
isLoading = false,
|
||||
}: S3IntegrationCardProps) => {
|
||||
const s3Integrations = integrations.filter(
|
||||
(integration) => integration.attributes.integration_type === "amazon_s3",
|
||||
);
|
||||
|
||||
const isConfigured = s3Integrations.length > 0;
|
||||
const connectedCount = s3Integrations.filter(
|
||||
(integration) => integration.attributes.connected,
|
||||
).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<S3IntegrationCardSkeleton
|
||||
variant="main"
|
||||
count={s3Integrations.length || 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="dark:bg-gray-800">
|
||||
<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 text-gray-900 dark:text-gray-100">
|
||||
Amazon S3
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-300">
|
||||
Export security findings to Amazon S3 buckets.
|
||||
</p>
|
||||
{/* Todo: add real DOCS, use CustomLink when available */}
|
||||
<Link
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary"
|
||||
aria-label="Learn more about S3 integration"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured && (
|
||||
<Chip
|
||||
size="sm"
|
||||
color={connectedCount > 0 ? "success" : "warning"}
|
||||
variant="flat"
|
||||
>
|
||||
{connectedCount} / {s3Integrations.length} connected
|
||||
</Chip>
|
||||
)}
|
||||
<CustomButton
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<SettingsIcon size={14} />}
|
||||
asLink="/integrations/s3"
|
||||
ariaLabel={
|
||||
isConfigured
|
||||
? "Manage S3 integrations"
|
||||
: "Configure S3 integration"
|
||||
}
|
||||
>
|
||||
{isConfigured ? "Manage" : "Configure"}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{s3Integrations.map((integration) => (
|
||||
<div
|
||||
key={integration.id}
|
||||
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{integration.attributes.configuration.bucket_name ||
|
||||
"Unknown Bucket"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-300">
|
||||
Output directory:{" "}
|
||||
{integration.attributes.configuration
|
||||
.output_directory ||
|
||||
integration.attributes.configuration.path ||
|
||||
"/"}
|
||||
</span>
|
||||
</div>
|
||||
<Chip
|
||||
size="sm"
|
||||
color={
|
||||
integration.attributes.connected ? "success" : "danger"
|
||||
}
|
||||
variant="dot"
|
||||
>
|
||||
{integration.attributes.connected
|
||||
? "Connected"
|
||||
: "Disconnected"}
|
||||
</Chip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Status: </span>
|
||||
<span className="text-gray-500">Not configured</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Export your security findings to Amazon S3 buckets
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Divider } from "@nextui-org/react";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
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 { 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";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { FormButtons } from "@/components/ui/form/form-buttons";
|
||||
import { getAWSCredentialsTemplateBucketLinks } from "@/lib";
|
||||
import { AWSCredentialsRole } from "@/types";
|
||||
import {
|
||||
editS3IntegrationFormSchema,
|
||||
IntegrationProps,
|
||||
s3IntegrationFormSchema,
|
||||
} from "@/types/integrations";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
interface S3IntegrationFormProps {
|
||||
integration?: IntegrationProps | null;
|
||||
providers: ProviderProps[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
editMode?: "configuration" | "credentials" | null; // null means creating new
|
||||
}
|
||||
|
||||
export const S3IntegrationForm = ({
|
||||
integration,
|
||||
providers,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
editMode = null,
|
||||
}: S3IntegrationFormProps) => {
|
||||
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";
|
||||
|
||||
// Create the form with updated schema and default values
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
// For credentials editing, use creation schema (all fields required)
|
||||
// For config editing, use edit schema (partial updates allowed)
|
||||
// For creation, use creation schema
|
||||
isEditingCredentials || isCreating
|
||||
? s3IntegrationFormSchema
|
||||
: editS3IntegrationFormSchema,
|
||||
),
|
||||
defaultValues: {
|
||||
integration_type: "amazon_s3" as const,
|
||||
bucket_name: integration?.attributes.configuration.bucket_name || "",
|
||||
output_directory:
|
||||
integration?.attributes.configuration.output_directory || "",
|
||||
providers:
|
||||
integration?.relationships?.providers?.data?.map((p) => p.id) || [],
|
||||
credentials_type: "aws-sdk-default" as const,
|
||||
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 || "",
|
||||
// External ID always defaults to tenantId, even when editing credentials
|
||||
external_id:
|
||||
integration?.attributes.configuration.credentials?.external_id ||
|
||||
session?.tenantId ||
|
||||
"",
|
||||
role_session_name: "",
|
||||
session_duration: "",
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const handleNext = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// If we're in single-step edit mode, don't advance
|
||||
if (isEditingConfig || isEditingCredentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate current step fields for creation flow
|
||||
const stepFields =
|
||||
currentStep === 0
|
||||
? (["bucket_name", "output_directory", "providers"] as const)
|
||||
: // Step 1: No required fields since role_arn and external_id are optional
|
||||
[];
|
||||
|
||||
const isValid = stepFields.length === 0 || (await form.trigger(stepFields));
|
||||
|
||||
if (isValid) {
|
||||
setCurrentStep(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep(0);
|
||||
};
|
||||
|
||||
// Helper function to build credentials object
|
||||
const buildCredentials = (values: any) => {
|
||||
const credentials: any = {};
|
||||
|
||||
// Only include role-related fields if role_arn is provided
|
||||
if (values.role_arn && values.role_arn.trim() !== "") {
|
||||
credentials.role_arn = values.role_arn;
|
||||
credentials.external_id = values.external_id;
|
||||
|
||||
// Optional role fields
|
||||
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;
|
||||
}
|
||||
|
||||
// Add static credentials if using access-secret-key type
|
||||
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, isPartial = false) => {
|
||||
const configuration: any = {};
|
||||
|
||||
// For creation mode, include all fields
|
||||
if (!isPartial) {
|
||||
configuration.bucket_name = values.bucket_name;
|
||||
configuration.output_directory = values.output_directory;
|
||||
} else {
|
||||
// For edit mode, only include fields that have actually changed
|
||||
const originalBucketName =
|
||||
integration?.attributes.configuration.bucket_name || "";
|
||||
const originalOutputDirectory =
|
||||
integration?.attributes.configuration.output_directory || "";
|
||||
|
||||
// Only include bucket_name if it has changed
|
||||
if (values.bucket_name && values.bucket_name !== originalBucketName) {
|
||||
configuration.bucket_name = values.bucket_name;
|
||||
}
|
||||
|
||||
// Only include output_directory if it has changed
|
||||
if (
|
||||
values.output_directory &&
|
||||
values.output_directory !== originalOutputDirectory
|
||||
) {
|
||||
configuration.output_directory = values.output_directory;
|
||||
}
|
||||
}
|
||||
|
||||
return configuration;
|
||||
};
|
||||
|
||||
// Helper function to build FormData based on edit mode
|
||||
const buildFormData = (values: any) => {
|
||||
const formData = new FormData();
|
||||
formData.append("integration_type", values.integration_type);
|
||||
|
||||
if (isEditingConfig) {
|
||||
const configuration = buildConfiguration(values, true);
|
||||
if (Object.keys(configuration).length > 0) {
|
||||
formData.append("configuration", JSON.stringify(configuration));
|
||||
}
|
||||
// Always send providers array, even if empty, to update relationships
|
||||
formData.append("providers", JSON.stringify(values.providers || []));
|
||||
} else if (isEditingCredentials) {
|
||||
const credentials = buildCredentials(values);
|
||||
formData.append("credentials", JSON.stringify(credentials));
|
||||
} else {
|
||||
// Creation mode - send everything
|
||||
const configuration = buildConfiguration(values);
|
||||
const credentials = buildCredentials(values);
|
||||
|
||||
formData.append("configuration", JSON.stringify(configuration));
|
||||
formData.append("credentials", JSON.stringify(credentials));
|
||||
formData.append("providers", JSON.stringify(values.providers));
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
const formData = buildFormData(values);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (isEditing && integration) {
|
||||
result = await updateIntegration(integration.id, formData);
|
||||
} else {
|
||||
result = await createIntegration(formData);
|
||||
}
|
||||
|
||||
if ("success" in result) {
|
||||
toast({
|
||||
title: "Success!",
|
||||
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();
|
||||
} else if ("error" in result) {
|
||||
const errorMessage = result.error;
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "S3 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 editing credentials, show only credentials form
|
||||
if (isEditingCredentials || currentStep === 1) {
|
||||
const bucketName = form.getValues("bucket_name") || "";
|
||||
const externalId =
|
||||
form.getValues("external_id") || session?.tenantId || "";
|
||||
const templateLinks = getAWSCredentialsTemplateBucketLinks(
|
||||
bucketName,
|
||||
externalId,
|
||||
);
|
||||
|
||||
return (
|
||||
<AWSRoleCredentialsForm
|
||||
control={form.control as unknown as Control<AWSCredentialsRole>}
|
||||
setValue={form.setValue as any}
|
||||
externalId={externalId}
|
||||
templateLinks={templateLinks}
|
||||
type="s3-integration"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show configuration step (step 0 or editing configuration)
|
||||
if (isEditingConfig || currentStep === 0) {
|
||||
return (
|
||||
<>
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-4">
|
||||
<ProviderSelector
|
||||
control={form.control}
|
||||
name="providers"
|
||||
providers={providers}
|
||||
label="Cloud Providers"
|
||||
placeholder="Select providers to integrate with"
|
||||
isInvalid={!!form.formState.errors.providers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* S3 Configuration */}
|
||||
<div className="space-y-4">
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="bucket_name"
|
||||
type="text"
|
||||
label="Bucket name"
|
||||
labelPlacement="inside"
|
||||
placeholder="my-security-findings-bucket"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.bucket_name}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="output_directory"
|
||||
type="text"
|
||||
label="Output directory"
|
||||
labelPlacement="inside"
|
||||
placeholder="/prowler-findings/"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!form.formState.errors.output_directory}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderStepButtons = () => {
|
||||
// Single edit mode (configuration or credentials)
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Creation flow - step 0
|
||||
if (currentStep === 0) {
|
||||
return (
|
||||
<FormButtons
|
||||
setIsOpen={() => {}}
|
||||
onCancel={onCancel}
|
||||
submitText="Next"
|
||||
cancelText="Cancel"
|
||||
loadingText="Processing..."
|
||||
isDisabled={isLoading}
|
||||
rightIcon={<ArrowRightIcon size={24} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Creation flow - step 1 (final step)
|
||||
return (
|
||||
<FormButtons
|
||||
setIsOpen={() => {}}
|
||||
onCancel={handleBack}
|
||||
submitText="Create Integration"
|
||||
cancelText="Back"
|
||||
loadingText="Creating..."
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={
|
||||
// For edit modes, always submit
|
||||
isEditingConfig || isEditingCredentials
|
||||
? form.handleSubmit(onSubmit)
|
||||
: // For creation flow, handle step logic
|
||||
currentStep === 0
|
||||
? handleNext
|
||||
: form.handleSubmit(onSubmit)
|
||||
}
|
||||
className="flex flex-col space-y-6"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="flex items-center gap-2 text-sm text-default-500">
|
||||
Need help connecting your AWS account?
|
||||
<CustomLink
|
||||
href="https://goto.prowler.com/provider-aws"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
>
|
||||
Read the docs
|
||||
</CustomLink>
|
||||
</p>
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
{renderStepButtons()}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody, CardHeader, Chip } from "@nextui-org/react";
|
||||
import {
|
||||
PlusIcon,
|
||||
Power,
|
||||
SettingsIcon,
|
||||
TestTube,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
deleteIntegration,
|
||||
testIntegrationConnection,
|
||||
updateIntegration,
|
||||
} from "@/actions/integrations";
|
||||
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
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[];
|
||||
providers: ProviderProps[];
|
||||
}
|
||||
|
||||
export const S3IntegrationsManager = ({
|
||||
integrations,
|
||||
providers,
|
||||
}: S3IntegrationsManagerProps) => {
|
||||
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); // Creation mode
|
||||
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);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "S3 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 S3 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.`,
|
||||
});
|
||||
} 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 = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingIntegration(null);
|
||||
setEditMode(null);
|
||||
setIsOperationLoading(true);
|
||||
// Reset loading state after a short delay to show the skeleton briefly
|
||||
setTimeout(() => {
|
||||
setIsOperationLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Delete S3 Integration"
|
||||
description="This action cannot be undone. This will permanently delete your S3 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 S3 Integration"
|
||||
: "Add S3 Integration"
|
||||
}
|
||||
>
|
||||
<S3IntegrationForm
|
||||
integration={editingIntegration}
|
||||
providers={providers}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={handleModalClose}
|
||||
editMode={editMode}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header with Add Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Configured S3 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>
|
||||
|
||||
{/* Integrations List */}
|
||||
{isOperationLoading ? (
|
||||
<S3IntegrationCardSkeleton
|
||||
variant="manager"
|
||||
count={integrations.length || 1}
|
||||
/>
|
||||
) : 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>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<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.connection_last_checked_at && (
|
||||
<p>
|
||||
<span className="font-medium">Last checked:</span>{" "}
|
||||
{new Date(
|
||||
integration.attributes.connection_last_checked_at,
|
||||
).toLocaleDateString()}
|
||||
</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>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user