[FE-PAGE-016] fe-page: Add Webhooks management page
This commit is contained in:
parent
67749f0f51
commit
fe0f663aa7
4 changed files with 540 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -139,3 +139,8 @@ export const LazyAnalytics = createLazyComponent(() =>
|
|||
default: m.AnalyticsPage,
|
||||
})),
|
||||
);
|
||||
export const LazyWebhooks = createLazyComponent(() =>
|
||||
import('@/pages/WebhooksPage').then((m) => ({
|
||||
default: m.WebhooksPage,
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
513
apps/web/src/pages/WebhooksPage.tsx
Normal file
513
apps/web/src/pages/WebhooksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue