From e12c117770267c1b05d2ad94c56be698c174c7ca Mon Sep 17 00:00:00 2001 From: Muhammad-Jay Date: Mon, 1 Dec 2025 19:09:52 +0100 Subject: [PATCH 1/2] feat: Implement webhook integration with SSRF protection --- .../dashboard/api-integrations/page.tsx | 7 +- app/api/execute-webhook/route.ts | 131 ++++++ .../API-Integrations/WebhookManager.tsx | 438 ++++++++++++++++++ components/ui/label.tsx | 8 +- context/GenerationContext.tsx | 37 ++ lib/actions/content.actions.ts | 3 +- lib/utils.ts | 36 ++ testRunner.js | 125 +++++ 8 files changed, 778 insertions(+), 7 deletions(-) create mode 100644 app/api/execute-webhook/route.ts create mode 100644 components/Layout/Dashboard/API-Integrations/WebhookManager.tsx create mode 100644 testRunner.js diff --git a/app/(root)/dashboard/api-integrations/page.tsx b/app/(root)/dashboard/api-integrations/page.tsx index 9ed0f8f..6e62c52 100644 --- a/app/(root)/dashboard/api-integrations/page.tsx +++ b/app/(root)/dashboard/api-integrations/page.tsx @@ -1,7 +1,6 @@ +import { WebhookManager } from '@/components/Layout/Dashboard/API-Integrations/WebhookManager'; + export default function Page(){ - return( - <> - - ) + return } \ No newline at end of file diff --git a/app/api/execute-webhook/route.ts b/app/api/execute-webhook/route.ts new file mode 100644 index 0000000..90fefa1 --- /dev/null +++ b/app/api/execute-webhook/route.ts @@ -0,0 +1,131 @@ +import { isPrivateIP } from '@/lib/utils'; +import { URL } from 'url'; +import { promises as dns } from 'dns'; + +// Helper function to resolve the IP address of a hostname +async function resolveIP(hostname) { + try { + const addresses = await dns.lookup(hostname, { all: true }); + + const ipv4Address = addresses.find(addr => addr.family === 4)?.address; + + if (ipv4Address) { + return ipv4Address; + } + + return addresses[0]?.address; + + } catch (error) { + console.error(`DNS lookup failed for ${hostname}:`, error.message); + throw new Error("DNS_RESOLUTION_FAILED"); + } +} + +/** + * This function enforces SSRF protection policies using real DNS lookups. + */ +export async function POST(request) { + let destinationUrl, secretKey, payload; + + try { + const body = await request.json(); + ({ destinationUrl, secretKey, payload } = body); + } catch (e) { + return Response.json({ success: false, error: 'Invalid JSON body or missing required fields.' }, { status: 400 }); + } + + // 1. Validate Required Payload Fields + if (!destinationUrl || !secretKey || !payload) { + return Response.json({ success: false, error: 'Missing destinationUrl, secretKey, or payload in the request body.' }, { status: 400 }); + } + + let url; + try { + url = new URL(destinationUrl); + } catch (e) { + return Response.json({ success: false, error: 'Invalid destination URL format.' }, { status: 400 }); + } + + // 2. --- SSRF DEFENSE 1: PROTOCOL LOCK --- + if (url.protocol !== 'https:') { + return Response.json({ success: false, error: 'Protocol violation. Only HTTPS endpoints are permitted for security reasons.' }, { status: 403 }); + } + + // Check if the hostname is an IP address already (e.g., https://1.2.3.4/hook) + const isDirectIP = url.hostname.match(/^(\d{1,3}\.){3}\d{1,3}$/); + + let resolvedIp; + + //. --- SSRF DEFENSE 2: IP BLOCKLIST CHECK --- + try { + if (isDirectIP) { + // If the user provided an IP directly, we use it directly + resolvedIp = url.hostname; + } else { + // Otherwise, we perform a real DNS lookup + resolvedIp = await resolveIP(url.hostname); + } + + if (!resolvedIp) { + return Response.json({ success: false, error: 'DNS resolution failed or returned no address.' }, { status: 403 }); + } + + // Check if the resolved IP is private + if (isPrivateIP(resolvedIp)) { + console.warn(`SSRF BLOCK: Attempted request to private IP ${resolvedIp} for URL ${destinationUrl}`); + return Response.json({ + success: false, + error: 'Security Policy Violation: Target IP resolves to a private or reserved network. Request blocked to prevent SSRF.' + }, { status: 403 }); + } + } catch (e) { + if (e.message === "DNS_RESOLUTION_FAILED") { + return Response.json({ success: false, error: 'Target hostname could not be resolved.' }, { status: 403 }); + } + return Response.json({ success: false, error: 'Internal server error during IP verification.' }, { status: 500 }); + } + + // Execute the Webhook Request + try { + const webhookResponse = await fetch(destinationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // --- PAYLOAD INTEGRITY DEFENSE --- + 'X-Webhook-Secret': secretKey, + 'User-Agent': 'AICAP-Webhook-Dispatcher/1.0', + }, + body: JSON.stringify(payload), + }); + + const status = webhookResponse.status; + + if (webhookResponse.ok) { + return Response.json({ + success: true, + message: 'Webhook delivered successfully.', + targetStatus: status + }, { status: 200 }); + } else { + const responseText = await webhookResponse.text(); + console.error(`Webhook failed, remote status: ${status}. Response: ${responseText.substring(0, 100)}`); + return Response.json({ + success: false, + error: `Webhook failed. Target responded with status: ${status}.`, + targetStatus: status + }, { status: 200 }); + } + + } catch (e) { + console.error(`Network error during webhook execution: ${e.message}`); + return Response.json({ + success: false, + error: `Network connection failed or timed out: ${e.message}` + }, { status: 200 }); + } +} + +// Optional: Block other methods for stricter API design +export async function GET() { + return Response.json({ error: 'Method Not Allowed' }, { status: 405 }); +} \ No newline at end of file diff --git a/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx b/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx new file mode 100644 index 0000000..9d22e75 --- /dev/null +++ b/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx @@ -0,0 +1,438 @@ +'use client' +import React, { useState, useEffect } from 'react'; +import { + Zap, + Link, + Key, + Database, + X, + Plus, + Trash2, + Edit, + Loader2, + CheckCircle, + AlertTriangle, + ToggleLeft, + ToggleRight, + Clipboard, + Calendar, +} from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +// Mock Encryption/Decryption Utility (Simulating secure storage/retrieval) +const mockEncrypt = (data) => `ENC:${btoa(data)}`; +const mockDecrypt = (data) => data.startsWith('ENC:') ? atob(data.slice(4)) : data; + +// Default state for a new webhook +const defaultWebhook = { + url: '', + trigger_event: 'content.complete', + secret_key: crypto.randomUUID(), // Auto-generate a strong initial secret + is_active: true, +}; + +// --- Mock Data Setup --- +const initialMockWebhooks = [ + { + id: 'hook_1', + url: 'https://staging.cms-backend.io/api/ingest', + secret_key: mockEncrypt('MyCmsSecretToken'), + trigger_event: 'content.complete', + is_active: true, + created_at: Date.now() - 86400000, + }, + { + id: 'hook_2', + url: 'https://dev.marketing-tool.net/receive-updates', + secret_key: mockEncrypt('MarketingApi123'), + trigger_event: 'content.complete', + is_active: false, + created_at: Date.now() - 3600000, + }, +]; + +// --- Dialog Component (Simulating shadcn/ui Dialog) --- +const WebhookFormDialog = ({ isOpen, onClose, initialData, onSave, status, message }) => { + const [formData, setFormData] = useState(initialData || defaultWebhook); + const [messageBox, setMessageBox] = useState({ message: message, status: status }); + + // Update form data if initialData changes (e.g., when editing starts) + useEffect(() => { + // We decrypt the key for presentation in the form + const decryptedData = initialData ? { + ...initialData, + secret_key: mockDecrypt(initialData.secret_key) + } : { ...defaultWebhook, secret_key: crypto.randomUUID() }; + + setFormData(decryptedData); + }, [initialData]); + + useEffect(() => { + setMessageBox({ message, status }) + }, [status, message]); + + + if (!isOpen) return null; + + const handleSave = () => { + try { + const url = new URL(formData.url.trim()); + if (url.protocol !== 'https:') { + // Using console.error instead of alert as per instructions + console.error("Validation Error: Only HTTPS URLs are permitted for security."); + // setMessageBox('Validation Error: Only HTTPS URLs are permitted for security.', 'error'); + return; + } + } catch(e) { + console.error("Validation Error: Please provide a valid URL."); + // setMessageBox('Validation Error: Please provide a valid URL.', 'error'); + return; + } + + if (!formData.secret_key) { + console.error("Validation Error: Secret key is required."); + // setMessageBox('Validation Error: Secret key is required.', 'error'); + return; + } + + onSave(formData); + }; + + const isEditing = !!initialData?.id; + + return ( +
+
+ {/* Dialog Header */} +
+

