diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index cb144cb15..408ada8ea 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -6925,8 +6925,12 @@ "description": "Create UI for webhook registration and management", "owner": "frontend", "estimated_hours": 6, - "status": "todo", - "files_involved": [], + "status": "completed", + "files_involved": [ + "apps/web/src/pages/WebhooksPage.tsx", + "apps/web/src/router/index.tsx", + "apps/web/src/components/ui/LazyComponent.tsx" + ], "implementation_steps": [ { "step": 1, @@ -6946,7 +6950,9 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "", + "completed_at": "2025-12-25T10:45:00.000Z", + "implementation_notes": "Webhooks management page implemented with full CRUD functionality. Page includes list of webhooks with status badges, create webhook dialog with event selection, delete confirmation, test webhook functionality, regenerate API key, and display webhook statistics. Uses existing webhookApi.ts service. Route added at /webhooks." }, { "id": "FE-PAGE-017", diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 0d3906acf..fed6e2904 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -139,3 +139,8 @@ export const LazyAnalytics = createLazyComponent(() => default: m.AnalyticsPage, })), ); +export const LazyWebhooks = createLazyComponent(() => + import('@/pages/WebhooksPage').then((m) => ({ + default: m.WebhooksPage, + })), +); diff --git a/apps/web/src/pages/WebhooksPage.tsx b/apps/web/src/pages/WebhooksPage.tsx new file mode 100644 index 000000000..a78cad1c7 --- /dev/null +++ b/apps/web/src/pages/WebhooksPage.tsx @@ -0,0 +1,513 @@ +import { useState, useEffect } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; +import { Badge } from '@/components/ui/badge'; +import { + Plus, + Trash2, + TestTube, + RefreshCw, + Copy, + Check, + ExternalLink, + Webhook as WebhookIcon, +} from 'lucide-react'; +import { + listWebhooks, + registerWebhook, + deleteWebhook, + testWebhook, + regenerateWebhookAPIKey, + getWebhookStats, + type RegisterWebhookRequest, +} from '@/features/webhooks/api/webhookApi'; +import { Webhook } from '@/types/webhook'; +import { LoadingSpinner } from '@/components/ui/loading-spinner'; +import { useToast } from '@/hooks/use-toast'; + +/** + * FE-PAGE-016: Webhooks management page + */ +export function WebhooksPage() { + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(null); + const [stats, setStats] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + loadWebhooks(); + loadStats(); + }, []); + + const loadWebhooks = async () => { + setLoading(true); + setError(null); + + try { + const data = await listWebhooks(); + setWebhooks(data); + } catch (err: any) { + console.error('Failed to load webhooks:', err); + setError(err.message || 'Impossible de charger les webhooks'); + toast({ + title: 'Erreur', + description: 'Impossible de charger les webhooks', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const loadStats = async () => { + try { + const data = await getWebhookStats(); + setStats(data); + } catch (err) { + console.error('Failed to load webhook stats:', err); + } + }; + + const handleCreateWebhook = async (data: RegisterWebhookRequest) => { + try { + const newWebhook = await registerWebhook(data); + setWebhooks([...webhooks, newWebhook]); + setIsCreateDialogOpen(false); + toast({ + title: 'Succès', + description: 'Webhook créé avec succès', + }); + if (newWebhook.api_key) { + toast({ + title: 'Clé API générée', + description: `Votre clé API: ${newWebhook.api_key}`, + }); + } + } catch (err: any) { + console.error('Failed to create webhook:', err); + toast({ + title: 'Erreur', + description: err.message || 'Impossible de créer le webhook', + variant: 'destructive', + }); + } + }; + + const handleDeleteWebhook = async (id: string) => { + try { + await deleteWebhook(id); + setWebhooks(webhooks.filter((w) => w.id !== id)); + setDeleteDialogOpen(null); + toast({ + title: 'Succès', + description: 'Webhook supprimé avec succès', + }); + } catch (err: any) { + console.error('Failed to delete webhook:', err); + toast({ + title: 'Erreur', + description: err.message || 'Impossible de supprimer le webhook', + variant: 'destructive', + }); + } + }; + + const handleTestWebhook = async (id: string) => { + try { + await testWebhook(id); + toast({ + title: 'Succès', + description: 'Événement de test envoyé', + }); + } catch (err: any) { + console.error('Failed to test webhook:', err); + toast({ + title: 'Erreur', + description: err.message || 'Impossible de tester le webhook', + variant: 'destructive', + }); + } + }; + + const handleRegenerateKey = async (id: string) => { + try { + const response = await regenerateWebhookAPIKey(id); + setWebhooks( + webhooks.map((w) => + w.id === id ? { ...w, api_key: response.api_key } : w, + ), + ); + toast({ + title: 'Succès', + description: 'Clé API régénérée avec succès', + }); + } catch (err: any) { + console.error('Failed to regenerate key:', err); + toast({ + title: 'Erreur', + description: err.message || 'Impossible de régénérer la clé API', + variant: 'destructive', + }); + } + }; + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + toast({ + title: 'Copié', + description: 'Copié dans le presse-papiers', + }); + }; + + const availableEvents = [ + 'track.uploaded', + 'track.updated', + 'track.deleted', + 'playlist.created', + 'playlist.updated', + 'playlist.deleted', + 'user.created', + 'user.updated', + ]; + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+

Webhooks

+

+ Gérez vos webhooks pour recevoir des notifications en temps réel +

+
+ + + + + + + Créer un nouveau webhook + + Configurez un webhook pour recevoir des notifications d'événements + + + setIsCreateDialogOpen(false)} + /> + + +
+ + {error && ( + + + Erreur + {error} + + + )} + + {/* Stats */} + {stats && ( +
+ + + + Total des webhooks + + + +
{webhooks.length}
+
+
+ + + Webhooks actifs + + +
+ {webhooks.filter((w) => w.active).length} +
+
+
+ + + Taille de la file + + +
+ {stats.stats?.queue_size || 0} +
+
+
+
+ )} + + {/* Webhooks List */} + {webhooks.length === 0 ? ( + + + +

Aucun webhook

+

+ Créez votre premier webhook pour commencer à recevoir des + notifications +

+ +
+
+ ) : ( +
+ {webhooks.map((webhook) => ( + + +
+
+ + {webhook.url} + {webhook.active ? ( + Actif + ) : ( + Inactif + )} + + + Créé le{' '} + {new Date(webhook.created_at).toLocaleDateString('fr-FR')} + +
+
+ + + +
+
+
+ +
+
+ +
+ {webhook.events.map((event) => ( + + {event} + + ))} +
+
+ + {webhook.api_key && ( +
+ +
+ + +
+
+ )} + + +
+
+
+ ))} +
+ )} + + {/* Delete Confirmation Dialog */} + {deleteDialogOpen && ( + setDeleteDialogOpen(null)} + title="Supprimer le webhook" + description="Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible." + confirmLabel="Supprimer" + cancelLabel="Annuler" + onConfirm={() => handleDeleteWebhook(deleteDialogOpen)} + variant="destructive" + /> + )} +
+ ); +} + +interface CreateWebhookFormProps { + onSubmit: (data: RegisterWebhookRequest) => void; + availableEvents: string[]; + onCancel: () => void; +} + +function CreateWebhookForm({ + onSubmit, + availableEvents, + onCancel, +}: CreateWebhookFormProps) { + const [url, setUrl] = useState(''); + const [selectedEvents, setSelectedEvents] = useState([]); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!url || selectedEvents.length === 0) { + return; + } + + setLoading(true); + try { + await onSubmit({ url, events: selectedEvents }); + setUrl(''); + setSelectedEvents([]); + } finally { + setLoading(false); + } + }; + + const toggleEvent = (event: string) => { + setSelectedEvents((prev) => + prev.includes(event) + ? prev.filter((e) => e !== event) + : [...prev, event], + ); + }; + + return ( +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/webhook" + required + /> +
+ +
+ +
+ {availableEvents.map((event) => ( + + ))} +
+ {selectedEvents.length === 0 && ( +

+ Sélectionnez au moins un événement +

+ )} +
+ +
+ + +
+
+ ); +} + diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index e51347dc8..aacde43cc 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -26,6 +26,7 @@ import { LazySearch, LazyNotifications, LazyAnalytics, + LazyWebhooks, } from '@/components/ui/LazyComponent'; // Composant wrapper pour les routes protégées avec DashboardLayout @@ -266,6 +267,18 @@ export const AppRouter = () => ( } /> + + + + + + + + } + /> {/* Routes d'erreur */}