feat(ui): S3 integration (#8391)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2025-08-04 11:43:14 +02:00
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>
);
};