mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat(ui): add SNS integration UI components
Add complete UI implementation for Amazon SNS integration with comprehensive management interface for sending email alerts. Features: - SNS integration form with topic ARN configuration - AWS credentials support (access keys, role ARN, session tokens) - Custom credentials toggle for tenant/integration-level auth - CRUD operations (create, edit, delete, toggle enable/disable) - Connection testing with real-time feedback - Integration manager with pagination support - Filter support (severity, provider, region, resource name/tags) UI Components: - sns-integration-form.tsx: Form component with AWS credentials config - sns-integration-card.tsx: Card for main integrations page - sns-integrations-manager.tsx: Manager with list view and actions - sns/page.tsx: Dedicated SNS integrations management page Updates: - Add SNS schemas to ui/types/integrations.ts with Zod validation - Export SNS components from ui/components/integrations/index.ts - Add SNS card to main integrations page - Fix IntegrationType to use const-based approach per AGENTS.md line 13 - Fix Jira email validation to use correct Zod v4 syntax (z.email) - Use proper TypeScript types (removed any types per AGENTS.md) - Fix session_duration to use z.coerce.number() for string-to-number conversion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
JiraIntegrationCard,
|
||||
S3IntegrationCard,
|
||||
SecurityHubIntegrationCard,
|
||||
SNSIntegrationCard,
|
||||
SsoLinkCard,
|
||||
} from "@/components/integrations";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
@@ -25,6 +26,9 @@ export default async function Integrations() {
|
||||
{/* AWS Security Hub Integration */}
|
||||
<SecurityHubIntegrationCard />
|
||||
|
||||
{/* Amazon SNS Integration */}
|
||||
<SNSIntegrationCard />
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraIntegrationCard />
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { SNSIntegrationsManager } from "@/components/integrations/sns/sns-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
|
||||
interface SNSIntegrationsProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function SNSIntegrations({
|
||||
searchParams,
|
||||
}: SNSIntegrationsProps) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const page = parseInt(resolvedSearchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(
|
||||
resolvedSearchParams.pageSize?.toString() || "10",
|
||||
10,
|
||||
);
|
||||
const sort = resolvedSearchParams.sort?.toString();
|
||||
|
||||
// Extract all filter parameters
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(resolvedSearchParams).filter(([key]) =>
|
||||
key.startsWith("filter["),
|
||||
),
|
||||
);
|
||||
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
urlSearchParams.set("filter[integration_type]", "sns");
|
||||
urlSearchParams.set("page[number]", page.toString());
|
||||
urlSearchParams.set("page[size]", pageSize.toString());
|
||||
|
||||
if (sort) {
|
||||
urlSearchParams.set("sort", sort);
|
||||
}
|
||||
|
||||
// Add any additional filters
|
||||
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] = await Promise.all([getIntegrations(urlSearchParams)]);
|
||||
|
||||
const snsIntegrations = integrations?.data || [];
|
||||
const metadata = integrations?.meta;
|
||||
|
||||
return (
|
||||
<ContentLayout title="Amazon SNS">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure Amazon SNS integration to send email alerts for security
|
||||
findings via SNS topics.
|
||||
</p>
|
||||
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader className="mb-0 pb-3">
|
||||
<CardTitle>Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ul className="grid grid-cols-1 gap-2 text-sm text-gray-600 md:grid-cols-2 dark:text-gray-300">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Email alert notifications
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Multi-Cloud support
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Severity-based filtering
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="bg-button-primary h-1.5 w-1.5 rounded-full" />
|
||||
Region and tag filtering
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SNSIntegrationsManager
|
||||
integrations={snsIntegrations}
|
||||
metadata={metadata}
|
||||
/>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -12,4 +12,7 @@ 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";
|
||||
export * from "./sns/sns-integration-card";
|
||||
export * from "./sns/sns-integration-form";
|
||||
export * from "./sns/sns-integrations-manager";
|
||||
export * from "./sso/sso-link-card";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { MailIcon, SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
|
||||
export const SNSIntegrationCard = () => {
|
||||
return (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<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">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||
<MailIcon
|
||||
size={24}
|
||||
className="text-orange-600 dark:text-orange-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Amazon SNS
|
||||
</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 email alerts for security findings via SNS.
|
||||
</p>
|
||||
<CustomLink
|
||||
href="https://docs.aws.amazon.com/sns/latest/dg/welcome.html"
|
||||
aria-label="Learn more about Amazon SNS integration"
|
||||
size="xs"
|
||||
isExternal
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/sns">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure Amazon SNS topics to send formatted email alerts for
|
||||
security findings with support for filtering by severity, provider,
|
||||
region, and resource tags.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Radio, RadioGroup } from "@heroui/radio";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
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, FormControl, FormField } from "@/components/ui/form";
|
||||
import { FormButtons } from "@/components/ui/form/form-buttons";
|
||||
import { getAWSCredentialsTemplateLinks } from "@/lib";
|
||||
import {
|
||||
editSNSIntegrationFormSchema,
|
||||
IntegrationProps,
|
||||
snsIntegrationFormSchema,
|
||||
type SNSCredentialsPayload,
|
||||
} from "@/types/integrations";
|
||||
|
||||
interface SNSIntegrationFormProps {
|
||||
integration?: IntegrationProps | null;
|
||||
onSuccess: (integrationId?: string, shouldTestConnection?: boolean) => void;
|
||||
onCancel: () => void;
|
||||
editMode?: "configuration" | "credentials" | null;
|
||||
}
|
||||
|
||||
export const SNSIntegrationForm = ({
|
||||
integration,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
editMode = null,
|
||||
}: SNSIntegrationFormProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const isEditing = !!integration;
|
||||
const isCreating = !isEditing;
|
||||
const isEditingConfig = editMode === "configuration";
|
||||
const isEditingCredentials = editMode === "credentials";
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
isEditingCredentials || isCreating
|
||||
? snsIntegrationFormSchema
|
||||
: editSNSIntegrationFormSchema,
|
||||
),
|
||||
defaultValues: {
|
||||
integration_type: "sns" as const,
|
||||
topic_arn: integration?.attributes.configuration.topic_arn || "",
|
||||
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 buildCredentials = (
|
||||
values: z.infer<typeof snsIntegrationFormSchema>,
|
||||
): SNSCredentialsPayload => {
|
||||
const credentials: SNSCredentialsPayload = {};
|
||||
|
||||
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 onSubmit = async (values: z.infer<typeof snsIntegrationFormSchema>) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add integration type
|
||||
formData.append("integration_type", "sns");
|
||||
|
||||
// Configuration (topic ARN)
|
||||
if (!isEditingCredentials) {
|
||||
const configuration = {
|
||||
topic_arn: values.topic_arn,
|
||||
};
|
||||
formData.append("configuration", JSON.stringify(configuration));
|
||||
}
|
||||
|
||||
// Credentials
|
||||
if (!isEditingConfig) {
|
||||
const credentials: SNSCredentialsPayload =
|
||||
useCustomCredentials || isCreating || isEditingCredentials
|
||||
? buildCredentials(values)
|
||||
: {};
|
||||
|
||||
if (Object.keys(credentials).length > 0) {
|
||||
formData.append("credentials", JSON.stringify(credentials));
|
||||
}
|
||||
}
|
||||
|
||||
// For creation, we need to provide providers (empty array for SNS)
|
||||
if (isCreating) {
|
||||
formData.append("providers", JSON.stringify([]));
|
||||
formData.append("enabled", JSON.stringify(values.enabled));
|
||||
}
|
||||
|
||||
let result;
|
||||
if (isEditing) {
|
||||
result = await updateIntegration(integration.id, formData);
|
||||
} else {
|
||||
result = await createIntegration(formData);
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
toast({
|
||||
title: isEditing
|
||||
? "SNS Integration Updated"
|
||||
: "SNS Integration Created",
|
||||
description: isEditing
|
||||
? "Your SNS integration has been updated successfully."
|
||||
: "Your SNS integration has been created successfully.",
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess(result.data.id, !isEditing);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: result.error || "Failed to save SNS integration.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SNS Integration form error:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Configuration Section */}
|
||||
{!isEditingCredentials && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Configuration</h3>
|
||||
<p className="text-small text-default-500">
|
||||
Configure your Amazon SNS topic for sending email alerts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic_arn"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<CustomInput
|
||||
label="SNS Topic ARN"
|
||||
labelPlacement="outside"
|
||||
placeholder="arn:aws:sns:us-east-1:123456789012:prowler-alerts"
|
||||
isRequired
|
||||
isDisabled={isEditingCredentials}
|
||||
errorMessage={form.formState.errors.topic_arn?.message}
|
||||
description="The Amazon Resource Name (ARN) of the SNS topic to send alerts to"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isCreating && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
isSelected={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<span className="text-small">Enable integration</span>
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials Section */}
|
||||
{!isEditingConfig && (
|
||||
<div className="space-y-4">
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">AWS Credentials</h3>
|
||||
<p className="text-small text-default-500">
|
||||
Configure AWS credentials to access the SNS topic
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(isCreating || isEditingCredentials) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_custom_credentials"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
isSelected={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<span className="text-small">
|
||||
Use custom AWS credentials
|
||||
</span>
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(useCustomCredentials || isEditingCredentials) && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="credentials_type"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
label="Credentials Type"
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<Radio value="aws-sdk-default">
|
||||
AWS SDK Default Credentials
|
||||
</Radio>
|
||||
<Radio value="access-secret-key">
|
||||
Access Key & Secret Key
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("credentials_type") === "access-secret-key" && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aws_access_key_id"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<CustomInput
|
||||
label="AWS Access Key ID"
|
||||
labelPlacement="outside"
|
||||
placeholder="AKIA..."
|
||||
isRequired
|
||||
errorMessage={
|
||||
form.formState.errors.aws_access_key_id?.message
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aws_secret_access_key"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<CustomInput
|
||||
label="AWS Secret Access Key"
|
||||
labelPlacement="outside"
|
||||
type="password"
|
||||
placeholder="Enter secret access key"
|
||||
isRequired
|
||||
errorMessage={
|
||||
form.formState.errors.aws_secret_access_key
|
||||
?.message
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aws_session_token"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<CustomInput
|
||||
label="AWS Session Token (Optional)"
|
||||
labelPlacement="outside"
|
||||
type="password"
|
||||
placeholder="For temporary credentials"
|
||||
errorMessage={
|
||||
form.formState.errors.aws_session_token?.message
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<AWSRoleCredentialsForm
|
||||
control={form.control}
|
||||
errors={form.formState.errors}
|
||||
showRoleSection={form.watch("show_role_section") || false}
|
||||
setShowRoleSection={(value) =>
|
||||
form.setValue("show_role_section", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!useCustomCredentials && isCreating && (
|
||||
<div className="rounded-lg border border-default-200 bg-default-50 p-4">
|
||||
<p className="text-small text-default-600">
|
||||
The integration will use the default AWS credentials from the
|
||||
provider configuration. Make sure the provider has access to
|
||||
the SNS topic.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-small text-default-500">
|
||||
<p className="mb-2">Need help setting up AWS credentials?</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{getAWSCredentialsTemplateLinks("sns").map((link) => (
|
||||
<CustomLink
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
isExternal
|
||||
showAnchorIcon
|
||||
size="sm"
|
||||
>
|
||||
{link.label}
|
||||
</CustomLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
submitLabel={isEditing ? "Update Integration" : "Create Integration"}
|
||||
cancelLabel="Cancel"
|
||||
isLoading={isLoading}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { MailIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
deleteIntegration,
|
||||
testIntegrationConnection,
|
||||
updateIntegration,
|
||||
} from "@/actions/integrations";
|
||||
import {
|
||||
IntegrationActionButtons,
|
||||
IntegrationCardHeader,
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomAlertModal } 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 { Card, CardContent, CardHeader } from "../../shadcn";
|
||||
import { SNSIntegrationForm } from "./sns-integration-form";
|
||||
|
||||
interface SNSIntegrationsManagerProps {
|
||||
integrations: IntegrationProps[];
|
||||
metadata?: MetaDataProps;
|
||||
}
|
||||
|
||||
export const SNSIntegrationsManager = ({
|
||||
integrations,
|
||||
metadata,
|
||||
}: SNSIntegrationsManagerProps) => {
|
||||
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, "sns");
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "SNS 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 SNS 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,
|
||||
"sns",
|
||||
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,
|
||||
"sns",
|
||||
toast,
|
||||
200,
|
||||
() => {
|
||||
// Clear testing state when server-triggered test completes
|
||||
setIsTesting(null);
|
||||
setIsOperationLoading(false);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Manage SNS Integrations
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure Amazon SNS topics to send email alerts for security
|
||||
findings
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddIntegration}>
|
||||
<PlusIcon size={16} />
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{integrations.length === 0 ? (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<MailIcon size={48} className="mb-4 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
No SNS integrations configured
|
||||
</h3>
|
||||
<p className="mb-4 text-center text-sm text-gray-600 dark:text-gray-300">
|
||||
Add your first SNS integration to start sending email alerts
|
||||
for security findings
|
||||
</p>
|
||||
<Button onClick={handleAddIntegration}>
|
||||
<PlusIcon size={16} />
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{integrations.map((integration) => (
|
||||
<Card key={integration.id} variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
integration={integration}
|
||||
icon={
|
||||
<MailIcon
|
||||
size={24}
|
||||
className="text-orange-600 dark:text-orange-400"
|
||||
/>
|
||||
}
|
||||
title="Amazon SNS Integration"
|
||||
onToggle={() => handleToggleEnabled(integration)}
|
||||
isTesting={isTesting === integration.id}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
SNS Topic ARN
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{integration.attributes.configuration.topic_arn ||
|
||||
"Not configured"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Last Checked
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{integration.attributes.connection_last_checked_at
|
||||
? format(
|
||||
new Date(
|
||||
integration.attributes
|
||||
.connection_last_checked_at,
|
||||
),
|
||||
"MMM d, yyyy HH:mm",
|
||||
)
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntegrationActionButtons
|
||||
integration={integration}
|
||||
onTestConnection={() =>
|
||||
handleTestConnection(integration.id)
|
||||
}
|
||||
onEditConfiguration={() =>
|
||||
handleEditConfiguration(integration)
|
||||
}
|
||||
onEditCredentials={() =>
|
||||
handleEditCredentials(integration)
|
||||
}
|
||||
onDelete={() => handleOpenDeleteModal(integration)}
|
||||
isTesting={isTesting === integration.id}
|
||||
isDeleting={isDeleting === integration.id}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{metadata && (
|
||||
<DataTablePagination
|
||||
currentPage={metadata.page.currentPage}
|
||||
totalPages={metadata.page.totalPages}
|
||||
pageSize={metadata.page.pageSize}
|
||||
totalCount={metadata.page.totalCount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<CustomAlertModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
title={
|
||||
editingIntegration
|
||||
? editMode === "configuration"
|
||||
? "Edit SNS Configuration"
|
||||
: "Edit AWS Credentials"
|
||||
: "Add SNS Integration"
|
||||
}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<SNSIntegrationForm
|
||||
integration={editingIntegration}
|
||||
editMode={editMode}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={handleModalClose}
|
||||
/>
|
||||
</CustomAlertModal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<CustomAlertModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={() => setIsDeleteOpen(false)}
|
||||
title="Delete SNS Integration"
|
||||
description="Are you sure you want to delete this SNS integration? This action cannot be undone."
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setIsDeleteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
integrationToDelete &&
|
||||
handleDeleteIntegration(integrationToDelete.id)
|
||||
}
|
||||
disabled={!!isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<IntegrationSkeleton />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2Icon size={16} />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomAlertModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,15 @@ import { z } from "zod";
|
||||
|
||||
import type { TaskState } from "@/types/tasks";
|
||||
|
||||
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
|
||||
export const IntegrationType = {
|
||||
AMAZON_S3: "amazon_s3",
|
||||
AWS_SECURITY_HUB: "aws_security_hub",
|
||||
JIRA: "jira",
|
||||
SNS: "sns",
|
||||
} as const;
|
||||
|
||||
export type IntegrationType =
|
||||
(typeof IntegrationType)[keyof typeof IntegrationType];
|
||||
|
||||
export interface IntegrationProps {
|
||||
type: "integrations";
|
||||
@@ -310,3 +318,83 @@ export interface JiraCredentialsPayload {
|
||||
user_mail?: string;
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
// SNS Integration Schemas
|
||||
export const snsIntegrationFormSchema = z
|
||||
.object({
|
||||
integration_type: z.literal("sns"),
|
||||
topic_arn: z
|
||||
.string()
|
||||
.min(1, "SNS topic ARN is required")
|
||||
.regex(
|
||||
/^arn:(aws|aws-cn|aws-us-gov):sns:[a-z0-9-]+:\d{12}:[a-zA-Z0-9_-]+$/,
|
||||
"Invalid SNS topic ARN format. Expected: arn:partition:sns:region:account-id:topic-name",
|
||||
),
|
||||
enabled: z.boolean().default(true),
|
||||
use_custom_credentials: z.boolean().default(false),
|
||||
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(),
|
||||
role_arn: z.string().optional(),
|
||||
external_id: z.string().optional(),
|
||||
role_session_name: z.string().optional(),
|
||||
session_duration: z.coerce
|
||||
.number()
|
||||
.min(900, "Session duration must be at least 900 seconds")
|
||||
.max(43200, "Session duration cannot exceed 43200 seconds")
|
||||
.optional(),
|
||||
aws_access_key_id: z.string().optional(),
|
||||
aws_secret_access_key: z.string().optional(),
|
||||
aws_session_token: z.string().optional(),
|
||||
})
|
||||
.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 editSNSIntegrationFormSchema = z
|
||||
.object({
|
||||
integration_type: z.literal("sns"),
|
||||
topic_arn: z
|
||||
.string()
|
||||
.min(1, "SNS topic ARN is required")
|
||||
.regex(
|
||||
/^arn:(aws|aws-cn|aws-us-gov):sns:[a-z0-9-]+:\d{12}:[a-zA-Z0-9_-]+$/,
|
||||
"Invalid SNS topic ARN format",
|
||||
)
|
||||
.optional(),
|
||||
use_custom_credentials: z.boolean().optional(),
|
||||
credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]).optional(),
|
||||
role_arn: z.string().optional(),
|
||||
external_id: z.string().optional(),
|
||||
role_session_name: z.string().optional(),
|
||||
session_duration: z.coerce
|
||||
.number()
|
||||
.min(900)
|
||||
.max(43200)
|
||||
.optional(),
|
||||
aws_access_key_id: z.string().optional(),
|
||||
aws_secret_access_key: z.string().optional(),
|
||||
aws_session_token: z.string().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);
|
||||
});
|
||||
|
||||
export interface SNSCredentialsPayload {
|
||||
role_arn?: string;
|
||||
external_id?: string;
|
||||
role_session_name?: string;
|
||||
session_duration?: number;
|
||||
aws_access_key_id?: string;
|
||||
aws_secret_access_key?: string;
|
||||
aws_session_token?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user