From 2e0ddcfe432aac695da44c0bd2756f77878f8f2b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 18 Apr 2026 18:10:58 -0700 Subject: [PATCH] feat(web): Phase C.4 webhook management UI + client --- web/src/app/webhooks/page.tsx | 223 ++++++++++++++++++++++++++++++++++ web/src/lib/webhook-client.ts | 78 ++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 web/src/app/webhooks/page.tsx create mode 100644 web/src/lib/webhook-client.ts diff --git a/web/src/app/webhooks/page.tsx b/web/src/app/webhooks/page.tsx new file mode 100644 index 0000000..cb086ea --- /dev/null +++ b/web/src/app/webhooks/page.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, Plus, Trash2, Zap, RefreshCw, Send, Globe } from 'lucide-react'; +import { + listSubscriptions, + createSubscription, + deleteSubscription, + testWebhook, + getEventTypes, + type WebhookSubscription, + type EventType, +} from '@/lib/webhook-client'; + +export default function WebhooksPage() { + const [subs, setSubs] = useState([]); + const [eventTypes, setEventTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newUrl, setNewUrl] = useState(''); + const [newEvents, setNewEvents] = useState([]); + const [newDesc, setNewDesc] = useState(''); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [subsData, typesData] = await Promise.all([listSubscriptions(), getEventTypes()]); + setSubs(subsData); + setEventTypes(typesData.eventTypes); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load'); + } + setLoading(false); + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const handleCreate = async () => { + if (!newUrl.trim() || newEvents.length === 0) { + setError('URL and at least one event type are required'); + return; + } + try { + await createSubscription({ url: newUrl.trim(), events: newEvents, description: newDesc.trim() || undefined }); + setShowCreate(false); + setNewUrl(''); + setNewEvents([]); + setNewDesc(''); + loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create'); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteSubscription(id); + loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete'); + } + }; + + const handleTest = async (id: string) => { + try { + await testWebhook(id); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Test failed'); + } + }; + + const toggleEvent = (type: string) => { + setNewEvents(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]); + }; + + return ( +
+
+ {/* Header */} +
+ + + +
+

Webhooks

+

Integrate ChronoMind with external services

+
+ +
+ + {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* Create form */} + {showCreate && ( +
+

New Webhook

+ setNewUrl(e.target.value)} + placeholder="https://example.com/webhook" + className="w-full px-3 py-2 rounded-lg text-sm mb-3" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> + setNewDesc(e.target.value)} + placeholder="Description (optional)" + className="w-full px-3 py-2 rounded-lg text-sm mb-3" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> +

Events

+
+ {eventTypes.map(et => ( + + ))} +
+ +
+ )} + + {/* Subscriptions list */} + {loading ? ( +
+ +
+ ) : subs.length === 0 ? ( +
+ +

No webhooks configured.

+
+ ) : ( +
+ {subs.map(sub => ( +
+
+
+
+ + + {sub.url} + +
+ {sub.description && ( +

{sub.description}

+ )} +
+ {sub.events.map(evt => ( + + {evt} + + ))} +
+
+
+
+ + {sub.consecutiveFailures > 0 ? `${sub.consecutiveFailures} failures` : 'Healthy'} + +
+ + +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/src/lib/webhook-client.ts b/web/src/lib/webhook-client.ts new file mode 100644 index 0000000..d1db92f --- /dev/null +++ b/web/src/lib/webhook-client.ts @@ -0,0 +1,78 @@ +/** + * Webhook management API client — talks to ChronoMind backend. + */ + +import { getBackendBaseURL } from './product-config'; + +function getToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('chronomind_access_token'); +} + +async function apiFetch(path: string, opts?: RequestInit): Promise { + const token = getToken(); + const res = await fetch(`${getBackendBaseURL()}${path}`, { + ...opts, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// ── Types ── + +export interface WebhookSubscription { + id: string; + url: string; + events: string[]; + active: boolean; + secret: string; + description?: string; + createdAt: string; + consecutiveFailures: number; +} + +export interface EventType { + type: string; + category: string; + action: string; +} + +// ── API calls ── + +export async function listSubscriptions(): Promise { + return apiFetch('/api/webhooks'); +} + +export async function createSubscription(data: { + url: string; + events: string[]; + description?: string; +}): Promise { + return apiFetch('/api/webhooks', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function deleteSubscription(id: string): Promise { + await apiFetch(`/api/webhooks/${id}`, { method: 'DELETE' }); +} + +export async function testWebhook(subscriptionId: string, eventType?: string): Promise { + return apiFetch('/api/webhooks/test', { + method: 'POST', + body: JSON.stringify({ subscriptionId, eventType }), + }); +} + +export async function getEventTypes(): Promise<{ eventTypes: EventType[] }> { + return apiFetch<{ eventTypes: EventType[] }>('/api/webhooks/event-types'); +}