feat(CC-081): add Webhooks page (create/list with HMAC security info)
This commit is contained in:
parent
7b634ad0ec
commit
c6e7fa35b7
1 changed files with 127 additions and 0 deletions
127
src/app/dashboard/integrations/webhooks/page.tsx
Normal file
127
src/app/dashboard/integrations/webhooks/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface DataSource { id: string; name: string; type: string; status: string; config: Record<string, unknown> | null; }
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
'contact.created', 'contact.updated', 'document.created',
|
||||
'task.completed', 'decision.created', 'approval.required',
|
||||
'anomaly.detected', 'contract.expiring',
|
||||
];
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', url: '', events: [] as string[], secret: '' });
|
||||
|
||||
const { data: sources = [], isLoading } = useQuery({
|
||||
queryKey: ['webhooks-sources', tenantId],
|
||||
queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const webhookSources = sources.filter((s) => s.type === 'webhook' || s.config?.type === 'webhook');
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/data-sources', { tenantId, method: 'POST', body: {
|
||||
name: form.name, type: 'webhook',
|
||||
config: { url: form.url, events: form.events, secret: form.secret || undefined },
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['webhooks-sources', tenantId] }); setShowCreate(false); setForm({ name: '', url: '', events: [], secret: '' }); },
|
||||
});
|
||||
|
||||
function toggleEvent(e: string) {
|
||||
setForm((p) => ({ ...p, events: p.events.includes(e) ? p.events.filter((x) => x !== e) : [...p.events, e] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Webhooks</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${webhookSources.length} webhook-uri configurate`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Webhook nou
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4">
|
||||
<p className="text-sm font-semibold text-ink">Webhook nou</p>
|
||||
<div className="grid gap-3">
|
||||
<input placeholder="Nume webhook" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="URL destinație (https://...)" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Secret (opțional, pentru HMAC signature)" value={form.secret} onChange={(e) => setForm((p) => ({ ...p, secret: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring font-mono text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-ink-faint mb-2">Evenimente</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEBHOOK_EVENTS.map((ev) => (
|
||||
<button key={ev} onClick={() => toggleEvent(ev)}
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-mono ${form.events.includes(ev) ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate()} disabled={!form.name || !form.url || form.events.length === 0 || createMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4 bg-primary/5 border-primary/20 space-y-2">
|
||||
<p className="text-xs font-semibold text-ink">Securitate webhook</p>
|
||||
<div className="text-[10px] text-ink-faint space-y-0.5">
|
||||
<p>• Toate webhook-urile sunt semnate HMAC-SHA256 cu secretul configurat.</p>
|
||||
<p>• Header-ul <code className="bg-muted px-1 rounded">X-CEO-OS-Signature</code> conține semnătura.</p>
|
||||
<p>• Timestamp-ul este inclus pentru protecție împotriva replay attacks.</p>
|
||||
<p>• Timeout de livrare: 10s. Retry: 3 încercări cu backoff exponențial.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-4">Se încarcă…</div>
|
||||
) : webhookSources.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🔗</p>
|
||||
<p className="text-sm text-ink-faint">Niciun webhook configurat.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{webhookSources.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-4 p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink">{s.name}</p>
|
||||
<p className="text-[10px] text-ink-faint font-mono truncate">{(s.config as Record<string, string>)?.url ?? '—'}</p>
|
||||
</div>
|
||||
<span className={`text-[9px] rounded-full px-2 py-0.5 font-bold ${s.status === 'active' ? 'bg-signal-ok/10 text-signal-ok' : 'bg-muted text-ink-faint'}`}>
|
||||
{s.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/dashboard/integrations" className="text-xs text-primary hover:underline">← Toate integrările</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue