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:
Agent Zero
2026-07-26 03:17:40 +02:00
parent 10dcc8ae90
commit 79ece0fe2e
23 changed files with 3094 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* Backup API client — database backup management.
*
* Features:
* - List backups
* - Create backup
* - Restore backup
* - Delete backup
*/
import { apiGet, apiPost, apiDelete } from './client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface Backup {
id: string;
tenant_id: string;
filename: string;
size_bytes: number | null;
status: 'pending' | 'completed' | 'failed';
error_message: string | null;
created_by: string | null;
created_at: string;
completed_at: string | null;
}
export interface BackupListResponse {
backups: Backup[];
total: number;
}
// ─── API Functions ──────────────────────────────────────────────────────────
export async function fetchBackups(): Promise<BackupListResponse> {
return apiGet<BackupListResponse>('/backups');
}
export async function createBackup(): Promise<Backup> {
return apiPost<Backup>('/backups');
}
export async function restoreBackup(backupId: string): Promise<Backup> {
return apiPost<Backup>(`/backups/${backupId}/restore`);
}
export async function deleteBackup(backupId: string): Promise<void> {
return apiDelete<void>(`/backups/${backupId}`);
}
// ─── React Query Hooks ─────────────────────────────────────────────────────
export function useBackups() {
return useQuery<BackupListResponse>({
queryKey: ['backups'],
queryFn: fetchBackups,
refetchInterval: (query) => {
// Auto-refresh while any backup is pending
const data = query.state.data;
if (data && data.backups.some((b) => b.status === 'pending')) {
return 3000; // Poll every 3 seconds
}
return false;
},
});
}
export function useCreateBackup() {
const queryClient = useQueryClient();
return useMutation<Backup, Error>({
mutationFn: createBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
}
export function useRestoreBackup() {
const queryClient = useQueryClient();
return useMutation<Backup, Error, string>({
mutationFn: restoreBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
}
export function useDeleteBackup() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: deleteBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
},
});
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Webhooks API client and React Query hooks.
*/
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export interface Webhook {
id: string;
tenant_id: string;
url: string;
events: string[];
secret: string | null;
is_active: boolean;
retry_count: number;
timeout_seconds: number;
created_by: string | null;
updated_by: string | null;
created_at: string;
updated_at: string;
}
export interface CreateWebhookPayload {
url: string;
events: string[];
secret?: string | null;
is_active?: boolean;
retry_count?: number;
timeout_seconds?: number;
}
export interface UpdateWebhookPayload {
url?: string;
events?: string[];
secret?: string | null;
is_active?: boolean;
retry_count?: number;
timeout_seconds?: number;
}
export interface WebhookTestResult {
success: boolean;
status_code: number | null;
error: string | null;
}
// ─── API Functions ──────────────────────────────────────────────────────────
export async function fetchWebhooks(event?: string): Promise<Webhook[]> {
const params = event ? `?event=${encodeURIComponent(event)}` : '';
return apiGet<Webhook[]>(`/webhooks${params}`);
}
export async function fetchWebhook(id: string): Promise<Webhook> {
return apiGet<Webhook>(`/webhooks/${id}`);
}
export async function createWebhook(data: CreateWebhookPayload): Promise<Webhook> {
return apiPost<Webhook>('/webhooks', data);
}
export async function updateWebhook(id: string, data: UpdateWebhookPayload): Promise<Webhook> {
return apiPatch<Webhook>(`/webhooks/${id}`, data);
}
export async function deleteWebhook(id: string): Promise<void> {
return apiDelete<void>(`/webhooks/${id}`);
}
export async function testWebhook(id: string): Promise<WebhookTestResult> {
return apiPost<WebhookTestResult>(`/webhooks/${id}/test`);
}
// ─── React Query Hooks ───────────────────────────────────────────────────────
export function useWebhooks(event?: string) {
return useQuery({
queryKey: ['webhooks', event],
queryFn: () => fetchWebhooks(event),
});
}
export function useWebhook(id: string) {
return useQuery({
queryKey: ['webhooks', id],
queryFn: () => fetchWebhook(id),
enabled: !!id,
});
}
export function useCreateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWebhookPayload) => createWebhook(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks'] });
},
});
}
export function useUpdateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateWebhookPayload }) =>
updateWebhook(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks'] });
},
});
}
export function useDeleteWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteWebhook(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['webhooks'] });
},
});
}
export function useTestWebhook() {
return useMutation({
mutationFn: (id: string) => testWebhook(id),
});
}
@@ -11,6 +11,8 @@ import { PluginRegistry } from '@/components/plugins/PluginRegistry';
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
import { WindowContainer } from '@/components/window/WindowContainer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog';
import { OnboardingTour } from '@/components/onboarding/OnboardingTour';
export function AppShell() {
const location = useLocation();
@@ -50,6 +52,8 @@ export function AppShell() {
<AIUIControlIndicator />
<WindowContainer />
<ToastContainer />
<WelcomeDialog open={false} />
<OnboardingTour />
</div>
);
}
@@ -0,0 +1,372 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useOnboardingStore } from '@/store/onboardingStore';
import { useUpsertUserPreference } from '@/api/userPreferences';
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
/**
* OnboardingTour — Custom guided tour with CSS overlay and positioned tooltips.
* No external dependency (react-joyride types unavailable for TS strict mode).
*
* 8 steps targeting sidebar items, search, contacts, calendar, AI assistant, settings.
* Each step highlights a target element via a cut-out overlay and shows a tooltip.
*/
interface TourStep {
target: string;
titleKey: string;
titleFallback: string;
descKey: string;
descFallback: string;
placement?: 'bottom' | 'top' | 'left' | 'right' | 'center';
}
const TOUR_STEPS: TourStep[] = [
{
target: '[data-testid="sidebar"]',
titleKey: 'onboarding.step1Title',
titleFallback: 'Navigation',
descKey: 'onboarding.step1Desc',
descFallback: 'In der Seitenleiste finden Sie alle Hauptbereiche von LeoCRM. Klicken Sie auf einen Eintrag, um dorthin zu navigieren.',
placement: 'right',
},
{
target: 'a[href="/dashboard"]',
titleKey: 'onboarding.step2Title',
titleFallback: 'Dashboard',
descKey: 'onboarding.step2Desc',
descFallback: 'Das Dashboard gibt Ihnen einen Überblick über wichtige Kennzahlen und aktuelle Aktivitäten.',
placement: 'right',
},
{
target: 'a[href="/contacts"]',
titleKey: 'onboarding.step3Title',
titleFallback: 'Kontakte',
descKey: 'onboarding.step3Desc',
descFallback: 'Verwalten Sie hier alle Firmen und Personen. Legen Sie neue Kontakte an oder bearbeiten Sie bestehende.',
placement: 'right',
},
{
target: '[data-testid="search-dropdown"]',
titleKey: 'onboarding.step4Title',
titleFallback: 'Globale Suche',
descKey: 'onboarding.step4Desc',
descFallback: 'Mit der globalen Suche finden Sie schnell Kontakte, Termine und andere Einträge in LeoCRM.',
placement: 'bottom',
},
{
target: 'a[href="/calendar"]',
titleKey: 'onboarding.step5Title',
titleFallback: 'Kalender',
descKey: 'onboarding.step5Desc',
descFallback: 'Im Kalender verwalten Sie Termine, Veranstaltungen und Wochenpläne.',
placement: 'right',
},
{
target: 'a[href="/ai-assistant"]',
titleKey: 'onboarding.step6Title',
titleFallback: 'KI-Assistent',
descKey: 'onboarding.step6Desc',
descFallback: 'Der KI-Assistent hilft Ihnen bei Automatisierung, Texterstellung und intelligente Vorschläge.',
placement: 'right',
},
{
target: '[data-testid="topbar"]',
titleKey: 'onboarding.step7Title',
titleFallback: 'Top-Bar',
descKey: 'onboarding.step7Desc',
descFallback: 'Hier erreichen Sie Benachrichtigungen, Ihr Benutzerprofil und weitere Einstellungen.',
placement: 'bottom',
},
{
target: 'a[href="/settings"]',
titleKey: 'onboarding.step8Title',
titleFallback: 'Einstellungen',
descKey: 'onboarding.step8Desc',
descFallback: 'Passen Sie LeoCRM an Ihre Bedürfnisse an: Profil, Sprache, Design und mehr.',
placement: 'right',
},
];
interface Rect {
top: number;
left: number;
width: number;
height: number;
}
const HIGHLIGHT_PADDING = 8;
export function OnboardingTour() {
const { t } = useTranslation();
const { isActive, step, next, prev, skip, complete } = useOnboardingStore();
const upsertPreference = useUpsertUserPreference();
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
const [targetMissing, setTargetMissing] = useState(false);
const currentStep = TOUR_STEPS[step];
const totalSteps = TOUR_STEPS.length;
const isLastStep = step >= totalSteps - 1;
const updatePosition = useCallback(() => {
if (!isActive || !currentStep) return;
const el = document.querySelector(currentStep.target) as HTMLElement | null;
if (!el) {
setTargetMissing(true);
setTargetRect(null);
setTooltipPos({ top: window.innerHeight / 2 - 120, left: window.innerWidth / 2 - 200 });
return;
}
setTargetMissing(false);
const rect = el.getBoundingClientRect();
const padded: Rect = {
top: rect.top - HIGHLIGHT_PADDING,
left: rect.left - HIGHLIGHT_PADDING,
width: rect.width + HIGHLIGHT_PADDING * 2,
height: rect.height + HIGHLIGHT_PADDING * 2,
};
setTargetRect(padded);
// Calculate tooltip position based on placement
const tooltipWidth = 360;
const tooltipHeight = 220;
const margin = 16;
let top: number;
let left: number;
const placement = currentStep.placement || 'bottom';
switch (placement) {
case 'right':
top = rect.top + rect.height / 2 - tooltipHeight / 2;
left = rect.right + margin;
break;
case 'left':
top = rect.top + rect.height / 2 - tooltipHeight / 2;
left = rect.left - tooltipWidth - margin;
break;
case 'top':
top = rect.top - tooltipHeight - margin;
left = rect.left + rect.width / 2 - tooltipWidth / 2;
break;
case 'bottom':
top = rect.bottom + margin;
left = rect.left + rect.width / 2 - tooltipWidth / 2;
break;
default:
top = window.innerHeight / 2 - tooltipHeight / 2;
left = window.innerWidth / 2 - tooltipWidth / 2;
}
// Clamp to viewport
top = Math.max(margin, Math.min(top, window.innerHeight - tooltipHeight - margin));
left = Math.max(margin, Math.min(left, window.innerWidth - tooltipWidth - margin));
setTooltipPos({ top, left });
}, [isActive, currentStep, step]);
useEffect(() => {
if (!isActive) return;
// Small delay to allow DOM to settle after potential route changes
const timer = setTimeout(updatePosition, 50);
return () => clearTimeout(timer);
}, [isActive, step, updatePosition]);
useEffect(() => {
if (!isActive) return;
const handler = () => updatePosition();
window.addEventListener('resize', handler);
window.addEventListener('scroll', handler, true);
return () => {
window.removeEventListener('resize', handler);
window.removeEventListener('scroll', handler, true);
};
}, [isActive, updatePosition]);
// Keyboard navigation
useEffect(() => {
if (!isActive) return;
const handler = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowRight':
case 'Enter':
e.preventDefault();
handleNext();
break;
case 'ArrowLeft':
e.preventDefault();
if (step > 0) prev();
break;
case 'Escape':
e.preventDefault();
handleSkip();
break;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, step]);
const handleNext = useCallback(() => {
if (isLastStep) {
complete();
upsertPreference.mutate({ key: 'onboarding_completed', value: true });
} else {
next();
}
}, [isLastStep, complete, next, upsertPreference]);
const handleSkip = useCallback(() => {
skip();
upsertPreference.mutate({ key: 'onboarding_completed', value: true });
}, [skip, upsertPreference]);
if (!isActive || !currentStep) return null;
const title = t(currentStep.titleKey, currentStep.titleFallback);
const description = t(currentStep.descKey, currentStep.descFallback);
return (
<>
{/* Dark overlay with cut-out for highlighted element */}
<div
className="fixed inset-0 z-[55] pointer-events-auto"
aria-hidden="true"
data-testid="onboarding-overlay"
onClick={handleSkip}
style={{
backgroundColor: 'rgba(15, 23, 42, 0.65)',
// Use box-shadow trick to create a cut-out: huge shadow from the highlight rect
...(targetRect && !targetMissing
? {
boxShadow: `0 0 0 9999px rgba(15, 23, 42, 0.65)`,
borderRadius: '8px',
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
inset: 'auto',
backgroundColor: 'transparent',
transition: 'all 0.3s ease',
}
: {}),
}}
/>
{/* Highlight border around target */}
{targetRect && !targetMissing && (
<div
className="fixed z-[56] pointer-events-none rounded-lg ring-2 ring-primary-500 ring-offset-2 ring-offset-transparent"
aria-hidden="true"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
transition: 'all 0.3s ease',
}}
/>
)}
{/* Tooltip card */}
{tooltipPos && (
<div
className="fixed z-[57] w-[360px] bg-white rounded-xl shadow-2xl border border-secondary-200 overflow-hidden"
role="dialog"
aria-modal="false"
aria-labelledby="tour-step-title"
data-testid="onboarding-tooltip"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transition: 'all 0.3s ease',
}}
>
{/* Header with step indicator + close */}
<div className="flex items-center justify-between px-5 py-3 bg-secondary-50 border-b border-secondary-100">
<span className="text-xs font-semibold text-primary-600 uppercase tracking-wider">
{t('onboarding.stepProgress', 'Schritt {{current}} von {{total}}', {
current: step + 1,
total: totalSteps,
})}
</span>
<button
onClick={handleSkip}
className="p-1 rounded-md hover:bg-secondary-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400"
aria-label={t('onboarding.skip', 'Überspringen')}
>
<X className="w-4 h-4" strokeWidth={2} />
</button>
</div>
{/* Progress bar */}
<div className="h-1 bg-secondary-100">
<div
className="h-full bg-primary-500 transition-all duration-300"
style={{ width: `${((step + 1) / totalSteps) * 100}%` }}
/>
</div>
{/* Body */}
<div className="px-5 py-4">
<h3 id="tour-step-title" className="text-lg font-bold text-secondary-900 mb-2">
{title}
</h3>
<p className="text-sm text-secondary-600 leading-relaxed">
{description}
</p>
{targetMissing && (
<p className="text-xs text-warning-600 mt-2 italic">
{t('onboarding.elementNotFound', 'Dieses Element ist derzeit nicht sichtbar. Klicken Sie auf Weiter, um fortzufahren.')}
</p>
)}
</div>
{/* Footer with navigation buttons */}
<div className="flex items-center justify-between px-5 py-3 bg-secondary-50 border-t border-secondary-100">
<button
onClick={handleSkip}
className="text-sm text-secondary-500 hover:text-secondary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 rounded min-h-touch px-2"
>
{t('onboarding.skipTour', 'Tour überspringen')}
</button>
<div className="flex items-center gap-2">
{step > 0 && (
<button
onClick={prev}
className="flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-secondary-600 hover:bg-secondary-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 min-h-touch"
aria-label={t('onboarding.back', 'Zurück')}
>
<ChevronLeft className="w-4 h-4" strokeWidth={2} />
{t('onboarding.back', 'Zurück')}
</button>
)}
<button
onClick={handleNext}
className="flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-semibold bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch shadow-sm"
aria-label={isLastStep ? t('onboarding.finish', 'Fertig') : t('onboarding.next', 'Weiter')}
>
{isLastStep ? (
<>
<Check className="w-4 h-4" strokeWidth={2} />
{t('onboarding.finish', 'Fertig')}
</>
) : (
<>
{t('onboarding.next', 'Weiter')}
<ChevronRight className="w-4 h-4" strokeWidth={2} />
</>
)}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,137 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOnboardingStore } from '@/store/onboardingStore';
import { Sparkles, X, ChevronRight } from 'lucide-react';
/**
* WelcomeDialog — shown on first login when onboarding has not been
* completed or skipped. Presents a brief intro and offers to start the
* guided tour or skip it.
*
* Visibility is controlled by the parent component (AppShell). This
* component only handles the dialog UI and delegates actions to the
* onboarding store.
*/
export interface WelcomeDialogProps {
/** Controls whether the dialog is visible. */
open: boolean;
/** Called when the dialog should close without starting the tour. */
onClose?: () => void;
}
export function WelcomeDialog({ open, onClose }: WelcomeDialogProps) {
const { t } = useTranslation();
const startTour = useOnboardingStore((s) => s.start);
const skip = useOnboardingStore((s) => s.skip);
useEffect(() => {
if (!open) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleSkip();
}
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (!open) return null;
const handleStartTour = () => {
startTour();
onClose?.();
};
const handleSkip = () => {
skip();
onClose?.();
};
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-secondary-900/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="welcome-dialog-title"
data-testid="welcome-dialog"
>
<div className="relative w-full max-w-lg mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* Decorative header banner */}
<div className="bg-gradient-to-br from-primary-600 to-accent-600 px-8 py-8 text-white relative">
<button
onClick={handleSkip}
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-white/20 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={t('onboarding.skip', 'Überspringen')}
>
<X className="w-5 h-5" strokeWidth={2} />
</button>
<div className="flex items-center gap-3 mb-3">
<Sparkles className="w-8 h-8" strokeWidth={2} />
<span className="text-sm font-medium uppercase tracking-wider opacity-90">
{t('onboarding.welcomeBadge', 'Neu hier?')}
</span>
</div>
<h2 id="welcome-dialog-title" className="text-2xl font-bold">
{t('onboarding.welcomeTitle', 'Willkommen bei LeoCRM!')}
</h2>
</div>
{/* Body */}
<div className="px-8 py-6">
<p className="text-secondary-600 text-base leading-relaxed mb-4">
{t(
'onboarding.welcomeIntro',
'LeoCRM ist Ihre zentrale Plattform für Kontaktverwaltung, Kalender, KI-gestützte Automatisierung und mehr. Lernen Sie in einer kurzen Tour die wichtigsten Funktionen kennen.'
)}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
<FeatureCard
icon="🗂️"
title={t('onboarding.featureContacts', 'Kontakte')}
desc={t('onboarding.featureContactsDesc', 'Verwalten Sie Firmen und Personen')}
/>
<FeatureCard
icon="📅"
title={t('onboarding.featureCalendar', 'Kalender')}
desc={t('onboarding.featureCalendarDesc', 'Termine und Veranstaltungen im Blick')}
/>
<FeatureCard
icon="🤖"
title={t('onboarding.featureAI', 'KI-Assistent')}
desc={t('onboarding.featureAIDesc', 'Intelligente Automatisierung')}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
<button
onClick={handleSkip}
className="px-5 py-2.5 rounded-lg text-sm font-medium text-secondary-600 hover:bg-secondary-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 min-h-touch"
>
{t('onboarding.skip', 'Überspringen')}
</button>
<button
onClick={handleStartTour}
className="flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg text-sm font-semibold bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch shadow-sm"
>
{t('onboarding.startTour', 'Tour starten')}
<ChevronRight className="w-4 h-4" strokeWidth={2} />
</button>
</div>
</div>
</div>
</div>
);
}
function FeatureCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return (
<div className="flex flex-col items-center text-center p-3 rounded-lg bg-secondary-50 border border-secondary-100">
<span className="text-2xl mb-1" aria-hidden="true">{icon}</span>
<span className="text-sm font-semibold text-secondary-800">{title}</span>
<span className="text-xs text-secondary-500 mt-0.5">{desc}</span>
</div>
);
}
+2
View File
@@ -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));
+499
View File
@@ -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>
);
}
+628
View File
@@ -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;
+4
View File
@@ -58,6 +58,8 @@ const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m
const TagsPage = React.lazy(() => import('@/pages/Tags').then(m => ({ default: m.TagsPage })));
const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage })));
const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage })));
const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage })));
const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage })));
/** Centered spinner fallback for lazy-loaded routes */
function PageLoader() {
@@ -161,6 +163,8 @@ const router = createBrowserRouter([
{ path: 'ai-settings', element: withSuspense(<SettingsAIPage />) },
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: 'custom-fields', element: withSuspense(<CustomFieldsPage />) },
{ path: 'webhooks', element: withSuspense(<SettingsWebhooksPage />) },
{ path: 'backup', element: withSuspense(<SettingsBackupPage />) },
{ path: '*', element: <PluginRouteRenderer /> },
],
},
+102
View File
@@ -0,0 +1,102 @@
import { create } from 'zustand';
/**
* Onboarding Store — manages the guided tour state for first-time users.
* Persists to localStorage so progress survives page reloads.
*/
const STORAGE_KEY = 'leocrm_onboarding';
export interface OnboardingState {
isActive: boolean;
step: number;
completed: boolean;
skipped: boolean;
start: () => void;
next: () => void;
prev: () => void;
skip: () => void;
complete: () => void;
goToStep: (n: number) => void;
reset: () => void;
}
interface PersistedData {
step: number;
completed: boolean;
skipped: boolean;
}
function loadPersisted(): Partial<OnboardingState> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const data: PersistedData = JSON.parse(raw);
return {
step: data.step ?? 0,
completed: data.completed ?? false,
skipped: data.skipped ?? false,
};
} catch {
return {};
}
}
function persist(state: OnboardingState): void {
try {
const data: PersistedData = {
step: state.step,
completed: state.completed,
skipped: state.skipped,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// localStorage may be unavailable in some contexts
}
}
const initial = loadPersisted();
export const useOnboardingStore = create<OnboardingState>((set, get) => ({
isActive: false,
step: initial.step ?? 0,
completed: initial.completed ?? false,
skipped: initial.skipped ?? false,
start: () => {
set({ isActive: true, step: 0, completed: false, skipped: false });
persist(get());
},
next: () => {
const s = get();
set({ step: s.step + 1 });
persist(get());
},
prev: () => {
const s = get();
set({ step: Math.max(0, s.step - 1) });
persist(get());
},
skip: () => {
set({ isActive: false, skipped: true });
persist(get());
},
complete: () => {
set({ isActive: false, completed: true, step: 0 });
persist(get());
},
goToStep: (n: number) => {
set({ step: Math.max(0, n) });
persist(get());
},
reset: () => {
set({ isActive: false, step: 0, completed: false, skipped: false });
persist(get());
},
}));