+ {isEditing ? 'Edit Webhook Integration' : 'Add New Webhook'} +

+ +
+ + {/* Status Indicator */} + {messageBox.status !== 'idle' && messageBox.message && ( +
+ {messageBox.status === 'loading' && } + {messageBox.status === 'success' && } + {messageBox.status === 'error' && } + {messageBox.message} +
+ )} + + {/* Dialog Content */} +
+ + {/* URL Input */} +
+ + setFormData(prev => ({ ...prev, url: e.target.value }))} + placeholder="https://yourcms.com/api/webhooks/ingest" + disabled={messageBox.status === 'loading'} + /> +

+ Must use HTTPS. The server checks the IP for SSRF prevention. +

+
+ + {/* Secret Key Input */} +
+ +
+ setFormData(prev => ({ ...prev, secret_key: e.target.value }))} + placeholder="Automatically generated secure token" + disabled={messageBox.status === 'loading'} + /> + +
+
+ + {/* Trigger and Active Status (Read-Only) */} +
+
+ + +
+
+ + +
+
+
+ + {/* Dialog Footer */} +
+
+ + +
+
+
+
+ ); +}; + + +// --- Main Webhook Manager Component --- +export const WebhookManager = () => { + // Initialize state with mock data. We decrypt for display purposes. + const [webhooks, setWebhooks] = useState(initialMockWebhooks.map(h => ({ + ...h, + secret_key: mockDecrypt(h.secret_key), + }))); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingWebhook, setEditingWebhook] = useState(null); + + // UI Status State (used for global actions like delete or toggle) + const [status, setStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error' + const [message, setMessage] = useState(''); + + // --- CRUD Handlers (Local State Management) --- + + const startCreate = () => { + setEditingWebhook({ ...defaultWebhook, secret_key: crypto.randomUUID() }); + setIsDialogOpen(true); + }; + + const startEdit = (hook) => { + // Pass the hook object to the dialog for pre-filling + setEditingWebhook(hook); + setIsDialogOpen(true); + }; + + const handleSave = (data) => { + setStatus('loading'); + setMessage(data.id ? 'Updating webhook...' : 'Creating new webhook...'); + + // Simulate async operation delay + setTimeout(() => { + const dataToSave = { + ...data, + // Re-encrypt the key before saving to mock persistent storage + secret_key: mockEncrypt(data.secret_key), + updated_at: Date.now(), + }; + + setWebhooks(prevHooks => { + if (data.id) { + // Update existing hook + return prevHooks.map(hook => + hook.id === data.id ? { ...dataToSave, id: data.id } : hook + ); + } else { + // Add new hook + const newHook = { + ...dataToSave, + id: crypto.randomUUID(), + created_at: Date.now() + }; + return [...prevHooks, newHook]; + } + }); + + setMessage(data.id ? 'Webhook updated successfully!' : 'New webhook created successfully!'); + setStatus('success'); + + // Close dialog after success + setTimeout(() => { + setStatus('idle'); + setIsDialogOpen(false); + }, 1000); + + }, 500); // Mock network delay + }; + + const handleDelete = (id) => { + // Substitute for custom confirmation dialog + if (!window.confirm("Confirm deletion: This action cannot be undone.")) return; + + setStatus('loading'); + setMessage('Deleting webhook...'); + + setTimeout(() => { + setWebhooks(prevHooks => prevHooks.filter(hook => hook.id !== id)); + setMessage('Webhook deleted.'); + setStatus('success'); + setTimeout(() => setStatus('idle'), 1500); + }, 500); // Mock network delay + }; + + const handleToggleActive = (hook) => { + setStatus('loading'); + const newStatus = !hook.is_active; + + setTimeout(() => { + setWebhooks(prevHooks => prevHooks.map(h => + h.id === hook.id ? { ...h, is_active: newStatus } : h + )); + setMessage(`Webhook ${newStatus ? 'activated' : 'deactivated'}.`); + setStatus('success'); + setTimeout(() => setStatus('idle'), 1500); + }, 500); // Mock network delay + }; + + + return ( +
+
+ {/* Header and Add Button */} +
+

