Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial
- Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042 - Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client - Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043 - Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh) - Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage) - Onboarding integrated into AppShell - Routes: /settings/webhooks, /settings/backup registered - Settings nav: Webhooks, Backup & Restore entries added - Migration conflict fixed: 0042_webhooks → 0043_backups chain
This commit is contained in:
@@ -22,6 +22,8 @@ export function SettingsPage() {
|
||||
{ to: '/settings/ai-settings', label: 'KI Einstellungen', icon: '\ud83e\udde0' },
|
||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/custom-fields', label: 'Custom Fields', icon: '\ud83d\udccb' },
|
||||
{ to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' },
|
||||
{ to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' },
|
||||
];
|
||||
|
||||
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* SettingsBackup page — Backup & Restore management.
|
||||
*
|
||||
* Features:
|
||||
* - "Backup jetzt erstellen" button (POST)
|
||||
* - List of backups: date, size, status badge, restore/delete buttons
|
||||
* - Restore dialog with warning text + type "RESTORE" to confirm
|
||||
* - Auto-refresh when backup is pending
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import { Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup, type Backup } from '@/api/backups';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatFileSize(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return '—';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const config: Record<string, { icon: React.ReactNode; label: string; classes: string }> = {
|
||||
pending: {
|
||||
icon: <Clock className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
label: t('backup.statusPending', 'Wird erstellt...'),
|
||||
classes: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
},
|
||||
completed: {
|
||||
icon: <CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
label: t('backup.statusCompleted', 'Abgeschlossen'),
|
||||
classes: 'bg-green-50 text-green-700 border-green-200',
|
||||
},
|
||||
failed: {
|
||||
icon: <XCircle className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
label: t('backup.statusFailed', 'Fehlgeschlagen'),
|
||||
classes: 'bg-danger-50 text-danger-700 border-danger-200',
|
||||
},
|
||||
};
|
||||
|
||||
const c = config[status] || config.pending;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||
c.classes
|
||||
)}
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Restore Confirmation Modal ─────────────────────────────────────────────
|
||||
|
||||
interface RestoreModalProps {
|
||||
open: boolean;
|
||||
backup: Backup | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isRestoring: boolean;
|
||||
}
|
||||
|
||||
function RestoreModal({ open, backup, onConfirm, onCancel, isRestoring }: RestoreModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (confirmText === 'RESTORE') {
|
||||
onConfirm();
|
||||
setConfirmText('');
|
||||
}
|
||||
}, [confirmText, onConfirm]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setConfirmText('');
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title={t('backup.restoreTitle', 'Backup wiederherstellen')} size="md">
|
||||
<div className="space-y-4">
|
||||
{/* Warning */}
|
||||
<div className="rounded-md border border-danger-200 bg-danger-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-danger-800">
|
||||
{t('backup.restoreWarningTitle', 'Achtung: Destruktiver Vorgang')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-danger-700">
|
||||
{t(
|
||||
'backup.restoreWarningText',
|
||||
'Die Wiederherstellung überschreibt die aktuelle Datenbank vollständig. ' +
|
||||
'Alle seit dem Backup vorgenommenen Änderungen gehen verloren. ' +
|
||||
'Dieser Vorgang kann nicht rückgängig gemacht werden.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup info */}
|
||||
{backup && (
|
||||
<div className="rounded-md bg-secondary-50 border border-secondary-200 p-3 text-sm">
|
||||
<p className="text-secondary-700">
|
||||
<span className="font-medium">{t('backup.restoreFile', 'Backup-Datei:')}</span>{' '}
|
||||
{backup.filename}
|
||||
</p>
|
||||
<p className="text-secondary-700 mt-1">
|
||||
<span className="font-medium">{t('backup.restoreDate', 'Erstellt am:')}</span>{' '}
|
||||
{formatDate(backup.created_at)}
|
||||
</p>
|
||||
<p className="text-secondary-700 mt-1">
|
||||
<span className="font-medium">{t('backup.restoreSize', 'Größe:')}</span>{' '}
|
||||
{formatFileSize(backup.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('backup.restoreConfirmLabel', 'Geben Sie "RESTORE" ein, um zu bestätigen:')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="RESTORE"
|
||||
className={clsx(
|
||||
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
|
||||
'focus:outline-none focus:ring-2 focus:ring-danger-500 focus:border-danger-500',
|
||||
'motion-safe:transition-colors text-secondary-900 placeholder-secondary-400'
|
||||
)}
|
||||
autoFocus
|
||||
data-testid="restore-confirm-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={handleClose} disabled={isRestoring}>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={handleConfirm}
|
||||
isLoading={isRestoring}
|
||||
disabled={confirmText !== 'RESTORE'}
|
||||
icon={<Download className="h-4 w-4" />}
|
||||
data-testid="restore-confirm-btn"
|
||||
>
|
||||
{t('backup.restoreBtn', 'Wiederherstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
|
||||
|
||||
interface DeleteModalProps {
|
||||
open: boolean;
|
||||
backup: Backup | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function DeleteModal({ open, backup, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={t('backup.deleteTitle', 'Backup 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('backup.deleteConfirm', 'Möchten Sie das Backup')}{' '}
|
||||
<span className="font-semibold text-secondary-900">{backup?.filename}</span>{' '}
|
||||
{t('backup.deleteConfirmEnd', 'wirklich löschen?')}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 mt-1">
|
||||
{t('backup.deleteWarning', 'Die Backup-Datei wird dauerhaft entfernt.')}
|
||||
</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}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
data-testid="backup-delete-confirm"
|
||||
>
|
||||
{t('common.delete', 'Löschen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SettingsBackup Page ─────────────────────────────────────────────────────
|
||||
|
||||
export function SettingsBackupPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
const [restoringBackup, setRestoringBackup] = useState<Backup | null>(null);
|
||||
const [showRestoreModal, setShowRestoreModal] = useState(false);
|
||||
const [deletingBackup, setDeletingBackup] = useState<Backup | null>(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────
|
||||
const { data, isLoading, isError, error } = useBackups();
|
||||
const backups = data?.backups ?? [];
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const createMutation = useCreateBackup();
|
||||
const restoreMutation = useRestoreBackup();
|
||||
const deleteMutation = useDeleteBackup();
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
const handleCreateBackup = useCallback(() => {
|
||||
createMutation.mutate();
|
||||
}, [createMutation]);
|
||||
|
||||
const handleRestoreClick = useCallback((backup: Backup) => {
|
||||
setRestoringBackup(backup);
|
||||
setShowRestoreModal(true);
|
||||
}, []);
|
||||
|
||||
const handleRestoreConfirm = useCallback(() => {
|
||||
if (restoringBackup) {
|
||||
restoreMutation.mutate(restoringBackup.id, {
|
||||
onSuccess: () => {
|
||||
setShowRestoreModal(false);
|
||||
setRestoringBackup(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [restoringBackup, restoreMutation]);
|
||||
|
||||
const handleRestoreCancel = useCallback(() => {
|
||||
setShowRestoreModal(false);
|
||||
setRestoringBackup(null);
|
||||
}, []);
|
||||
|
||||
const handleDeleteClick = useCallback((backup: Backup) => {
|
||||
setDeletingBackup(backup);
|
||||
setShowDeleteModal(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (deletingBackup) {
|
||||
deleteMutation.mutate(deletingBackup.id, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingBackup(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [deletingBackup, deleteMutation]);
|
||||
|
||||
const handleDeleteCancel = useCallback(() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingBackup(null);
|
||||
}, []);
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-4" data-testid="settings-backup-page">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-secondary-900">
|
||||
{t('backup.title', 'Backup & Restore')}
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-500 mt-0.5">
|
||||
{t('backup.subtitle', 'Erstellen und verwalten Sie Datenbank-Backups')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
onClick={handleCreateBackup}
|
||||
isLoading={createMutation.isPending}
|
||||
disabled={createMutation.isPending}
|
||||
data-testid="backup-create-btn"
|
||||
>
|
||||
{t('backup.createBtn', 'Backup jetzt erstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{isError && (
|
||||
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
|
||||
{error?.message || t('backup.loadError', 'Fehler beim Laden der Backups.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create error */}
|
||||
{createMutation.isError && (
|
||||
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
|
||||
{createMutation.error?.message || t('backup.createError', 'Fehler beim Erstellen des Backups.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore error */}
|
||||
{restoreMutation.isError && (
|
||||
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
|
||||
{restoreMutation.error?.message || t('backup.restoreError', 'Fehler bei der Wiederherstellung.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backups list */}
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 text-secondary-500">
|
||||
<HardDrive className="h-5 w-5 animate-pulse" aria-hidden="true" />
|
||||
{t('common.loading', 'Laden...')}
|
||||
</div>
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-3 w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center">
|
||||
<HardDrive className="h-6 w-6 text-secondary-400" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 mb-3">
|
||||
{t('backup.empty', 'Noch keine Backups vorhanden.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
onClick={handleCreateBackup}
|
||||
isLoading={createMutation.isPending}
|
||||
>
|
||||
{t('backup.createFirst', 'Erstes Backup erstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" data-testid="backups-table">
|
||||
<thead>
|
||||
<tr className="border-b border-secondary-200">
|
||||
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('backup.date', 'Datum')}
|
||||
</th>
|
||||
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('backup.filename', 'Dateiname')}
|
||||
</th>
|
||||
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('backup.size', 'Größe')}
|
||||
</th>
|
||||
<th className="text-center text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('backup.status', 'Status')}
|
||||
</th>
|
||||
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
|
||||
{t('common.actions', 'Aktionen')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-100">
|
||||
{backups.map((backup) => (
|
||||
<tr
|
||||
key={backup.id}
|
||||
className="hover:bg-secondary-50 transition-colors"
|
||||
data-testid={`backup-row-${backup.id}`}
|
||||
>
|
||||
{/* Date */}
|
||||
<td className="px-3 py-3">
|
||||
<span className="text-sm text-secondary-900">
|
||||
{formatDate(backup.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
{/* Filename */}
|
||||
<td className="px-3 py-3 max-w-xs">
|
||||
<span className="text-sm text-secondary-700 truncate block">
|
||||
{backup.filename}
|
||||
</span>
|
||||
</td>
|
||||
{/* Size */}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<span className="text-sm text-secondary-700 font-mono">
|
||||
{formatFileSize(backup.size_bytes)}
|
||||
</span>
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-3 py-3 text-center">
|
||||
<StatusBadge status={backup.status} />
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRestoreClick(backup)}
|
||||
disabled={backup.status !== 'completed'}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md p-1.5',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
'min-h-touch min-w-touch',
|
||||
backup.status === 'completed'
|
||||
? 'text-secondary-400 hover:text-primary-600 hover:bg-primary-50'
|
||||
: 'text-secondary-300 cursor-not-allowed'
|
||||
)}
|
||||
aria-label={t('backup.restoreLabel', 'Backup wiederherstellen')}
|
||||
data-testid={`backup-restore-btn-${backup.id}`}
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteClick(backup)}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-md p-1.5',
|
||||
'text-secondary-400 hover:text-danger-600 hover:bg-danger-50',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500',
|
||||
'min-h-touch min-w-touch'
|
||||
)}
|
||||
aria-label={t('backup.deleteLabel', 'Backup löschen')}
|
||||
data-testid={`backup-delete-btn-${backup.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Restore Modal */}
|
||||
<RestoreModal
|
||||
open={showRestoreModal}
|
||||
backup={restoringBackup}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
onCancel={handleRestoreCancel}
|
||||
isRestoring={restoreMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<DeleteModal
|
||||
open={showDeleteModal}
|
||||
backup={deletingBackup}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user