mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
60
package-lock.json
generated
Normal 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
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.5.0"
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user