+ + Webhook Integrations +

+ +
+ + {/* Global Status Display */} + {message && status !== 'idle' && status !== 'loading' && ( +
+ {status === 'success' && } + {status === 'error' && } + {message} +
+ )} + + {/* Webhooks List */} + {status === 'loading' ? ( +
+ +

{message}

+
+ ) : ( +
+ {webhooks.length === 0 ? ( +
+ +

No webhooks configured.

+

+ Start automating by adding your first content ingestion endpoint. +

+
+ ) : ( + webhooks.map((hook) => ( +
+
+
+ + + +
+
+ {hook.is_active ? 'Active' : 'Disabled'} Integration +
+
+ + {new Date(hook.created_at || hook.updated_at).toLocaleDateString()} +
+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ + {/* Details */} +
+

Destination URL

+ + {hook.url} + +
+ +
+ Trigger: {hook.trigger_event} + Secured by Secret Key +
+
+ )) + )} +
+ )} +
+ + {/* Webhook Dialog */} + setIsDialogOpen(false)} + initialData={editingWebhook} + onSave={handleSave} + status={'idle'} // Global status doesn't apply to dialog's internal actions + message={''} + /> +
+ ); +}; \ No newline at end of file diff --git a/components/ui/label.tsx b/components/ui/label.tsx index d0fde0f..3e657c8 100644 --- a/components/ui/label.tsx +++ b/components/ui/label.tsx @@ -5,7 +5,8 @@ import * as LabelPrimitive from '@radix-ui/react-label'; import { cn } from '@/lib/utils'; -function Label({ className, ...props }: React.ComponentProps) { +function Label({ icon, className = '', children, ...props }) { + const IconComponent = icon ? icon : null return ( + > + {icon && } + {children} + ); } diff --git a/context/GenerationContext.tsx b/context/GenerationContext.tsx index d86b239..6299514 100644 --- a/context/GenerationContext.tsx +++ b/context/GenerationContext.tsx @@ -116,6 +116,8 @@ interface GenerationContextType { setIsDialogOpen: (prev?: boolean) => void; onRefinePrompt: () => Promise; + + triggerWebhookDispatch: () => Promise; } // --- 2. Create the Context with Default Values --- @@ -411,6 +413,40 @@ export function ContextProvider({ children }: { children: ReactNode }) { setIsRefineLoading(false); }, [prompt, setPrompt]); + // --- Webhook Trigger Function --- + async function triggerWebhookDispatch(hookConfig, contentPayload) { + const decryptedSecret = "" + const apiCallBody = { + destinationUrl: hookConfig.url, + secretKey: decryptedSecret, // The key we use for target validation + payload: contentPayload, // The content we are sending + }; + + try { + const response = await fetch('/api/execute-webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiCallBody), + }); + + const result = await response.json(); + + if (!result.success) { + console.error(`Webhook execution failed on server: ${result.error}`); + toast.error('Webhook execution failed on server'); + return; + } + + console.log(`Webhook successfully dispatched to ${hookConfig.url}`); + toast.error('Webhook successfully dispatched'); + // Show success message! + + } catch (error) { + console.error("Local network error calling webhook API:", error); + toast.error('Local network error calling webhook API'); + } + } + const value = { generatedContent, isLoading, @@ -458,6 +494,7 @@ export function ContextProvider({ children }: { children: ReactNode }) { onRefinePrompt, isDialogOpen, setIsDialogOpen, + triggerWebhookDispatch }; return {children}; diff --git a/lib/actions/content.actions.ts b/lib/actions/content.actions.ts index 60cbf95..c673082 100644 --- a/lib/actions/content.actions.ts +++ b/lib/actions/content.actions.ts @@ -71,7 +71,7 @@ export async function updateContent(contentId: string, newContent: string) { } } -export async function makeRequest(url: string, options: any) { +export async function makeRequest(url: string, options: never) { try { return await axios.post(url, options); @@ -80,3 +80,4 @@ export async function makeRequest(url: string, options: any) { throw e; } } + diff --git a/lib/utils.ts b/lib/utils.ts index e2c899d..a3743ae 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -76,3 +76,39 @@ export const generateCronString = (frequency: string, timeStr: string): string = return `${minute} ${hour} * * *`; } }; + + +export function isPrivateIP(ip) { + if (!ip) return true; // Safety check + + // Parse the IP segments + const parts = ip.split('.').map(Number); + if (parts.length !== 4) return true; + + // A CIDR range checker: + const privateRanges = [ + // 10.0.0.0/8 (Private Class A) + [10, 0, 0, 0, 10, 255, 255, 255], + // 172.16.0.0/12 (Private Class B) + [172, 16, 0, 0, 172, 31, 255, 255], + // 192.168.0.0/16 (Private Class C) + [192, 168, 0, 0, 192, 168, 255, 255], + // 127.0.0.0/8 (Loopback) + [127, 0, 0, 0, 127, 255, 255, 255], + // 169.254.0.0/16 (Link-local) + [169, 254, 0, 0, 169, 254, 255, 255], + ]; + + const ipNum = parts[0] * 256**3 + parts[1] * 256**2 + parts[2] * 256 + parts[3]; + + for (const [startA, startB, startC, startD, endA, endB, endC, endD] of privateRanges) { + const startNum = startA * 256**3 + startB * 256**2 + startC * 256 + startD; + const endNum = endA * 256**3 + endB * 256**2 + endC * 256 + endD; + + if (ipNum >= startNum && ipNum <= endNum) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/testRunner.js b/testRunner.js new file mode 100644 index 0000000..e4a8f26 --- /dev/null +++ b/testRunner.js @@ -0,0 +1,125 @@ +import { promises as dns } from 'dns'; + +// --- IMPORTANT CONFIGURATION --- +// You must replace this with the actual URL of your running Next.js API route. +// If you run Next.js locally, it will likely be: +const API_ENDPOINT = 'http://localhost:3000/api/execute-webhook'; + +// --- SSRF TEST TARGETS --- +// For a real test, you'd run a simple mock server that listens on different IPs +// For demonstration, we'll use public IPs (like Google's DNS or a known public test service) +// and the blocked private IP (127.0.0.1) + +/** + * Executes a POST request against the Next.js Webhook API endpoint. + * @param {string} destinationUrl The URL the Next.js server will attempt to call. + * @param {string} secretKey The mock secret key. + * @param {object} payload The mock JSON payload. + * @param {string} testName A description for the test case. + */ +async function runTest(testName, destinationUrl, secretKey, payload) { + console.log(`\n--- Running Test Case: ${testName} ---`); + console.log(`Target Webhook: ${destinationUrl}`); + + const body = { destinationUrl, secretKey, payload }; + + try { + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const status = response.status; + const result = await response.json(); + + console.log(`API Status Code: ${status}`); + console.log('Response Body:', result); + + // Assertions for expected behavior + if (testName.includes("BLOCK")) { + if (status === 403 && !result.success) { + console.log('✅ TEST PASSED: Request was correctly blocked with 403.'); + } else { + console.log(`❌ TEST FAILED: Expected 403 block, got status ${status} and success: ${result.success}.`); + } + } else if (testName.includes("SUCCESS")) { + if (status === 200 && result.success === true) { + console.log('✅ TEST PASSED: Webhook dispatch was processed successfully (200 OK).'); + } else { + console.log(`❌ TEST FAILED: Expected 200 success, got status ${status} and success: ${result.success}.`); + } + } + + } catch (error) { + console.error(`❌ TEST FAILED: Network error connecting to Next.js API at ${API_ENDPOINT}:`, error.message); + console.error("-> Ensure your Next.js server is running on port 3000."); + } +} + + +async function main() { + console.log("================================================="); + console.log(" SSRF Defense Test Runner Script "); + console.log("================================================="); + + // Check DNS module availability (only relevant if running on a platform + // where Node.js features might be restricted, like Vercel Edge). + try { + await dns.lookup('google.com'); + console.log("DNS module check: Available (Node.js runtime confirmed)."); + } catch (e) { + console.error("DNS module check: FAILED. Ensure your Next.js route is running in a Node.js environment."); + return; + } + + // --- TEST SUITE --- + + // 1. SSRF BLOCK TEST: Private IP (127.0.0.1) via Hostname + // NOTE: This will fail if the Next.js server is running on Edge, + // but should succeed if running as a Node function as expected. + await runTest( + "1. SSRF BLOCK TEST (127.0.0.1)", + "https://127.0.0.1/internal-resource-that-should-be-safe", + "mock-secret", + { event: "test", data: 123 } + ); + + // 2. PROTOCOL BLOCK TEST: HTTP (Non-HTTPS) + await runTest( + "2. PROTOCOL BLOCK TEST (HTTP)", + "http://example.com/some/path", + "mock-secret", + { event: "test", data: 456 } + ); + + // 3. SSRF BLOCK TEST: Private IP (192.168.x.x) via Hostname + // We use a hostname that resolves to a public IP to simulate the DNS lookup, + // but imagine 'localhost' or a resolved '192.168.1.1' + // This test relies on the DNS lookup mechanism identifying a private IP. + await runTest( + "3. SSRF BLOCK TEST (localhost, needs mock)", + "https://localhost/api/sensitive-data", + "mock-secret", + { event: "test", data: 789 } + ); + + // 4. EXTERNAL SUCCESS TEST: Public Endpoint (Assuming it connects) + // NOTE: This test requires a real public webhook target (e.g., https://postman-echo.com/post) + // or a proxy to succeed. We use a placeholder that will likely fail + // due to lack of a real server but will pass the SSRF/Protocol checks. + await runTest( + "4. EXTERNAL SUCCESS TEST (Public HTTPS)", + "https://jsonplaceholder.typicode.com/posts", // A real public API for testing POST + "mock-secret-prod", + { title: "Test Post", body: "This should be sent successfully", userId: 1 } + ); + + console.log("\n================================================="); + console.log("Test Suite Finished."); + console.log("================================================="); +} + +main(); \ No newline at end of file From 6707a800fd7a530691e869e3c3f26258962fecc0 Mon Sep 17 00:00:00 2001 From: Muhammad-Jay Date: Mon, 1 Dec 2025 20:03:19 +0100 Subject: [PATCH 2/2] refactor: Adjust styling and layout of WebhookManager component - Introduced ScrollArea for better content management. - Applied text-xs for smaller font sizes in various elements. - Refined button styles and spacing. --- .../API-Integrations/WebhookManager.tsx | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx b/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx index 9d22e75..b854922 100644 --- a/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx +++ b/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx @@ -21,6 +21,7 @@ import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { ScrollArea } from '@/components/ui/scroll-area'; // Mock Encryption/Decryption Utility (Simulating secure storage/retrieval) const mockEncrypt = (data) => `ENC:${btoa(data)}`; @@ -33,7 +34,6 @@ const defaultWebhook = { secret_key: crypto.randomUUID(), // Auto-generate a strong initial secret is_active: true, }; - // --- Mock Data Setup --- const initialMockWebhooks = [ { @@ -119,7 +119,7 @@ const WebhookFormDialog = ({ isOpen, onClose, initialData, onSave, status, messa {/* Status Indicator */} {messageBox.status !== 'idle' && messageBox.message && (
{/* URL Input */} -
- +
+ setFormData(prev => ({ ...prev, url: e.target.value }))} placeholder="https://yourcms.com/api/webhooks/ingest" disabled={messageBox.status === 'loading'} @@ -151,20 +152,20 @@ const WebhookFormDialog = ({ isOpen, onClose, initialData, onSave, status, messa
{/* Secret Key Input */} -
- +
+
setFormData(prev => ({ ...prev, secret_key: e.target.value }))} placeholder="Automatically generated secure token" disabled={messageBox.status === 'loading'} /> - @@ -320,14 +321,14 @@ export const WebhookManager = () => { return (
-
+
{/* Header and Add Button */}

Webhook Integrations

-
@@ -335,7 +336,7 @@ export const WebhookManager = () => { {/* Global Status Display */} {message && status !== 'idle' && status !== 'loading' && (
@@ -352,12 +353,12 @@ export const WebhookManager = () => {

{message}

) : ( -
+ {webhooks.length === 0 ? (

No webhooks configured.

-

+

Start automating by adding your first content ingestion endpoint.

@@ -366,8 +367,8 @@ export const WebhookManager = () => {
@@ -384,15 +385,15 @@ export const WebhookManager = () => {
{hook.is_active ? 'Active' : 'Disabled'} Integration
-
+
- {new Date(hook.created_at || hook.updated_at).toLocaleDateString()} + {new Date(hook.created_at).toLocaleDateString()}
{/* Action Buttons */} -
+
@@ -420,7 +421,7 @@ export const WebhookManager = () => {
)) )} -
+ )}
@@ -430,7 +431,7 @@ export const WebhookManager = () => { onClose={() => setIsDialogOpen(false)} initialData={editingWebhook} onSave={handleSave} - status={'idle'} // Global status doesn't apply to dialog's internal actions + status={'idle'} message={''} />