veza/apps/web/src/pages/AdminDashboardPage.tsx
google-labs-jules[bot] 59ead9e2b8 feat: prepare production environment and fix frontend build
- Create .env file with production configuration for local testing.
- Fix frontend compilation errors:
  - Correct import paths for `useToast` hook in `WebhooksPage.tsx` and `AdminDashboardPage.tsx`.
  - Update `WebhooksPage.tsx` to use the existing custom `Dialog` component API.
- Improve Nginx configuration in `apps/web/nginx.conf`:
  - Use resolver and variables for upstream proxies to prevent crash when backend services are down.
  - Fix stream server proxy path to route `/stream` to `/ws` as expected by the backend.
- Update `docker-compose.production.yml` to use correct `Dockerfile` name for stream server.
2025-12-31 16:27:36 +00:00

587 lines
20 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Users,
Activity,
AlertTriangle,
FileText,
Search,
Shield,
TrendingUp,
Clock,
} from 'lucide-react';
import {
getAuditStats,
detectSuspiciousActivity,
searchAuditLogs,
} from '@/features/admin/api/auditService';
import { apiClient } from '@/services/api/client';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { useToast } from '@/hooks/useToast';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Pagination } from '@/components/navigation/Pagination';
/**
* FE-PAGE-017: Admin dashboard page with system stats and user management
*/
interface SystemStats {
total_users: number;
active_users: number;
total_tracks: number;
total_playlists: number;
}
interface User {
id: string;
username: string;
email: string;
role: string;
is_active: boolean;
is_verified: boolean;
created_at: string;
last_login_at?: string;
}
interface AuditStat {
action: string;
resource: string;
action_count: number;
}
export function AdminDashboardPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [auditStats, setAuditStats] = useState<AuditStat[]>([]);
const [suspiciousActivities, setSuspiciousActivities] = useState<any[]>([]);
const [recentLogs, setRecentLogs] = useState<any[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('overview');
const [usersPage, setUsersPage] = useState(1);
const [usersTotal, setUsersTotal] = useState(0);
const usersLimit = 50;
const { toast } = useToast();
const navigate = useNavigate();
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
setLoading(true);
setError(null);
try {
await Promise.all([
loadSystemStats(),
loadAuditStats(),
loadSuspiciousActivities(),
loadRecentLogs(),
loadUsers(),
]);
} catch (err: any) {
console.error('Failed to load admin dashboard:', err);
setError(err.message || 'Impossible de charger le dashboard admin');
if (err.message?.includes('403') || err.message?.includes('Forbidden')) {
toast({
title: 'Accès refusé',
description: "Vous n'avez pas les permissions administrateur",
variant: 'destructive',
});
navigate('/dashboard');
}
} finally {
setLoading(false);
}
};
const loadSystemStats = async () => {
try {
// Try to get stats from multiple endpoints
const [usersRes, tracksRes, playlistsRes] = await Promise.allSettled([
apiClient.get('/users', { params: { limit: 1 } }),
apiClient.get('/tracks', { params: { limit: 1 } }),
apiClient.get('/playlists', { params: { limit: 1 } }),
]);
const totalUsers =
usersRes.status === 'fulfilled'
? usersRes.value.data?.pagination?.total || 0
: 0;
const totalTracks =
tracksRes.status === 'fulfilled'
? tracksRes.value.data?.pagination?.total || 0
: 0;
const totalPlaylists =
playlistsRes.status === 'fulfilled'
? playlistsRes.value.data?.pagination?.total || 0
: 0;
// Get active users (users with recent activity)
const activeUsersRes = await apiClient.get('/users', {
params: { limit: 1000, is_active: true },
});
const activeUsers =
activeUsersRes.data?.users?.filter(
(u: User) => u.last_login_at && new Date(u.last_login_at) > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
).length || 0;
setSystemStats({
total_users: totalUsers,
active_users: activeUsers,
total_tracks: totalTracks,
total_playlists: totalPlaylists,
});
} catch (err) {
console.error('Failed to load system stats:', err);
}
};
const loadAuditStats = async () => {
try {
const data = await getAuditStats();
setAuditStats(data.stats || []);
} catch (err) {
console.error('Failed to load audit stats:', err);
}
};
const loadSuspiciousActivities = async () => {
try {
const data = await detectSuspiciousActivity({ hours: 24 });
setSuspiciousActivities(data.activities || []);
} catch (err) {
console.error('Failed to load suspicious activities:', err);
}
};
const loadRecentLogs = async () => {
try {
const data = await searchAuditLogs({ limit: 10 });
setRecentLogs(data.logs || []);
} catch (err) {
console.error('Failed to load recent logs:', err);
}
};
const loadUsers = async () => {
try {
const response = await apiClient.get('/users', {
params: {
page: usersPage,
limit: usersLimit,
search: searchQuery || undefined
},
});
setUsers(response.data?.users || []);
setUsersTotal(response.data?.pagination?.total || 0);
} catch (err) {
console.error('Failed to load users:', err);
}
};
useEffect(() => {
if (activeTab === 'users') {
loadUsers();
}
}, [searchQuery, activeTab, usersPage]);
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case 'admin':
return 'destructive';
case 'creator':
return 'default';
default:
return 'secondary';
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center h-[400px]">
<LoadingSpinner />
</div>
</div>
);
}
if (error && error.includes('Forbidden')) {
return (
<div className="container mx-auto px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="text-destructive">Accès refusé</CardTitle>
<CardDescription>
Vous n'avez pas les permissions nécessaires pour accéder à cette
page.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => navigate('/dashboard')}>
Retour au dashboard
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<Shield className="h-8 w-8" />
Dashboard Administrateur
</h1>
<p className="text-muted-foreground">
Gestion du système et surveillance des activités
</p>
</div>
{error && !error.includes('Forbidden') && (
<Card className="mb-6 border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Erreur</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Vue d'ensemble</TabsTrigger>
<TabsTrigger value="users">Utilisateurs</TabsTrigger>
<TabsTrigger value="audit">Audit</TabsTrigger>
<TabsTrigger value="security">Sécurité</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-4">
{/* System Stats */}
{systemStats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total utilisateurs
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(systemStats.total_users)}
</div>
<p className="text-xs text-muted-foreground">
{systemStats.active_users} actifs (30j)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total pistes</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(systemStats.total_tracks)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total playlists
</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(systemStats.total_playlists)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Activités suspectes
</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{suspiciousActivities.length}
</div>
<p className="text-xs text-muted-foreground">Dernières 24h</p>
</CardContent>
</Card>
</div>
)}
{/* Audit Stats */}
{auditStats.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Statistiques d'audit
</CardTitle>
<CardDescription>
Actions les plus fréquentes
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{auditStats.slice(0, 10).map((stat, index) => (
<div
key={index}
className="flex items-center justify-between p-2 border rounded"
>
<div>
<p className="font-medium">{stat.action}</p>
<p className="text-sm text-muted-foreground">
{stat.resource}
</p>
</div>
<Badge variant="outline">{stat.action_count}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Logs */}
{recentLogs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Logs récents
</CardTitle>
<CardDescription>
Dernières activités enregistrées
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{recentLogs.map((log) => (
<div
key={log.id}
className="flex items-center justify-between p-2 border rounded"
>
<div className="flex-1">
<p className="font-medium">{log.action}</p>
<p className="text-sm text-muted-foreground">
{log.resource} •{' '}
{formatDistanceToNow(new Date(log.timestamp || log.created_at), {
addSuffix: true,
locale: fr,
})}
</p>
</div>
<Badge variant="outline">{log.user_id?.slice(0, 8)}</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* Users Tab */}
<TabsContent value="users" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Gestion des utilisateurs</CardTitle>
<CardDescription>
Liste et gestion des utilisateurs du système
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un utilisateur..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="space-y-2">
{users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 border rounded hover:bg-accent cursor-pointer"
onClick={() => navigate(`/u/${user.username}`)}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{user.username}</p>
<Badge variant={getRoleBadgeVariant(user.role)}>
{user.role}
</Badge>
{!user.is_active && (
<Badge variant="secondary">Inactif</Badge>
)}
{!user.is_verified && (
<Badge variant="outline">Non vérifié</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground">
Créé le{' '}
{new Date(user.created_at).toLocaleDateString('fr-FR')}
</p>
</div>
</div>
))}
</div>
{/* FE-COMP-006: Pagination component */}
{usersTotal > usersLimit && (
<Pagination
currentPage={usersPage}
totalPages={Math.ceil(usersTotal / usersLimit)}
onPageChange={setUsersPage}
totalItems={usersTotal}
itemsPerPage={usersLimit}
showItemsInfo={true}
className="mt-4"
/>
)}
</CardContent>
</Card>
</TabsContent>
{/* Audit Tab */}
<TabsContent value="audit" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Logs d'audit
</CardTitle>
<CardDescription>
Historique complet des activités système
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={loadRecentLogs} variant="outline" className="mb-4">
Actualiser
</Button>
<div className="space-y-2">
{recentLogs.map((log) => (
<div
key={log.id}
className="p-3 border rounded"
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{log.action}</p>
<p className="text-sm text-muted-foreground">
{log.resource} {log.user_id}
</p>
{log.metadata && (
<p className="text-xs text-muted-foreground mt-1">
{JSON.stringify(log.metadata)}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(log.timestamp || log.created_at), {
addSuffix: true,
locale: fr,
})}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Security Tab */}
<TabsContent value="security" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Activités suspectes
</CardTitle>
<CardDescription>
Activités détectées comme suspectes dans les dernières 24 heures
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={loadSuspiciousActivities}
variant="outline"
className="mb-4"
>
Actualiser
</Button>
{suspiciousActivities.length === 0 ? (
<p className="text-muted-foreground">
Aucune activité suspecte détectée
</p>
) : (
<div className="space-y-2">
{suspiciousActivities.map((activity, index) => (
<div
key={index}
className="p-3 border border-destructive rounded"
>
<p className="font-medium text-destructive">
{activity.reason || 'Activité suspecte'}
</p>
<p className="text-sm text-muted-foreground">
{activity.user_id} {activity.ip_address}
</p>
<p className="text-xs text-muted-foreground mt-1">
{activity.description}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}