webhook-sheet.tsx
1 'use client' 2 3 import { useEffect, useMemo, useState } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { BottomSheet } from '@/components/shared/bottom-sheet' 6 import { ConfirmDialog } from '@/components/shared/confirm-dialog' 7 import { api } from '@/lib/app/api-client' 8 import { copyTextToClipboard } from '@/lib/clipboard' 9 import type { Webhook, WebhookLogEntry } from '@/types' 10 import { dedup } from '@/lib/shared-utils' 11 import { toast } from 'sonner' 12 13 type WebhookApiResponse = Webhook | { error: string } 14 type DeleteWebhookResponse = { ok: boolean } | { error: string } 15 16 const inputClass = 'w-full px-4 py-3 rounded-[14px] bg-bg border border-white/[0.06] text-text text-[14px] outline-none focus:border-accent-bright/40 transition-colors placeholder:text-text-3/70' 17 18 function webhookPath(id: string): string { 19 return `/api/webhooks/${id}` 20 } 21 22 function parseEvents(input: string): string[] { 23 const values = input 24 .split(/[\n,]+/) 25 .map((v) => v.trim()) 26 .filter(Boolean) 27 return dedup(values) 28 } 29 30 function makeSecret(length = 28): string { 31 const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' 32 const arr = new Uint8Array(length) 33 if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { 34 crypto.getRandomValues(arr) 35 } else { 36 for (let i = 0; i < length; i++) arr[i] = Math.floor(Math.random() * 256) 37 } 38 let out = '' 39 for (let i = 0; i < length; i++) out += chars[arr[i] % chars.length] 40 return out 41 } 42 43 export function WebhookSheet() { 44 const open = useAppStore((s) => s.webhookSheetOpen) 45 const setOpen = useAppStore((s) => s.setWebhookSheetOpen) 46 const editingId = useAppStore((s) => s.editingWebhookId) 47 const setEditingId = useAppStore((s) => s.setEditingWebhookId) 48 const webhooks = useAppStore((s) => s.webhooks) 49 const loadWebhooks = useAppStore((s) => s.loadWebhooks) 50 const agents = useAppStore((s) => s.agents) 51 const loadAgents = useAppStore((s) => s.loadAgents) 52 53 const [name, setName] = useState('') 54 const [source, setSource] = useState('custom') 55 const [eventsText, setEventsText] = useState('') 56 const [agentId, setAgentId] = useState('') 57 const [secret, setSecret] = useState('') 58 const [isEnabled, setIsEnabled] = useState(true) 59 const [saving, setSaving] = useState(false) 60 const [copied, setCopied] = useState<'endpoint' | 'secret' | null>(null) 61 const [error, setError] = useState<string | null>(null) 62 const [tab, setTab] = useState<'config' | 'history'>('config') 63 const [history, setHistory] = useState<WebhookLogEntry[]>([]) 64 const [historyLoading, setHistoryLoading] = useState(false) 65 const [confirmDelete, setConfirmDelete] = useState(false) 66 const [deleting, setDeleting] = useState(false) 67 68 const editing = editingId ? (webhooks[editingId] as Webhook | undefined) : null 69 const endpoint = editing ? webhookPath(editing.id) : '' 70 const eligibleAgents = useMemo( 71 () => Object.values(agents), 72 [agents] 73 ) 74 75 useEffect(() => { 76 if (open) { 77 loadWebhooks() 78 loadAgents() 79 setCopied(null) 80 setError(null) 81 setTab('config') 82 setHistory([]) 83 } 84 }, [open, loadWebhooks, loadAgents]) 85 86 useEffect(() => { 87 if (tab === 'history' && editing) { 88 setHistoryLoading(true) 89 api<WebhookLogEntry[]>('GET', `/webhooks/${editing.id}/history`) 90 .then((res) => setHistory(Array.isArray(res) ? res : [])) 91 .catch(() => setHistory([])) 92 .finally(() => setHistoryLoading(false)) 93 } 94 }, [tab, editing]) 95 96 useEffect(() => { 97 if (editing) { 98 setName(editing.name || '') 99 setSource(editing.source || 'custom') 100 setEventsText((editing.events || []).join(', ')) 101 setAgentId(editing.agentId || '') 102 setSecret(editing.secret || '') 103 setIsEnabled(editing.isEnabled !== false) 104 } else { 105 setName('') 106 setSource('custom') 107 setEventsText('') 108 setAgentId('') 109 setSecret(makeSecret()) 110 setIsEnabled(true) 111 } 112 }, [editing, open]) 113 114 const handleClose = () => { 115 setConfirmDelete(false) 116 setDeleting(false) 117 setOpen(false) 118 setEditingId(null) 119 } 120 121 const copyText = async (type: 'endpoint' | 'secret', value: string) => { 122 if (!value) return 123 try { 124 const copied = await copyTextToClipboard(value) 125 if (!copied) return 126 setCopied(type) 127 setTimeout(() => setCopied((prev) => (prev === type ? null : prev)), 1500) 128 } catch { 129 // ignore clipboard errors 130 } 131 } 132 133 const handleSave = async () => { 134 if (!agentId) { 135 setError('Choose an eligible agent to handle this webhook.') 136 return 137 } 138 139 const payload = { 140 name: name.trim() || 'Unnamed Webhook', 141 source: source.trim() || 'custom', 142 events: parseEvents(eventsText), 143 agentId: agentId || null, 144 secret: secret.trim(), 145 isEnabled, 146 } 147 148 setSaving(true) 149 setError(null) 150 try { 151 if (editing) { 152 const updated = await api<WebhookApiResponse>('PUT', `/webhooks/${editing.id}`, payload) 153 if ('error' in updated && updated.error) throw new Error(updated.error) 154 toast.success('Webhook updated successfully') 155 } else { 156 const created = await api<WebhookApiResponse>('POST', '/webhooks', payload) 157 if ('error' in created && created.error) throw new Error(created.error) 158 toast.success('Webhook created successfully') 159 } 160 await loadWebhooks() 161 handleClose() 162 } catch (err: unknown) { 163 const msg = err instanceof Error ? err.message : 'Failed to save webhook' 164 setError(msg) 165 toast.error(msg) 166 } finally { 167 setSaving(false) 168 } 169 } 170 171 const handleDelete = async () => { 172 if (!editing) return 173 setDeleting(true) 174 try { 175 const res = await api<DeleteWebhookResponse>('DELETE', `/webhooks/${editing.id}`) 176 if ('error' in res && res.error) throw new Error(res.error) 177 toast.success('Webhook deleted') 178 await loadWebhooks() 179 setConfirmDelete(false) 180 handleClose() 181 } catch (err: unknown) { 182 const msg = err instanceof Error ? err.message : 'Failed to delete webhook' 183 setError(msg) 184 toast.error(msg) 185 } finally { 186 setDeleting(false) 187 } 188 } 189 190 return ( 191 <BottomSheet open={open} onClose={handleClose} wide> 192 <div className="space-y-6"> 193 <div> 194 <h2 className="font-display text-[24px] font-700 tracking-[-0.02em] mb-1"> 195 {editing ? 'Edit Webhook' : 'New Webhook'} 196 </h2> 197 <p className="text-[13px] text-text-3">Create an inbound endpoint that launches an agent workflow</p> 198 </div> 199 200 {editing && ( 201 <div className="flex gap-1 p-1 rounded-[12px] bg-bg border border-white/[0.06]"> 202 {(['config', 'history'] as const).map((t) => ( 203 <button 204 key={t} 205 onClick={() => setTab(t)} 206 className={`flex-1 py-2 rounded-[10px] text-center cursor-pointer transition-all text-[13px] font-600 border-none capitalize ${ 207 tab === t ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2' 208 }`} 209 style={{ fontFamily: 'inherit' }} 210 > 211 {t} 212 </button> 213 ))} 214 </div> 215 )} 216 217 {tab === 'history' && editing ? ( 218 <div> 219 {historyLoading ? ( 220 <div className="text-center py-8 text-[13px] text-text-3">Loading history...</div> 221 ) : history.length === 0 ? ( 222 <div className="text-center py-8 text-[13px] text-text-3/60">No webhook invocations yet</div> 223 ) : ( 224 <div className="space-y-2 max-h-[400px] overflow-y-auto"> 225 {history.map((entry) => ( 226 <div key={entry.id} className="p-3 rounded-[10px] border border-white/[0.06] bg-white/[0.02]"> 227 <div className="flex items-center gap-2 mb-1"> 228 <span className={`text-[10px] font-700 uppercase tracking-wider px-1.5 py-0.5 rounded-[4px] ${ 229 entry.status === 'success' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400' 230 }`}> 231 {entry.status} 232 </span> 233 <span className="text-[11px] text-text-3/60 font-mono">{entry.event}</span> 234 <span className="text-[10px] text-text-3/40 ml-auto"> 235 {new Date(entry.timestamp).toLocaleString()} 236 </span> 237 </div> 238 {entry.error && ( 239 <div className="text-[11px] text-red-300/80 mt-1">{entry.error}</div> 240 )} 241 {entry.sessionId && ( 242 <div className="text-[10px] text-text-3/50 mt-1 font-mono">Chat: {entry.sessionId}</div> 243 )} 244 </div> 245 ))} 246 </div> 247 )} 248 </div> 249 ) : null} 250 251 {tab === 'config' && error && ( 252 <div className="px-3.5 py-2.5 rounded-[12px] bg-red-500/10 border border-red-500/20 text-[12px] text-red-300"> 253 {error} 254 </div> 255 )} 256 257 {tab === 'config' && editing && ( 258 <div className="p-4 rounded-[14px] bg-white/[0.02] border border-white/[0.06]"> 259 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Endpoint URL</label> 260 <div className="flex gap-2"> 261 <input 262 readOnly 263 value={endpoint} 264 className={`${inputClass} font-mono text-[12px]`} 265 /> 266 <button 267 onClick={() => copyText('endpoint', `${window.location.origin}${endpoint}`)} 268 className="px-3.5 py-2 rounded-[10px] border border-accent-bright/20 bg-accent-soft/40 text-accent-bright text-[12px] font-600 cursor-pointer hover:bg-accent-soft transition-colors" 269 style={{ fontFamily: 'inherit' }} 270 > 271 {copied === 'endpoint' ? 'Copied' : 'Copy'} 272 </button> 273 </div> 274 <p className="mt-2 text-[11px] text-text-3/70"> 275 POST JSON payloads to this URL. Include <code className="font-mono">x-webhook-secret</code> if a secret is set. 276 </p> 277 </div> 278 )} 279 280 {tab === 'config' && <> 281 <div> 282 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Name</label> 283 <input 284 type="text" 285 value={name} 286 onChange={(e) => setName(e.target.value)} 287 placeholder="e.g. GitHub Push" 288 className={inputClass} 289 style={{ fontFamily: 'inherit' }} 290 /> 291 </div> 292 293 <div> 294 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Source</label> 295 <input 296 type="text" 297 value={source} 298 onChange={(e) => setSource(e.target.value)} 299 placeholder="custom, github, slack..." 300 className={inputClass} 301 style={{ fontFamily: 'inherit' }} 302 /> 303 </div> 304 305 <div> 306 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Route to Agent</label> 307 <select 308 value={agentId} 309 onChange={(e) => setAgentId(e.target.value)} 310 className={`${inputClass} appearance-none cursor-pointer`} 311 style={{ fontFamily: 'inherit' }} 312 > 313 <option value="">Select agent...</option> 314 {eligibleAgents.map((agent) => ( 315 <option key={agent.id} value={agent.id}>{agent.name}</option> 316 ))} 317 </select> 318 </div> 319 320 <div> 321 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2"> 322 Events <span className="normal-case tracking-normal font-normal text-text-3/70">(optional)</span> 323 </label> 324 <textarea 325 value={eventsText} 326 onChange={(e) => setEventsText(e.target.value)} 327 placeholder="push, release or *" 328 rows={3} 329 className={`${inputClass} resize-y min-h-[86px] font-mono text-[12px]`} 330 style={{ fontFamily: 'inherit' }} 331 /> 332 <p className="mt-1.5 text-[11px] text-text-3/70">Leave blank for all events. Use commas or new lines. Use <code>*</code> to match all.</p> 333 </div> 334 335 <div> 336 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2"> 337 Secret <span className="normal-case tracking-normal font-normal text-text-3/70">(optional but recommended)</span> 338 </label> 339 <div className="flex gap-2"> 340 <input 341 type="text" 342 value={secret} 343 onChange={(e) => setSecret(e.target.value)} 344 placeholder="x-webhook-secret value" 345 className={`${inputClass} font-mono text-[12px]`} 346 style={{ fontFamily: 'inherit' }} 347 /> 348 <button 349 onClick={() => copyText('secret', secret)} 350 disabled={!secret.trim()} 351 className="px-3.5 py-2 rounded-[10px] border border-white/[0.1] bg-white/[0.04] text-text-2 text-[12px] font-600 cursor-pointer hover:bg-white/[0.08] transition-colors disabled:opacity-40" 352 style={{ fontFamily: 'inherit' }} 353 > 354 {copied === 'secret' ? 'Copied' : 'Copy'} 355 </button> 356 <button 357 onClick={() => setSecret(makeSecret())} 358 className="px-3.5 py-2 rounded-[10px] border border-accent-bright/20 bg-accent-soft/40 text-accent-bright text-[12px] font-600 cursor-pointer hover:bg-accent-soft transition-colors" 359 style={{ fontFamily: 'inherit' }} 360 > 361 Regenerate 362 </button> 363 </div> 364 </div> 365 366 <div> 367 <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Status</label> 368 <div className="flex p-1 rounded-[12px] bg-bg border border-white/[0.06]"> 369 <button 370 onClick={() => setIsEnabled(true)} 371 className={`flex-1 py-2.5 rounded-[10px] text-center cursor-pointer transition-all text-[13px] font-600 border-none ${ 372 isEnabled ? 'bg-emerald-500/15 text-emerald-300' : 'bg-transparent text-text-3 hover:text-text-2' 373 }`} 374 style={{ fontFamily: 'inherit' }} 375 > 376 Enabled 377 </button> 378 <button 379 onClick={() => setIsEnabled(false)} 380 className={`flex-1 py-2.5 rounded-[10px] text-center cursor-pointer transition-all text-[13px] font-600 border-none ${ 381 !isEnabled ? 'bg-white/[0.08] text-text-2' : 'bg-transparent text-text-3 hover:text-text-2' 382 }`} 383 style={{ fontFamily: 'inherit' }} 384 > 385 Disabled 386 </button> 387 </div> 388 </div> 389 390 <div className="flex gap-3 pt-2"> 391 {editing && ( 392 <button 393 onClick={() => setConfirmDelete(true)} 394 className="px-5 py-3 rounded-[14px] border border-danger/30 bg-transparent text-danger text-[14px] font-600 cursor-pointer hover:bg-danger/10 transition-colors" 395 style={{ fontFamily: 'inherit' }} 396 > 397 Delete 398 </button> 399 )} 400 <div className="flex-1" /> 401 <button 402 onClick={handleClose} 403 className="px-5 py-3 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[14px] font-600 cursor-pointer hover:bg-surface-2 transition-colors" 404 style={{ fontFamily: 'inherit' }} 405 > 406 Cancel 407 </button> 408 <button 409 onClick={handleSave} 410 disabled={saving} 411 className="px-8 py-3 rounded-[14px] border-none bg-accent-bright text-white text-[14px] font-600 cursor-pointer disabled:opacity-30 transition-all hover:brightness-110" 412 style={{ fontFamily: 'inherit' }} 413 > 414 {saving ? 'Saving...' : editing ? 'Update' : 'Create'} 415 </button> 416 </div> 417 </>} 418 </div> 419 <ConfirmDialog 420 open={confirmDelete} 421 title="Delete Webhook?" 422 message={editing ? `Delete "${editing.name}"? This will remove the endpoint and its saved webhook configuration.` : 'Delete this webhook?'} 423 confirmLabel={deleting ? 'Deleting...' : 'Delete'} 424 confirmDisabled={deleting} 425 cancelDisabled={deleting} 426 danger 427 onConfirm={() => { void handleDelete() }} 428 onCancel={() => { if (!deleting) setConfirmDelete(false) }} 429 /> 430 </BottomSheet> 431 ) 432 }