Files
leocrm/frontend/src/pages/SettingsWebhooks.tsx
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

630 lines
22 KiB
TypeScript

// TODO: P2-F20 — Replace hardcoded AVAILABLE_EVENTS with backend config
/**
* SettingsWebhooks page — Webhook management.
*
* Features:
* - Table listing all webhooks with URL, events, status toggle, test/edit/delete buttons
* - Create/edit modal: URL, events (multi-select checkboxes), secret, retry_count, timeout
* - Test button sends test payload, shows result
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import { Plus, Pencil, Trash2, AlertTriangle, Send, Webhook as WebhookIcon, CheckCircle, XCircle } from 'lucide-react';
import {
fetchWebhooks,
createWebhook,
updateWebhook,
deleteWebhook,
testWebhook,
useWebhooks,
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
useTestWebhook,
type Webhook,
type CreateWebhookPayload,
type UpdateWebhookPayload,
type WebhookTestResult,
} from '@/api/webhooks';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
// ─── Available Events ───────────────────────────────────────────────────────
const AVAILABLE_EVENTS = [
'contact.created',
'contact.updated',
'contact.deleted',
'company.created',
'company.updated',
'company.deleted',
'user.created',
'user.updated',
'deal.created',
'deal.updated',
'deal.deleted',
'task.created',
'task.updated',
'task.deleted',
'note.created',
'note.updated',
'note.deleted',
];
// ─── Webhook Form Modal (shared for create & edit) ───────────────────────────
interface WebhookFormModalProps {
open: boolean;
onClose: () => void;
webhook?: Webhook | null;
onSubmit: (data: CreateWebhookPayload | UpdateWebhookPayload) => void;
isSubmitting: boolean;
error?: string | null;
}
function WebhookFormModal({ open, onClose, webhook, onSubmit, isSubmitting, error }: WebhookFormModalProps) {
const { t } = useTranslation();
const isEdit = !!webhook;
const [url, setUrl] = useState(webhook?.url ?? '');
const [events, setEvents] = useState<string[]>(webhook?.events ?? []);
const [secret, setSecret] = useState(webhook?.secret ?? '');
const [retryCount, setRetryCount] = useState(webhook?.retry_count ?? 3);
const [timeoutSeconds, setTimeoutSeconds] = useState(webhook?.timeout_seconds ?? 30);
// Reset form when modal opens or webhook changes
React.useEffect(() => {
if (open) {
setUrl(webhook?.url ?? '');
setEvents(webhook?.events ?? []);
setSecret(webhook?.secret ?? '');
setRetryCount(webhook?.retry_count ?? 3);
setTimeoutSeconds(webhook?.timeout_seconds ?? 30);
}
}, [open, webhook]);
const toggleEvent = useCallback((event: string) => {
setEvents((prev) =>
prev.includes(event)
? prev.filter((e) => e !== event)
: [...prev, event]
);
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const trimmedUrl = url.trim();
if (!trimmedUrl || events.length === 0) return;
const data: CreateWebhookPayload | UpdateWebhookPayload = {
url: trimmedUrl,
events,
secret: secret.trim() || null,
retry_count: retryCount,
timeout_seconds: timeoutSeconds,
};
if (!isEdit) {
(data as CreateWebhookPayload).is_active = true;
}
onSubmit(data);
},
[url, events, secret, retryCount, timeoutSeconds, isEdit, onSubmit]
);
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? t('webhooks.editTitle', 'Webhook bearbeiten') : t('webhooks.createTitle', 'Neuer Webhook')}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* URL */}
<Input
label={t('webhooks.url', 'URL')}
value={url}
onChange={(e) => setUrl(e.target.value)}
required
placeholder={t('webhooks.urlPlaceholder', 'https://example.com/webhook')}
autoFocus
data-testid="webhook-form-url"
/>
{/* Events multi-select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
{t('webhooks.events', 'Events')}
</label>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-secondary-200 rounded-md p-2">
{AVAILABLE_EVENTS.map((event) => (
<label
key={event}
className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm',
'hover:bg-secondary-50 transition-colors',
events.includes(event) ? 'bg-primary-50 text-primary-700' : 'text-secondary-700'
)}
>
<input
type="checkbox"
checked={events.includes(event)}
onChange={() => toggleEvent(event)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span>{event}</span>
</label>
))}
</div>
{events.length === 0 && (
<p className="text-xs text-danger-600 mt-1">
{t('webhooks.eventsRequired', 'Mindestens ein Event erforderlich')}
</p>
)}
</div>
{/* Secret */}
<Input
label={t('webhooks.secret', 'Secret (optional)')}
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={t('webhooks.secretPlaceholder', 'HMAC-Signing Secret')}
type="password"
data-testid="webhook-form-secret"
/>
{/* Retry Count & Timeout */}
<div className="grid grid-cols-2 gap-4">
<Input
label={t('webhooks.retryCount', 'Retry Count')}
value={String(retryCount)}
onChange={(e) => setRetryCount(parseInt(e.target.value) || 3)}
type="number"
min={0}
max={10}
data-testid="webhook-form-retry"
/>
<Input
label={t('webhooks.timeout', 'Timeout (s)')}
value={String(timeoutSeconds)}
onChange={(e) => setTimeoutSeconds(parseInt(e.target.value) || 30)}
type="number"
min={1}
max={120}
data-testid="webhook-form-timeout"
/>
</div>
{/* Error */}
{error && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
disabled={!url.trim() || events.length === 0}
icon={<Plus className="h-4 w-4" />}
data-testid="webhook-form-submit"
>
{isEdit ? t('common.save', 'Speichern') : t('webhooks.create', 'Erstellen')}
</Button>
</div>
</form>
</Modal>
);
}
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
interface DeleteModalProps {
open: boolean;
webhook: Webhook | null;
onConfirm: () => void;
onCancel: () => void;
isDeleting: boolean;
}
function DeleteModal({ open, webhook, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
const { t } = useTranslation();
return (
<Modal open={open} onClose={onCancel} title={t('webhooks.deleteTitle', 'Webhook löschen')} size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 rounded-full bg-danger-100 p-2">
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
</div>
<div>
<p className="text-sm text-secondary-700">
{t('webhooks.deleteConfirm', 'Möchten Sie den Webhook')}{' '}
<span className="font-semibold text-secondary-900 break-all">{webhook?.url}</span>{' '}
{t('webhooks.deleteConfirmEnd', 'wirklich löschen?')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isDeleting}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
type="button"
variant="danger"
onClick={onConfirm}
isLoading={isDeleting}
data-testid="webhook-delete-confirm"
>
{t('webhooks.delete', 'Löschen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── Test Result Modal ──────────────────────────────────────────────────────
interface TestResultModalProps {
open: boolean;
result: WebhookTestResult | null;
onClose: () => void;
}
function TestResultModal({ open, result, onClose }: TestResultModalProps) {
const { t } = useTranslation();
if (!result) return null;
return (
<Modal open={open} onClose={onClose} title={t('webhooks.testResult', 'Test-Ergebnis')} size="sm">
<div className="space-y-4">
<div className={clsx(
'flex items-start gap-3',
result.success ? 'text-success-700' : 'text-danger-700'
)}>
<div className={clsx(
'flex-shrink-0 rounded-full p-2',
result.success ? 'bg-success-100' : 'bg-danger-100'
)}>
{result.success
? <CheckCircle className="h-5 w-5" aria-hidden="true" />
: <XCircle className="h-5 w-5" aria-hidden="true" />
}
</div>
<div>
<p className="text-sm font-medium">
{result.success
? t('webhooks.testSuccess', 'Webhook erfolgreich gesendet')
: t('webhooks.testFailed', 'Webhook fehlgeschlagen')
}
</p>
{result.status_code && (
<p className="text-sm mt-1">
{t('webhooks.statusCode', 'Status-Code')}: {result.status_code}
</p>
)}
{result.error && (
<p className="text-sm mt-1">
{t('webhooks.error', 'Fehler')}: {result.error}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t('common.close', 'Schließen')}
</Button>
</div>
</div>
</Modal>
);
}
// ─── Main Page Component ─────────────────────────────────────────────────────
export function SettingsWebhooksPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: webhooks = [], isLoading, error } = useWebhooks();
const createMutation = useCreateWebhook();
const updateMutation = useUpdateWebhook();
const deleteMutation = useDeleteWebhook();
const testMutation = useTestWebhook();
// Modal state
const [formModalOpen, setFormModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deletingWebhook, setDeletingWebhook] = useState<Webhook | null>(null);
const [testResultModalOpen, setTestResultModalOpen] = useState(false);
const [testResult, setTestResult] = useState<WebhookTestResult | null>(null);
const [formError, setFormError] = useState<string | null>(null);
// Handlers
const handleCreate = useCallback(() => {
setEditingWebhook(null);
setFormError(null);
setFormModalOpen(true);
}, []);
const handleEdit = useCallback((webhook: Webhook) => {
setEditingWebhook(webhook);
setFormError(null);
setFormModalOpen(true);
}, []);
const handleFormSubmit = useCallback(
(data: CreateWebhookPayload | UpdateWebhookPayload) => {
setFormError(null);
if (editingWebhook) {
updateMutation.mutate(
{ id: editingWebhook.id, data: data as UpdateWebhookPayload },
{
onSuccess: () => {
setFormModalOpen(false);
setEditingWebhook(null);
},
onError: (err: any) => {
setFormError(err?.message || t('webhooks.updateError', 'Fehler beim Aktualisieren'));
},
}
);
} else {
createMutation.mutate(data as CreateWebhookPayload, {
onSuccess: () => {
setFormModalOpen(false);
},
onError: (err: any) => {
setFormError(err?.message || t('webhooks.createError', 'Fehler beim Erstellen'));
},
});
}
},
[editingWebhook, createMutation, updateMutation, t]
);
const handleDeleteClick = useCallback((webhook: Webhook) => {
setDeletingWebhook(webhook);
setDeleteModalOpen(true);
}, []);
const handleDeleteConfirm = useCallback(() => {
if (!deletingWebhook) return;
deleteMutation.mutate(deletingWebhook.id, {
onSuccess: () => {
setDeleteModalOpen(false);
setDeletingWebhook(null);
},
});
}, [deletingWebhook, deleteMutation]);
const handleTest = useCallback(
(webhook: Webhook) => {
testMutation.mutate(webhook.id, {
onSuccess: (result) => {
setTestResult(result);
setTestResultModalOpen(true);
},
onError: (err: any) => {
setTestResult({ success: false, status_code: null, error: err?.message || 'Unknown error' });
setTestResultModalOpen(true);
},
});
},
[testMutation]
);
const handleToggleActive = useCallback(
(webhook: Webhook) => {
updateMutation.mutate({
id: webhook.id,
data: { is_active: !webhook.is_active },
});
},
[updateMutation]
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900">
{t('webhooks.title', 'Webhooks')}
</h1>
<p className="mt-1 text-sm text-secondary-500">
{t('webhooks.description', 'Verwalten Sie ausgehende Webhook-Abonnements für Ereignisbenachrichtigungen')}
</p>
</div>
<Button
variant="primary"
onClick={handleCreate}
icon={<Plus className="h-4 w-4" />}
data-testid="webhook-create-btn"
>
{t('webhooks.add', '+ Neuer Webhook')}
</Button>
</div>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
</div>
)}
{/* Error */}
{error && (
<Card>
<div className="p-4 text-center text-danger-600">
{t('webhooks.loadError', 'Fehler beim Laden der Webhooks')}
</div>
</Card>
)}
{/* Empty state */}
{!isLoading && !error && webhooks.length === 0 && (
<Card>
<div className="flex flex-col items-center justify-center py-12 text-center">
<WebhookIcon className="h-12 w-12 text-secondary-300 mb-4" />
<h3 className="text-lg font-medium text-secondary-900">
{t('webhooks.noWebhooks', 'Keine Webhooks')}
</h3>
<p className="mt-1 text-sm text-secondary-500 max-w-sm">
{t('webhooks.noWebhooksDesc', 'Erstellen Sie Ihren ersten Webhook, um Ereignisbenachrichtigungen an externe Dienste zu senden.')}
</p>
<Button
variant="primary"
className="mt-4"
onClick={handleCreate}
icon={<Plus className="h-4 w-4" />}
>
{t('webhooks.createFirst', 'Ersten Webhook erstellen')}
</Button>
</div>
</Card>
)}
{/* Webhook list */}
{!isLoading && !error && webhooks.length > 0 && (
<div className="space-y-3">
{webhooks.map((webhook) => (
<Card key={webhook.id}>
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={clsx(
'inline-block w-2 h-2 rounded-full',
webhook.is_active ? 'bg-success-500' : 'bg-secondary-300'
)}
aria-hidden="true"
/>
<span className="text-sm font-medium text-secondary-900 break-all">
{webhook.url}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{webhook.events.slice(0, 5).map((event) => (
<span
key={event}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary-50 text-primary-700"
>
{event}
</span>
))}
{webhook.events.length > 5 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-600">
+{webhook.events.length - 5}
</span>
)}
</div>
<div className="mt-1 text-xs text-secondary-400">
{t('webhooks.retryCount', 'Retries')}: {webhook.retry_count} |{' '}
{t('webhooks.timeout', 'Timeout')}: {webhook.timeout_seconds}s
</div>
</div>
<div className="flex items-center gap-1 ml-4 flex-shrink-0">
{/* Toggle active */}
<button
type="button"
onClick={() => handleToggleActive(webhook)}
className={clsx(
'relative inline-flex h-6 w-10 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
webhook.is_active ? 'bg-success-500' : 'bg-secondary-200'
)}
role="switch"
aria-checked={webhook.is_active}
aria-label={t('webhooks.toggleActive', 'Aktiv/Inaktiv umschalten')}
>
<span
className={clsx(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
webhook.is_active ? 'translate-x-4' : 'translate-x-0'
)}
/>
</button>
{/* Test */}
<button
type="button"
onClick={() => handleTest(webhook)}
className="p-1.5 rounded-md text-secondary-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
title={t('webhooks.test', 'Testen')}
data-testid="webhook-test-btn"
>
<Send className="h-4 w-4" />
</button>
{/* Edit */}
<button
type="button"
onClick={() => handleEdit(webhook)}
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 transition-colors"
title={t('common.edit', 'Bearbeiten')}
data-testid="webhook-edit-btn"
>
<Pencil className="h-4 w-4" />
</button>
{/* Delete */}
<button
type="button"
onClick={() => handleDeleteClick(webhook)}
className="p-1.5 rounded-md text-secondary-400 hover:text-danger-600 hover:bg-danger-50 transition-colors"
title={t('common.delete', 'Löschen')}
data-testid="webhook-delete-btn"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* Modals */}
<WebhookFormModal
open={formModalOpen}
onClose={() => { setFormModalOpen(false); setEditingWebhook(null); setFormError(null); }}
webhook={editingWebhook}
onSubmit={handleFormSubmit}
isSubmitting={createMutation.isPending || updateMutation.isPending}
error={formError}
/>
<DeleteModal
open={deleteModalOpen}
webhook={deletingWebhook}
onConfirm={handleDeleteConfirm}
onCancel={() => { setDeleteModalOpen(false); setDeletingWebhook(null); }}
isDeleting={deleteMutation.isPending}
/>
<TestResultModal
open={testResultModalOpen}
result={testResult}
onClose={() => { setTestResultModalOpen(false); setTestResult(null); }}
/>
</div>
);
}
export default SettingsWebhooksPage;