/ src / components / webhooks / webhook-sheet.tsx
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  }