- 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.
587 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|