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:
Toni de la Fuente
2026-01-07 22:33:04 +01:00
parent ff260ef75d
commit 43918cc947
7 changed files with 1029 additions and 1 deletions
+4
View File
@@ -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>
);
}
+3
View File
@@ -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>
);
};
+89 -1
View File
@@ -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;
}