template-saas/apps/frontend/src/components/webhooks/WebhookForm.tsx
Adrian Flores Cortes f853c49568
Some checks are pending
CI / Backend CI (push) Waiting to run
CI / Frontend CI (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / CI Summary (push) Blocked by required conditions
feat(frontend): Align documentation with development - complete frontend audit
- Add Sales/Commissions routes to router (11 new routes)
- Complete authStore (refreshTokens, updateProfile methods)
- Add JSDoc documentation to 40+ components across all categories
- Create FRONTEND-ROUTING.md with complete route mapping
- Create FRONTEND-PAGES-SPEC.md with 38 page specifications
- Update FRONTEND_INVENTORY.yml to v4.1.0 with resolved gaps

Components documented: ai/, audit/, commissions/, feature-flags/,
notifications/, sales/, storage/, webhooks/, whatsapp/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:23:50 -06:00

330 lines
12 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Webhook, CreateWebhookRequest, UpdateWebhookRequest, WebhookEvent } from '@/services/api';
import { X, Plus, Trash2, Eye, EyeOff, Copy, Check } from 'lucide-react';
import clsx from 'clsx';
/**
* Props for the WebhookForm component.
*/
interface WebhookFormProps {
/** Existing webhook data for editing, or null/undefined for creating a new webhook */
webhook?: Webhook | null;
/** List of available webhook events that can be subscribed to */
events: WebhookEvent[];
/** Callback invoked when the form is submitted with valid data */
onSubmit: (data: CreateWebhookRequest | UpdateWebhookRequest) => void;
/** Callback invoked when the user cancels the form */
onCancel: () => void;
/** Whether the form submission is in progress */
isLoading?: boolean;
}
/**
* Form component for creating or editing webhook configurations.
*
* @description Provides a comprehensive form for webhook management including:
* - Name and description fields
* - HTTPS endpoint URL with validation
* - Event selection checkboxes
* - Custom HTTP headers management
* - Signing secret display (edit mode only)
*
* @param props - The component props
* @param props.webhook - Existing webhook data for editing, or null for creating
* @param props.events - List of available webhook events to subscribe to
* @param props.onSubmit - Callback when form is submitted with valid data
* @param props.onCancel - Callback when user cancels the form
* @param props.isLoading - Whether submission is in progress (disables form)
*
* @example
* ```tsx
* // Creating a new webhook
* <WebhookForm
* events={availableEvents}
* onSubmit={(data) => createWebhook(data)}
* onCancel={() => closeModal()}
* />
*
* // Editing an existing webhook
* <WebhookForm
* webhook={existingWebhook}
* events={availableEvents}
* onSubmit={(data) => updateWebhook(existingWebhook.id, data)}
* onCancel={() => closeModal()}
* isLoading={isSaving}
* />
* ```
*/
export function WebhookForm({
webhook,
events,
onSubmit,
onCancel,
isLoading,
}: WebhookFormProps) {
const [name, setName] = useState(webhook?.name || '');
const [description, setDescription] = useState(webhook?.description || '');
const [url, setUrl] = useState(webhook?.url || '');
const [selectedEvents, setSelectedEvents] = useState<string[]>(webhook?.events || []);
const [headers, setHeaders] = useState<{ key: string; value: string }[]>(
webhook?.headers
? Object.entries(webhook.headers).map(([key, value]) => ({ key, value }))
: []
);
const [showSecret, setShowSecret] = useState(false);
const [secretCopied, setSecretCopied] = useState(false);
const isEditing = !!webhook;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const headersObj = headers.reduce((acc, { key, value }) => {
if (key.trim()) {
acc[key.trim()] = value;
}
return acc;
}, {} as Record<string, string>);
const data = {
name,
description: description || undefined,
url,
events: selectedEvents,
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
};
onSubmit(data);
};
const toggleEvent = (eventName: string) => {
setSelectedEvents((prev) =>
prev.includes(eventName)
? prev.filter((e) => e !== eventName)
: [...prev, eventName]
);
};
const addHeader = () => {
setHeaders((prev) => [...prev, { key: '', value: '' }]);
};
const removeHeader = (index: number) => {
setHeaders((prev) => prev.filter((_, i) => i !== index));
};
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
setHeaders((prev) =>
prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))
);
};
const copySecret = async () => {
if (webhook?.secret) {
await navigator.clipboard.writeText(webhook.secret);
setSecretCopied(true);
setTimeout(() => setSecretCopied(false), 2000);
}
};
const isValid = name.trim() && url.trim() && url.startsWith('https://') && selectedEvents.length > 0;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Webhook"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
/>
</div>
{/* URL */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Endpoint URL *
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
required
/>
{url && !url.startsWith('https://') && (
<p className="mt-1 text-sm text-red-600">URL must use HTTPS</p>
)}
</div>
{/* Secret (only for editing) */}
{isEditing && webhook?.secret && (
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Signing Secret
</label>
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<input
type={showSecret ? 'text' : 'password'}
value={webhook.secret}
readOnly
className="w-full px-3 py-2 pr-20 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-secondary-100 font-mono text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
onClick={() => setShowSecret(!showSecret)}
className="p-1 text-secondary-400 hover:text-secondary-600"
>
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={copySecret}
className="p-1 text-secondary-400 hover:text-secondary-600"
>
{secretCopied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500">
Use this secret to verify webhook signatures
</p>
</div>
)}
{/* Events */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Events * ({selectedEvents.length} selected)
</label>
<div className="border border-secondary-300 dark:border-secondary-600 rounded-lg p-3 max-h-48 overflow-y-auto">
<div className="space-y-2">
{events.map((event) => (
<label
key={event.name}
className="flex items-start gap-3 p-2 rounded hover:bg-secondary-50 dark:hover:bg-secondary-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedEvents.includes(event.name)}
onChange={() => toggleEvent(event.name)}
className="mt-0.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
{event.name}
</div>
<div className="text-xs text-secondary-500">{event.description}</div>
</div>
</label>
))}
</div>
</div>
{selectedEvents.length === 0 && (
<p className="mt-1 text-sm text-red-600">Select at least one event</p>
)}
</div>
{/* Custom Headers */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Custom Headers
</label>
<button
type="button"
onClick={addHeader}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="w-4 h-4" />
Add Header
</button>
</div>
{headers.length > 0 && (
<div className="space-y-2">
{headers.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
placeholder="Header name"
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
/>
<input
type="text"
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
placeholder="Value"
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
/>
<button
type="button"
onClick={() => removeHeader(index)}
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{headers.length === 0 && (
<p className="text-sm text-secondary-500">No custom headers</p>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className={clsx(
'px-4 py-2 rounded-lg font-medium',
isValid && !isLoading
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-secondary-200 text-secondary-400 cursor-not-allowed'
)}
>
{isLoading ? 'Saving...' : isEditing ? 'Update Webhook' : 'Create Webhook'}
</button>
</div>
</form>
);
}