feat[webhook]: added generic webhook (#130)

* added logic to support generic webhooks

* fixed style of generic webhook

* fixed style of generic webhook
This commit is contained in:
Waleed Latif
2025-03-20 17:08:19 -07:00
committed by GitHub
parent 2db6c82054
commit 37567b4638
8 changed files with 715 additions and 69 deletions

View File

@@ -1,5 +1,3 @@
# Pull Request Template
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.

60
package-lock.json generated Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "sim",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"framer-motion": "^12.5.0"
}
},
"node_modules/framer-motion": {
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.5.0.tgz",
"integrity": "sha512-buPlioFbH9/W7rDzYh1C09AuZHAk2D1xTA1BlounJ2Rb9aRg84OXexP0GLd+R83v0khURdMX7b5MKnGTaSg5iA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.5.0",
"motion-utils": "^12.5.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.5.0.tgz",
"integrity": "sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.5.0"
}
},
"node_modules/motion-utils": {
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.5.0.tgz",
"integrity": "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"framer-motion": "^12.5.0"
}
}

View File

@@ -170,6 +170,60 @@ export async function GET(request: NextRequest) {
})
}
case 'generic': {
// Get the general webhook configuration
const token = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName
const requireAuth = providerConfig.requireAuth
const allowedIps = providerConfig.allowedIps
// Generate sample curl command for testing
let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"`
// Add auth headers to the curl command if required
if (requireAuth && token) {
if (secretHeaderName) {
curlCommand += ` -H "${secretHeaderName}: ${token}"`
} else {
curlCommand += ` -H "Authorization: Bearer ${token}"`
}
}
// Add a sample payload
curlCommand += ` -d '{"event":"test_event","timestamp":"${new Date().toISOString()}"}'`
logger.info(`[${requestId}] General webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
isActive: foundWebhook.isActive,
},
message:
'General webhook configuration is valid. Use the URL and authentication details as needed.',
details: {
requireAuth: requireAuth || false,
hasToken: !!token,
hasCustomHeader: !!secretHeaderName,
customHeaderName: secretHeaderName,
hasIpRestrictions: Array.isArray(allowedIps) && allowedIps.length > 0,
},
test: {
curlCommand,
headers: requireAuth
? secretHeaderName
? { [secretHeaderName]: token }
: { Authorization: `Bearer ${token}` }
: {},
samplePayload: {
event: 'test_event',
timestamp: new Date().toISOString(),
},
},
})
}
default: {
// Generic webhook test
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)

View File

@@ -13,9 +13,7 @@ import { Serializer } from '@/serializer'
const logger = createLogger('WebhookTriggerAPI')
// Force dynamic rendering for webhook endpoints
export const dynamic = 'force-dynamic'
// Increase the response size limit for webhook payloads
export const maxDuration = 300 // 5 minutes max execution time for long-running webhooks
/**
@@ -306,8 +304,58 @@ async function processWebhook(
// Stripe verification would go here if needed
break
case 'generic':
// Enhanced general webhook authentication
if (providerConfig.requireAuth) {
let isAuthenticated = false
// Check for token in Authorization header (Bearer token)
if (providerConfig.token) {
const providedToken = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: null
if (providedToken === providerConfig.token) {
isAuthenticated = true
}
// Check for token in custom header if specified
if (!isAuthenticated && providerConfig.secretHeaderName) {
const customHeaderValue = request.headers.get(providerConfig.secretHeaderName)
if (customHeaderValue === providerConfig.token) {
isAuthenticated = true
}
}
// Return 401 if authentication failed
if (!isAuthenticated) {
logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`)
return new NextResponse('Unauthorized', { status: 401 })
}
}
}
// IP restriction check
if (
providerConfig.allowedIps &&
Array.isArray(providerConfig.allowedIps) &&
providerConfig.allowedIps.length > 0
) {
const clientIp =
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
request.headers.get('x-real-ip') ||
'unknown'
if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) {
logger.warn(
`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`
)
return new NextResponse('Forbidden - IP not allowed', { status: 403 })
}
}
break
default:
// For generic webhooks, check for a token if provided in providerConfig
// For other generic webhooks, check for a token if provided in providerConfig
if (providerConfig.token) {
const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null
if (!providedToken || providedToken !== providerConfig.token) {

View File

@@ -40,8 +40,8 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
options: [
{ label: 'Generic', id: 'generic' },
{ label: 'WhatsApp', id: 'whatsapp' },
{ label: 'GitHub', id: 'github' },
{ label: 'Stripe', id: 'stripe' },
// { label: 'GitHub', id: 'github' },
// { label: 'Stripe', id: 'stripe' },
],
value: () => 'generic',
condition: { field: 'startWorkflow', value: 'webhook' },

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Check, Copy, Loader2, Trash2, X } from 'lucide-react'
import { GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons'
import { motion } from 'framer-motion'
import { Check, Copy, Loader2, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -34,8 +35,8 @@ interface WebhookModalProps {
webhookPath: string
webhookProvider: string
workflowId: string
onSave?: (path: string, providerConfig: ProviderConfig) => void
onDelete?: () => void
onSave?: (path: string, providerConfig: ProviderConfig) => Promise<boolean>
onDelete?: () => Promise<boolean>
webhookId?: string
}
@@ -57,14 +58,40 @@ export function WebhookModal({
const [testResult, setTestResult] = useState<{
success: boolean
message?: string
test?: {
curlCommand?: string
status?: number
contentType?: string
responseText?: string
headers?: Record<string, string>
samplePayload?: Record<string, any>
}
} | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [showUnsavedChangesConfirm, setShowUnsavedChangesConfirm] = useState(false)
const isConfigured = Boolean(webhookId)
// Provider-specific configuration state
const [whatsappVerificationToken, setWhatsappVerificationToken] = useState('')
const [githubContentType, setGithubContentType] = useState('application/json')
// General webhook configuration state
const [generalToken, setGeneralToken] = useState('')
const [secretHeaderName, setSecretHeaderName] = useState('')
const [requireAuth, setRequireAuth] = useState(false)
const [allowedIps, setAllowedIps] = useState('')
// Original values to track changes
const [originalValues, setOriginalValues] = useState({
whatsappVerificationToken: '',
githubContentType: 'application/json',
generalToken: '',
secretHeaderName: '',
requireAuth: false,
allowedIps: '',
})
// Get the current provider configuration
const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
@@ -78,8 +105,29 @@ export function WebhookModal({
) {
const randomToken = Math.random().toString(36).substring(2, 10)
setWhatsappVerificationToken(randomToken)
setOriginalValues((prev) => ({ ...prev, whatsappVerificationToken: randomToken }))
}
}, [webhookProvider, whatsappVerificationToken, webhookId, isLoadingToken])
// Generate a random token for general webhook if none exists and auth is required
if (
webhookProvider === 'generic' &&
!generalToken &&
!webhookId &&
!isLoadingToken &&
requireAuth
) {
const randomToken = crypto.randomUUID()
setGeneralToken(randomToken)
setOriginalValues((prev) => ({ ...prev, generalToken: randomToken }))
}
}, [
webhookProvider,
whatsappVerificationToken,
generalToken,
webhookId,
isLoadingToken,
requireAuth,
])
// Load existing configuration values
useEffect(() => {
@@ -96,9 +144,34 @@ export function WebhookModal({
// Check provider type and set appropriate state
if (webhookProvider === 'whatsapp' && 'verificationToken' in config) {
setWhatsappVerificationToken(config.verificationToken)
const token = config.verificationToken || ''
setWhatsappVerificationToken(token)
setOriginalValues((prev) => ({ ...prev, whatsappVerificationToken: token }))
} else if (webhookProvider === 'github' && 'contentType' in config) {
setGithubContentType(config.contentType)
const contentType = config.contentType || 'application/json'
setGithubContentType(contentType)
setOriginalValues((prev) => ({ ...prev, githubContentType: contentType }))
} else if (webhookProvider === 'generic') {
// Set general webhook configuration
const token = config.token || ''
const headerName = config.secretHeaderName || ''
const auth = !!config.requireAuth
const ips = Array.isArray(config.allowedIps)
? config.allowedIps.join(', ')
: config.allowedIps || ''
setGeneralToken(token)
setSecretHeaderName(headerName)
setRequireAuth(auth)
setAllowedIps(ips)
setOriginalValues((prev) => ({
...prev,
generalToken: token,
secretHeaderName: headerName,
requireAuth: auth,
allowedIps: ips,
}))
}
}
}
@@ -117,6 +190,32 @@ export function WebhookModal({
}
}, [webhookId, webhookProvider])
// Check for unsaved changes
useEffect(() => {
// Compare current values with original values
if (webhookProvider === 'whatsapp') {
setHasUnsavedChanges(whatsappVerificationToken !== originalValues.whatsappVerificationToken)
} else if (webhookProvider === 'github') {
setHasUnsavedChanges(githubContentType !== originalValues.githubContentType)
} else if (webhookProvider === 'generic') {
setHasUnsavedChanges(
generalToken !== originalValues.generalToken ||
secretHeaderName !== originalValues.secretHeaderName ||
requireAuth !== originalValues.requireAuth ||
allowedIps !== originalValues.allowedIps
)
}
}, [
webhookProvider,
whatsappVerificationToken,
githubContentType,
generalToken,
secretHeaderName,
requireAuth,
allowedIps,
originalValues,
])
// Use the provided path or generate a UUID-based path
const formattedPath = webhookPath && webhookPath.trim() !== '' ? webhookPath : crypto.randomUUID()
@@ -142,6 +241,21 @@ export function WebhookModal({
return { contentType: githubContentType }
case 'stripe':
return {}
case 'generic':
// Parse the allowed IPs into an array
const parsedIps = allowedIps
? allowedIps
.split(',')
.map((ip) => ip.trim())
.filter((ip) => ip)
: []
return {
token: generalToken || undefined,
secretHeaderName: secretHeaderName || undefined,
requireAuth,
allowedIps: parsedIps.length > 0 ? parsedIps : undefined,
}
default:
return {}
}
@@ -158,7 +272,20 @@ export function WebhookModal({
? formattedPath.substring(1)
: formattedPath
await new Promise((resolve) => setTimeout(resolve, 100))
await onSave(pathToSave, providerConfig)
await new Promise((resolve) => setTimeout(resolve, 100))
// Update original values to match current values after successful save
setOriginalValues({
whatsappVerificationToken,
githubContentType,
generalToken,
secretHeaderName,
requireAuth,
allowedIps,
})
setHasUnsavedChanges(false)
}
} catch (error) {
logger.error('Error saving webhook:', { error })
@@ -181,6 +308,23 @@ export function WebhookModal({
}
}
const handleClose = () => {
if (hasUnsavedChanges) {
setShowUnsavedChangesConfirm(true)
} else {
onClose()
}
}
const handleCancelClose = () => {
setShowUnsavedChangesConfirm(false)
}
const handleConfirmClose = () => {
setShowUnsavedChangesConfirm(false)
onClose()
}
// Test the webhook configuration
const testWebhook = async () => {
if (!webhookId) return
@@ -199,11 +343,15 @@ export function WebhookModal({
const data = await response.json()
// Add a slight delay before showing the result for smoother animation
await new Promise((resolve) => setTimeout(resolve, 300))
// If the test was successful, show a success message
if (data.success) {
setTestResult({
success: true,
message: data.message || 'Webhook configuration is valid.',
test: data.test,
})
} else {
setTestResult({
@@ -276,7 +424,10 @@ export function WebhookModal({
</div>
{testResult && (
<div
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`p-3 rounded-md ${
testResult.success
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300 border border-green-200 dark:border-green-800'
@@ -284,7 +435,7 @@ export function WebhookModal({
}`}
>
<p className="text-sm">{testResult.message}</p>
</div>
</motion.div>
)}
<div className="space-y-2">
@@ -411,18 +562,295 @@ export function WebhookModal({
</div>
</div>
)
default:
case 'generic':
return (
<div className="space-y-2">
<h4 className="font-medium">Generic Webhook Setup</h4>
<p className="text-sm">Use the URL above to send webhook events to this workflow.</p>
<div className="space-y-4">
<div className="flex items-center space-x-2 mb-3">
<div className="flex items-center h-3.5 space-x-2">
<Checkbox
id="require-auth"
checked={requireAuth}
onCheckedChange={(checked) => setRequireAuth(checked as boolean)}
/>
<Label htmlFor="require-auth" className="text-sm font-medium cursor-pointer">
Require Authentication
</Label>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
You can test your webhook by sending a POST request to the URL.
{requireAuth && (
<div className="space-y-4 ml-5 border-l-2 pl-4 border-gray-200 dark:border-gray-700">
<div className="space-y-2">
<Label htmlFor="auth-token">Authentication Token</Label>
<div className="flex items-center space-x-2">
{isLoadingToken ? (
<div className="flex-1 h-10 px-3 py-2 rounded-md border border-input bg-background flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<Input
id="auth-token"
value={generalToken}
onChange={(e) => setGeneralToken(e.target.value)}
placeholder="Enter an auth token"
className="flex-1"
/>
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(generalToken, 'general-token')}
disabled={isLoadingToken}
>
{copied === 'general-token' ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
This token will be used to authenticate requests to your webhook (via Bearer
token).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="header-name">Secret Header Name (Optional)</Label>
<Input
id="header-name"
value={secretHeaderName}
onChange={(e) => setSecretHeaderName(e.target.value)}
placeholder="X-Secret-Key"
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Custom HTTP header name for passing the authentication token instead of using
Bearer authentication.
</p>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="allowed-ips">Allowed IP Addresses (Optional)</Label>
<Input
id="allowed-ips"
value={allowedIps}
onChange={(e) => setAllowedIps(e.target.value)}
placeholder="192.168.1.1, 10.0.0.1"
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of IP addresses that are allowed to access this webhook.
</p>
</div>
{testResult && testResult.success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="p-3 rounded-md bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300 border border-green-200 dark:border-green-800"
>
<p className="text-sm">{testResult.message}</p>
</motion.div>
)}
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-sm mb-2">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Copy the Webhook URL above</li>
<li>Configure your service to send HTTP POST requests to this URL</li>
{requireAuth && (
<>
<li>
{secretHeaderName
? `Add the "${secretHeaderName}" header with your token to all requests`
: 'Add an "Authorization: Bearer YOUR_TOKEN" header to all requests'}
</li>
</>
)}
</ol>
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
The webhook will receive all HTTP POST requests and pass the data to your
workflow.
</p>
</div>
</div>
{testResult && testResult.success && testResult.test?.curlCommand && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="mt-3 bg-black/10 dark:bg-white/10 p-2 rounded text-xs font-mono overflow-x-auto relative group"
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 h-6 w-6 opacity-70 hover:opacity-100"
onClick={() =>
copyToClipboard(testResult.test?.curlCommand || '', 'curl-command')
}
>
{copied === 'curl-command' ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
<pre className="whitespace-pre-wrap break-all pr-8">
{testResult.test.curlCommand}
</pre>
</motion.div>
)}
{testResult && !testResult.success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="p-3 rounded-md bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 border border-red-200 dark:border-red-800"
>
<p className="text-sm">{testResult.message}</p>
</motion.div>
)}
</div>
)
default:
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="require-auth"
checked={requireAuth}
onCheckedChange={(checked) => setRequireAuth(checked === true)}
className="h-3.5 w-3.5"
/>
<div>
<Label htmlFor="require-auth" className="text-sm font-medium cursor-pointer">
Require Authentication
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Enable authentication to secure your webhook endpoint.
</p>
</div>
</div>
{requireAuth && (
<div className="space-y-4 pl-5 ml-1 border-l border-gray-200 dark:border-gray-700">
<div className="space-y-2">
<Label htmlFor="auth-token">Authentication Token</Label>
<div className="flex items-center space-x-2">
{isLoadingToken ? (
<div className="flex-1 h-10 px-3 py-2 rounded-md border border-input bg-background flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<Input
id="auth-token"
value={generalToken}
onChange={(e) => setGeneralToken(e.target.value)}
placeholder="Enter an authentication token"
className="flex-1"
/>
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(generalToken, 'general-token')}
disabled={isLoadingToken}
>
{copied === 'general-token' ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
This token will be used to authenticate requests to your webhook (via Bearer
token).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="header-name">Secret Header Name (Optional)</Label>
<Input
id="header-name"
value={secretHeaderName}
onChange={(e) => setSecretHeaderName(e.target.value)}
placeholder="X-Secret-Key"
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Custom HTTP header name for passing the authentication token instead of using
Bearer authentication.
</p>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="allowed-ips">Allowed IP Addresses (Optional)</Label>
<Input
id="allowed-ips"
value={allowedIps}
onChange={(e) => setAllowedIps(e.target.value)}
placeholder="192.168.1.1, 10.0.0.1"
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of IP addresses that are allowed to access this webhook.
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-sm mb-2">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Copy the Webhook URL above</li>
<li>Configure your service to send HTTP POST requests to this URL</li>
{requireAuth && (
<>
<li>
{secretHeaderName
? `Add the "${secretHeaderName}" header with your token to all requests`
: 'Add an "Authorization: Bearer YOUR_TOKEN" header to all requests'}
</li>
</>
)}
</ol>
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
The webhook will receive all HTTP POST requests and pass the data to your
workflow.
</p>
</div>
</div>
{testResult && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`p-3 rounded-md ${
testResult.success
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300 border border-green-200 dark:border-green-800'
: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 border border-red-200 dark:border-red-800'
}`}
>
<p className="text-sm">{testResult.message}</p>
</motion.div>
)}
</div>
)
}
@@ -430,7 +858,7 @@ export function WebhookModal({
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center">
@@ -482,61 +910,59 @@ export function WebhookModal({
{renderProviderContent()}
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-y-0 space-y-2 gap-2 mt-4">
{webhookId && (
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto sm:mr-auto">
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting || isLoadingToken}
className="w-full sm:w-auto"
size="sm"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="mr-2 h-3 w-3" />
Delete
</>
)}
</Button>
{webhookProvider === 'whatsapp' && (
<DialogFooter className="flex justify-between mt-6 sticky bottom-0 py-3 bg-background border-t z-10">
<div>
{webhookId && (
<div className="flex space-x-3">
<Button
variant="outline"
onClick={testWebhook}
disabled={isTesting || isLoadingToken}
className="w-full sm:w-auto"
size="sm"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting || isLoadingToken}
>
{isTesting ? (
{isDeleting ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Testing...
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Test'
<>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</>
)}
</Button>
)}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={onClose} className="w-full sm:w-auto" size="sm">
{(webhookProvider === 'whatsapp' || webhookProvider === 'generic') && (
<Button
variant="outline"
onClick={testWebhook}
disabled={isTesting || isLoadingToken}
>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
'Test'
)}
</Button>
)}
</div>
)}
</div>
<div className="flex space-x-3">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
variant="default"
onClick={handleSave}
disabled={isSaving || isLoadingToken}
className="w-full sm:w-auto"
size="sm"
className="bg-primary"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
@@ -564,6 +990,23 @@ export function WebhookModal({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showUnsavedChangesConfirm} onOpenChange={setShowUnsavedChangesConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to close without saving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelClose}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmClose} className="bg-red-600 hover:bg-red-700">
Discard changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -38,8 +38,20 @@ export interface StripeConfig {
// Any Stripe-specific fields would go here
}
export interface GeneralWebhookConfig {
token?: string
secretHeaderName?: string
requireAuth?: boolean
allowedIps?: string[]
}
// Union type for all provider configurations
export type ProviderConfig = WhatsAppConfig | GitHubConfig | StripeConfig | Record<string, never>
export type ProviderConfig =
| WhatsAppConfig
| GitHubConfig
| StripeConfig
| GeneralWebhookConfig
| Record<string, never>
// Define available webhook providers
export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
@@ -78,9 +90,35 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
},
generic: {
id: 'generic',
name: 'Generic',
name: 'General',
icon: (props) => <CheckCircle2 {...props} />,
configFields: {},
configFields: {
token: {
type: 'string',
label: 'Authentication Token',
placeholder: 'Enter an auth token (optional)',
description:
'This token will be used to authenticate webhook requests via Bearer token authentication.',
},
secretHeaderName: {
type: 'string',
label: 'Secret Header Name',
placeholder: 'X-Secret-Key',
description: 'Custom HTTP header name for authentication (optional).',
},
requireAuth: {
type: 'boolean',
label: 'Require Authentication',
defaultValue: false,
description: 'Require authentication for all webhook requests.',
},
allowedIps: {
type: 'string',
label: 'Allowed IP Addresses',
placeholder: '10.0.0.1, 192.168.1.1',
description: 'Comma-separated list of allowed IP addresses (optional).',
},
},
},
}
@@ -210,7 +248,7 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
}
const handleDeleteWebhook = async () => {
if (!webhookId) return
if (!webhookId) return false
try {
setIsDeleting(true)