[FE-PAGE-016] fe-page: Add Webhooks management page

This commit is contained in:
senke 2025-12-25 11:27:17 +01:00
parent 67749f0f51
commit fe0f663aa7
4 changed files with 540 additions and 3 deletions

View file

@ -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",

View file

@ -139,3 +139,8 @@ export const LazyAnalytics = createLazyComponent(() =>
default: m.AnalyticsPage,
})),
);
export const LazyWebhooks = createLazyComponent(() =>
import('@/pages/WebhooksPage').then((m) => ({
default: m.WebhooksPage,
})),
);

View file

@ -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<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<string | null>(null);
const [stats, setStats] = useState<any>(null);
const [copiedId, setCopiedId] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center h-[400px]">
<LoadingSpinner />
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">Webhooks</h1>
<p className="text-muted-foreground">
Gérez vos webhooks pour recevoir des notifications en temps réel
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Créer un webhook
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Créer un nouveau webhook</DialogTitle>
<DialogDescription>
Configurez un webhook pour recevoir des notifications d'événements
</DialogDescription>
</DialogHeader>
<CreateWebhookForm
onSubmit={handleCreateWebhook}
availableEvents={availableEvents}
onCancel={() => setIsCreateDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
{error && (
<Card className="mb-6 border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Erreur</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Total des webhooks
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{webhooks.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Webhooks actifs</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{webhooks.filter((w) => w.active).length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Taille de la file</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.stats?.queue_size || 0}
</div>
</CardContent>
</Card>
</div>
)}
{/* Webhooks List */}
{webhooks.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<WebhookIcon className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Aucun webhook</h3>
<p className="text-muted-foreground mb-4 text-center">
Créez votre premier webhook pour commencer à recevoir des
notifications
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Créer un webhook
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{webhooks.map((webhook) => (
<Card key={webhook.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{webhook.url}
{webhook.active ? (
<Badge variant="default">Actif</Badge>
) : (
<Badge variant="secondary">Inactif</Badge>
)}
</CardTitle>
<CardDescription className="mt-2">
Créé le{' '}
{new Date(webhook.created_at).toLocaleDateString('fr-FR')}
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestWebhook(webhook.id)}
>
<TestTube className="h-4 w-4 mr-2" />
Tester
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleRegenerateKey(webhook.id)}
>
<RefreshCw className="h-4 w-4 mr-2" />
Régénérer la clé
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteDialogOpen(webhook.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-2 block">
Événements
</Label>
<div className="flex flex-wrap gap-2">
{webhook.events.map((event) => (
<Badge key={event} variant="outline">
{event}
</Badge>
))}
</div>
</div>
{webhook.api_key && (
<div>
<Label className="text-sm font-medium mb-2 block">
Clé API
</Label>
<div className="flex items-center gap-2">
<Input
value={webhook.api_key}
readOnly
className="font-mono text-sm"
/>
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(webhook.api_key!, webhook.id)
}
>
{copiedId === webhook.id ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ExternalLink className="h-4 w-4" />
<a
href={webhook.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{webhook.url}
</a>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
{deleteDialogOpen && (
<ConfirmationDialog
open={true}
onClose={() => 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"
/>
)}
</div>
);
}
interface CreateWebhookFormProps {
onSubmit: (data: RegisterWebhookRequest) => void;
availableEvents: string[];
onCancel: () => void;
}
function CreateWebhookForm({
onSubmit,
availableEvents,
onCancel,
}: CreateWebhookFormProps) {
const [url, setUrl] = useState('');
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="url">URL du webhook</Label>
<Input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/webhook"
required
/>
</div>
<div>
<Label>Événements</Label>
<div className="mt-2 space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
{availableEvents.map((event) => (
<label
key={event}
className="flex items-center space-x-2 cursor-pointer hover:bg-accent p-2 rounded"
>
<input
type="checkbox"
checked={selectedEvents.includes(event)}
onChange={() => toggleEvent(event)}
className="rounded"
/>
<span className="text-sm">{event}</span>
</label>
))}
</div>
{selectedEvents.length === 0 && (
<p className="text-sm text-muted-foreground mt-2">
Sélectionnez au moins un événement
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
Annuler
</Button>
<Button type="submit" disabled={!url || selectedEvents.length === 0 || loading}>
{loading ? 'Création...' : 'Créer'}
</Button>
</div>
</form>
);
}

View file

@ -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 = () => (
</ProtectedRoute>
}
/>
<Route
path="/webhooks"
element={
<ProtectedRoute>
<ProtectedLayoutRoute>
<ErrorBoundary>
<LazyWebhooks />
</ErrorBoundary>
</ProtectedLayoutRoute>
</ProtectedRoute>
}
/>
{/* Routes d'erreur */}
<Route