feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-03-07 11:42:58 +01:00
parent 3d37ce4582
commit 473b3e26c7
181 changed files with 30617 additions and 7170 deletions

View File

@@ -0,0 +1,110 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Languages, Menu, X, ChevronLeft, Shield, LogOut } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useAdminLogin } from "./login/useAdminLogin";
import { adminNavItems } from "./constants";
export function AdminHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { logout } = useAdminLogin();
return (
<>
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-card px-3 lg:px-4">
<Button
variant="ghost"
size="icon"
className="lg:hidden h-8 w-8"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
</Button>
<div className="flex items-center gap-1.5 lg:hidden">
<div className="flex size-5 items-center justify-center rounded bg-foreground">
<Languages className="size-2.5 text-background" />
</div>
<span className="text-xs font-semibold text-foreground">Admin</span>
</div>
<div className="hidden items-center gap-2 lg:flex">
<h1 className="text-xs font-semibold text-foreground">System Administration</h1>
<Separator orientation="vertical" className="h-3" />
<span className="text-xs text-muted-foreground">Monitor infrastructure and manage users</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="border-destructive/30 bg-destructive/5 text-destructive text-[10px] px-1.5 py-0"
>
<Shield className="mr-1 size-2.5" />
Superadmin
</Badge>
<Avatar className="size-6">
<AvatarFallback className="bg-foreground text-background text-[10px] font-semibold">
SA
</AvatarFallback>
</Avatar>
</div>
</header>
{mobileOpen && (
<div className="border-b border-border bg-card px-3 py-2 lg:hidden">
<nav className="flex flex-col gap-0.5">
{adminNavItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.label}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
)}
>
<item.icon className="size-3.5 shrink-0" />
{item.label}
</Link>
);
})}
<Separator className="my-1" />
<Link
href="/dashboard"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<ChevronLeft className="size-3.5 shrink-0" />
User Dashboard
</Link>
<Button
variant="ghost"
size="sm"
className="h-7 justify-start gap-2 text-xs text-muted-foreground hover:text-destructive"
onClick={() => {
setMobileOpen(false);
logout();
}}
>
<LogOut className="size-3.5 shrink-0" />
Logout
</Button>
</nav>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronLeft, Shield, Languages, LogOut } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { useAdminLogin } from "./login/useAdminLogin";
import { adminNavItems } from "./constants";
export function AdminSidebar() {
const pathname = usePathname();
const { logout } = useAdminLogin();
return (
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
<div className="flex h-12 items-center gap-2 px-4">
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
<Languages className="size-3 text-background" />
</div>
<span className="text-xs font-semibold tracking-tight text-foreground">
Office Translator
</span>
<Badge
variant="outline"
className="ml-auto px-1.5 py-0 text-[10px] font-medium text-muted-foreground"
>
Admin
</Badge>
</div>
<Separator />
<nav className="flex flex-1 flex-col gap-0.5 px-2 py-3">
{adminNavItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.label}
href={item.href}
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
)}
>
<item.icon className="size-3.5 shrink-0" />
{item.label}
</Link>
);
})}
</nav>
<Separator />
<div className="flex flex-col gap-1 px-2 py-2">
<div className="flex items-center gap-2 px-2.5 py-1">
<Shield className="size-3 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Superadmin access</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 justify-start gap-2 text-xs text-muted-foreground"
asChild
>
<Link href="/dashboard">
<ChevronLeft className="size-3" />
User Dashboard
</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 justify-start gap-2 text-xs text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut className="size-3" />
Logout
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { Calendar } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StatsPeriod } from "./types";
interface DateRangeFilterProps {
value: StatsPeriod;
onChange: (value: StatsPeriod) => void;
}
const periodOptions: { value: StatsPeriod; label: string }[] = [
{ value: "today", label: "Aujourd'hui" },
{ value: "week", label: "7 derniers jours" },
{ value: "month", label: "30 derniers jours" },
];
export function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
return (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<Select value={value} onValueChange={(v) => onChange(v as StatsPeriod)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sélectionner une période" />
</SelectTrigger>
<SelectContent>
{periodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { FileSpreadsheet, FileText, Presentation } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { TranslationStatsData, FormatBreakdownItem } from "./types";
interface FormatBreakdownChartProps {
data: TranslationStatsData | null;
isLoading: boolean;
}
const formatConfig: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
xlsx: {
label: "Excel (.xlsx)",
icon: <FileSpreadsheet className="h-4 w-4 text-green-500" />,
color: "bg-green-500",
},
docx: {
label: "Word (.docx)",
icon: <FileText className="h-4 w-4 text-blue-500" />,
color: "bg-blue-500",
},
pptx: {
label: "PowerPoint (.pptx)",
icon: <Presentation className="h-4 w-4 text-orange-500" />,
color: "bg-orange-500",
},
};
export function FormatBreakdownChart({ data, isLoading }: FormatBreakdownChartProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Répartition par Format</CardTitle>
<CardDescription>Chargement...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-2 w-full animate-pulse rounded bg-muted" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!data?.format_breakdown) {
return (
<Card>
<CardHeader>
<CardTitle>Répartition par Format</CardTitle>
<CardDescription>Aucune donnée disponible</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">Aucune donnée de format</p>
</div>
</CardContent>
</Card>
);
}
const formats = Object.entries(data.format_breakdown).filter(
([, value]) => value.count > 0
);
return (
<Card>
<CardHeader>
<CardTitle>Répartition par Format</CardTitle>
<CardDescription>
Distribution des traductions par type de fichier
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{formats.map(([format, value]) => {
const config = formatConfig[format] || {
label: format.toUpperCase(),
icon: <FileText className="h-4 w-4" />,
color: "bg-gray-500",
};
return (
<div key={format} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{config.icon}
<span className="font-medium">{config.label}</span>
</div>
<span className="text-muted-foreground">
{value.count} ({value.percentage.toFixed(1)}%)
</span>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className={`h-full ${config.color} transition-all duration-500`}
style={{ width: `${value.percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { Cpu } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { TranslationStatsData } from "./types";
interface ProviderBreakdownChartProps {
data: TranslationStatsData | null;
isLoading: boolean;
}
const providerLabels: Record<string, string> = {
google: "Google Translate",
deepl: "DeepL",
ollama: "Ollama (Local)",
openai: "OpenAI",
};
const providerColors: Record<string, string> = {
google: "bg-blue-500",
deepl: "bg-indigo-500",
ollama: "bg-green-500",
openai: "bg-purple-500",
};
export function ProviderBreakdownChart({ data, isLoading }: ProviderBreakdownChartProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5" />
Répartition par Provider
</CardTitle>
<CardDescription>Chargement...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-2 w-full animate-pulse rounded bg-muted" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!data?.provider_breakdown) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5" />
Répartition par Provider
</CardTitle>
<CardDescription>Aucune donnée disponible</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">Aucune donnée de provider</p>
</div>
</CardContent>
</Card>
);
}
const providers = Object.entries(data.provider_breakdown).filter(
([, value]) => value.count > 0
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5" />
Répartition par Provider
</CardTitle>
<CardDescription>
Distribution des traductions par fournisseur de service
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{providers.map(([provider, value]) => (
<div key={provider} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{providerLabels[provider] || provider}
</span>
<span className="text-muted-foreground">
{value.count} ({value.percentage.toFixed(1)}%)
</span>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className={`h-full ${providerColors[provider] || "bg-primary"} transition-all duration-500`}
style={{ width: `${value.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { AdminDashboardData, ProviderStatus } from "./types";
interface ProviderStatusProps {
data: AdminDashboardData | null;
isLoading: boolean;
}
const PROVIDER_LABELS: Record<string, string> = {
google: "Google Translate",
deepl: "DeepL",
ollama: "Ollama (Local)",
openai: "OpenAI",
openrouter: "OpenRouter",
};
const STATUS_CONFIG = {
online: {
dotClass: "bg-green-500",
label: "Online",
badgeClass:
"border-green-200/30 bg-green-500/10 text-green-600",
},
degraded: {
dotClass: "bg-yellow-500",
label: "Degraded",
badgeClass:
"border-yellow-200/30 bg-yellow-500/10 text-yellow-600",
},
offline: {
dotClass: "bg-red-500",
label: "Offline",
badgeClass: "border-red-200/30 bg-red-500/10 text-red-500",
},
};
function getProviderStatus(
provider: ProviderStatus
): "online" | "degraded" | "offline" {
if (provider.available) return "online";
if (provider.error) return "offline";
return "degraded";
}
export function ProviderStatus({ data, isLoading }: ProviderStatusProps) {
const providers = Object.entries(data?.providers || {});
if (isLoading && !data) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
</div>
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-6 w-28 animate-pulse rounded bg-muted"
/>
))}
</div>
</div>
);
}
if (providers.length === 0) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Translation API Providers
</span>
<span className="text-sm text-muted-foreground">
No provider data available
</span>
</div>
);
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Translation API Providers
</span>
<span className="text-[10px] text-muted-foreground">
{providers.length} provider{providers.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{providers.map(([key, provider]) => {
const status = getProviderStatus(provider);
const config = STATUS_CONFIG[status];
const label = PROVIDER_LABELS[key] || provider.name || key;
return (
<Tooltip key={key}>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`cursor-default gap-1.5 px-2.5 py-1 text-xs font-medium ${config.badgeClass}`}
>
<span
className={`size-1.5 rounded-full ${config.dotClass}`}
/>
{label}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-0.5 text-xs">
<span className="font-medium">{config.label}</span>
{provider.latency_ms !== undefined && (
<span className="text-muted-foreground">
Latency: {provider.latency_ms}ms
</span>
)}
{provider.last_check && (
<span className="text-muted-foreground">
Last check:{" "}
{new Date(provider.last_check).toLocaleTimeString()}
</span>
)}
{provider.error && (
<span className="text-red-500">{provider.error}</span>
)}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { TrendingUp, TrendingDown, FileText, AlertCircle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { TranslationStatsData } from "./types";
interface StatsOverviewProps {
data: TranslationStatsData | null;
isLoading: boolean;
}
export function StatsOverview({ data, isLoading }: StatsOverviewProps) {
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-20 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
);
}
if (!data) {
return null;
}
const diff = data.total_translations - data.total_translations_last_period;
const trendUp = diff >= 0;
const trendPercent = data.total_translations_last_period > 0
? Math.abs((diff / data.total_translations_last_period) * 100).toFixed(1)
: "0";
const periodLabels: Record<string, string> = {
today: "Aujourd'hui",
week: "Cette Semaine",
month: "Ce Mois",
};
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Traductions {periodLabels[data.period]}
</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.total_translations}</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{trendUp ? (
<TrendingUp className="h-3 w-3 text-green-500" />
) : (
<TrendingDown className="h-3 w-3 text-red-500" />
)}
<span className={trendUp ? "text-green-500" : "text-red-500"}>
{trendUp ? "+" : ""}{diff}
</span>
<span>({trendPercent}%)</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Réussies</CardTitle>
<Badge variant="default" className="bg-green-500/10 text-green-500 hover:bg-green-500/20">
OK
</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.success_count}</div>
<p className="text-xs text-muted-foreground">
{(100 - data.error_rate).toFixed(1)}% de réussite
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Erreurs</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.error_count}</div>
<p className="text-xs text-muted-foreground">
Taux d&apos;erreur: {data.error_rate.toFixed(1)}%
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Période Précédente</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.total_translations_last_period}</div>
<p className="text-xs text-muted-foreground">
Comparaison
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react";
import type { AdminDashboardData } from "./types";
interface SystemHealthCardsProps {
data: AdminDashboardData | null;
isLoading: boolean;
isPurging: boolean;
onPurge: () => void;
purgeResult: { files_cleaned: number } | null;
}
export function SystemHealthCards({
data,
isLoading,
isPurging,
onPurge,
purgeResult,
}: SystemHealthCardsProps) {
const diskUsed = data?.system?.disk?.used_percent ?? 0;
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
const systemStatus = data?.status ?? "unhealthy";
if (isLoading && !data) {
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
systemStatus === "healthy"
? "bg-green-500/10"
: "bg-red-500/10"
}`}
>
<HeartPulse
className={`size-4 ${
systemStatus === "healthy"
? "text-green-500"
: "text-red-500"
}`}
/>
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Server Health
</span>
<div className="flex items-center gap-1.5">
<span className="relative flex size-2">
{systemStatus === "healthy" && (
<>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
</>
)}
{systemStatus !== "healthy" && (
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
)}
</span>
<span className="text-sm font-semibold text-foreground">
{systemStatus === "healthy"
? "All Systems Operational"
: "System Issues Detected"}
</span>
</div>
<span className="text-[10px] text-muted-foreground">
{data?.timestamp
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
: "Waiting for data..."}
</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
<HardDrive className="size-4 text-blue-500" />
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Disk Space
</span>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold tabular-nums text-foreground">
{diskUsed}% used
</span>
<span className="text-[10px] tabular-nums text-muted-foreground">
{data?.system?.disk?.total_gb ?? "--"} GB total
</span>
</div>
<Progress
value={diskUsed}
className="h-1.5 bg-muted [&>[data-slot=progress-indicator]]:bg-blue-500"
/>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-500/10">
<FileWarning className="size-4 text-red-500" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Temporary Files
</span>
<span className="text-sm font-semibold tabular-nums text-foreground">
{trackedFilesCount} orphaned files
</span>
{purgeResult && (
<span className="text-[10px] text-green-500">
{purgeResult.files_cleaned} files deleted
</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 border-red-200/30 text-red-500 hover:bg-red-500/10 hover:text-red-500 text-xs"
onClick={onPurge}
disabled={isPurging || trackedFilesCount === 0}
>
{isPurging ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
{isPurging
? "Purging..."
: trackedFilesCount === 0
? "Clean"
: "Purge"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { Users, Trophy } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import type { TopUser } from "./types";
interface TopUsersTableProps {
topUsers: TopUser[];
isLoading: boolean;
}
export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Top Utilisateurs
</CardTitle>
<CardDescription>Chargement...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
if (!topUsers || topUsers.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Top Utilisateurs
</CardTitle>
<CardDescription>Aucune donnée disponible</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Users className="h-12 w-12 mb-2 opacity-50" />
<p className="text-sm">Aucune traduction enregistrée</p>
</div>
</CardContent>
</Card>
);
}
const getRankBadge = (rank: number) => {
if (rank === 1) {
return (
<Badge className="bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20">
1er
</Badge>
);
}
if (rank === 2) {
return (
<Badge className="bg-gray-400/10 text-gray-500 hover:bg-gray-400/20">
2e
</Badge>
);
}
if (rank === 3) {
return (
<Badge className="bg-orange-500/10 text-orange-600 hover:bg-orange-500/20">
3e
</Badge>
);
}
return (
<Badge variant="outline" className="text-muted-foreground">
{rank}e
</Badge>
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Top Utilisateurs
</CardTitle>
<CardDescription>
Les 10 utilisateurs les plus actifs par volume de traduction
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rang</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Traductions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topUsers.slice(0, 10).map((user, index) => (
<TableRow key={user.user_id}>
<TableCell>{getRankBadge(index + 1)}</TableCell>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">{user.translation_count}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import { LayoutDashboard, Users, Settings, FileText, Key, type LucideIcon } from 'lucide-react';
export interface AdminNavItem {
label: string;
href: string;
icon: LucideIcon;
}
export const adminNavItems: AdminNavItem[] = [
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Providers', href: '/admin/settings', icon: Key },
{ label: 'System', href: '/admin/system', icon: Settings },
{ label: 'Logs', href: '/admin/logs', icon: FileText },
];

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import { AdminSidebar } from "./AdminSidebar";
import { AdminHeader } from "./AdminHeader";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { settings, setAdminToken } = useTranslationStore();
const [isChecking, setIsChecking] = useState(true);
const [isValid, setIsValid] = useState(false);
const verifyToken = useCallback(async (token: string): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/api/v1/admin/verify`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
},
});
return response.ok;
} catch {
return false;
}
}, []);
useEffect(() => {
if (pathname === "/admin/login") {
setIsChecking(false);
setIsValid(true);
return;
}
const adminToken = settings.adminToken;
if (!adminToken) {
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
return;
}
verifyToken(adminToken).then((valid) => {
if (!valid) {
setAdminToken(undefined);
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
return;
}
setIsValid(true);
setIsChecking(false);
});
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken]);
if (isChecking && pathname !== "/admin/login") {
return (
<div className="min-h-screen bg-card flex items-center justify-center">
<div className="text-muted-foreground text-sm">Vérification de l&apos;authentification...</div>
</div>
);
}
if (!isValid && pathname !== "/admin/login") {
return null;
}
if (pathname === "/admin/login") {
return <>{children}</>;
}
return (
<div className="flex min-h-screen bg-background">
<AdminSidebar />
<div className="flex flex-1 flex-col">
<AdminHeader />
<main className="flex-1 p-4 lg:p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -2,55 +2,25 @@
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { useAdminLogin } from "./useAdminLogin";
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
function AdminLoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { setAdminToken } = useTranslationStore();
const { login, isLoading, error } = useAdminLogin();
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Mot de passe incorrect");
}
const data = await response.json();
setAdminToken(data.access_token);
const redirect = searchParams.get("redirect") || "/admin";
router.push(redirect);
} catch (err: any) {
const errorMessage = typeof err.message === 'string' ? err.message : "Erreur de connexion";
setError(errorMessage);
} finally {
setLoading(false);
}
await login(password);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
<Shield className="w-8 h-8 text-purple-400" />
@@ -59,7 +29,6 @@ function AdminLoginContent() {
<p className="text-gray-400 mt-2">Connexion requise</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
{error && (
<div className="flex items-center gap-3 p-4 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400">
@@ -80,6 +49,7 @@ function AdminLoginContent() {
placeholder="••••••••"
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
required
disabled={isLoading}
/>
<button
type="button"
@@ -93,10 +63,10 @@ function AdminLoginContent() {
<button
type="submit"
disabled={loading || !password}
disabled={isLoading || !password}
className="w-full py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-600/50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2"
>
{loading ? (
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Connexion...

View File

@@ -0,0 +1,18 @@
export interface AdminLoginRequest {
password: string;
}
export interface AdminLoginResponse {
status: string;
access_token: string;
token_type: string;
expires_in: number;
message: string;
}
export interface AdminLoginState {
login: (password: string) => Promise<void>;
logout: () => Promise<void>;
isLoading: boolean;
error: string | null;
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { AdminLoginResponse } from "./types";
const TIMEOUT_MS = 15000;
export function useAdminLogin() {
const router = useRouter();
const { setAdminToken } = useTranslationStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const login = async (password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(`${API_BASE}/api/v1/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
signal: controller.signal,
});
clearTimeout(timeoutId);
const contentType = response.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!response.ok) {
const message = isJson
? (await response.json()).detail || "Mot de passe incorrect"
: "Erreur de connexion au serveur";
throw new Error(message);
}
const data: AdminLoginResponse = isJson ? await response.json() : {};
const token = data.access_token;
if (!token) {
throw new Error("Réponse invalide du serveur");
}
setAdminToken(token);
router.push("/admin");
} catch (err: unknown) {
if (err instanceof Error) {
if (err.name === "AbortError") {
setError("Délai de connexion dépassé. Veuillez réessayer.");
} else {
setError(err.message);
}
} else {
setError("Erreur de connexion");
}
} finally {
setIsLoading(false);
}
};
const logout = useCallback(async (): Promise<void> => {
const token = useTranslationStore.getState().settings.adminToken;
if (token) {
try {
await fetch(`${API_BASE}/api/v1/admin/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Ignore logout errors - proceed with local cleanup
}
}
setAdminToken(undefined);
router.push("/admin/login");
}, [router, setAdminToken]);
return { login, logout, isLoading, error };
}

View File

@@ -1,614 +1,113 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { motion } from "framer-motion";
import { Users, Activity, Settings, FileText, TrendingUp, Server, Key, LogOut, RefreshCw, Search, ChevronRight, Shield, Zap, Globe, DollarSign } from "lucide-react";
interface DashboardData {
translations_today: number;
translations_total: number;
active_users: number;
popular_languages: { [key: string]: number };
average_processing_time: number;
cache_hit_rate: number;
openrouter_usage?: {
total_cost: number;
requests_count: number;
models_used: { [key: string]: number };
};
}
interface User {
id: string;
email: string;
username: string;
plan: string;
translations_count: number;
is_active: boolean;
created_at: string;
last_login?: string;
}
interface AdminSettings {
default_provider: string;
openrouter_enabled: boolean;
google_enabled: boolean;
max_file_size_mb: number;
rate_limit_per_minute: number;
cache_enabled: boolean;
}
function AdminContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { adminToken } = useTranslationStore();
const [activeTab, setActiveTab] = useState<"overview" | "users" | "config" | "settings">("overview");
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [settings, setSettings] = useState<AdminSettings>({
default_provider: "google",
openrouter_enabled: true,
google_enabled: true,
max_file_size_mb: 10,
rate_limit_per_minute: 60,
cache_enabled: true
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [refreshing, setRefreshing] = useState(false);
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
useEffect(() => {
const tab = searchParams.get("tab");
if (tab && ["overview", "users", "config", "settings"].includes(tab)) {
setActiveTab(tab as any);
}
}, [searchParams]);
useEffect(() => {
if (!adminToken) {
router.push("/admin/login");
return;
}
fetchDashboardData();
}, [adminToken]);
useEffect(() => {
if (activeTab === "users" && users.length === 0) {
fetchUsers();
}
}, [activeTab]);
const fetchDashboardData = async () => {
try {
setLoading(true);
const response = await fetch(`${API_BASE}/admin/dashboard`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (!response.ok) throw new Error("Failed to fetch dashboard data");
const data = await response.json();
setDashboardData(data);
} catch (err) {
setError("Erreur de chargement des données");
console.error(err);
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
const response = await fetch(`${API_BASE}/admin/users`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (!response.ok) throw new Error("Failed to fetch users");
const data = await response.json();
setUsers(data.users || []);
} catch (err) {
console.error("Error fetching users:", err);
}
};
const refreshData = async () => {
setRefreshing(true);
await fetchDashboardData();
if (activeTab === "users") {
await fetchUsers();
}
setRefreshing(false);
};
const handleLogout = () => {
useTranslationStore.getState().setAdminToken(null);
router.push("/admin/login");
};
const filteredUsers = users.filter(user =>
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.username?.toLowerCase().includes(searchQuery.toLowerCase())
);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
<div className="text-white text-xl flex items-center gap-3">
<RefreshCw className="animate-spin" />
Chargement...
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
<header className="bg-black/30 backdrop-blur-xl border-b border-white/10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-purple-400" />
<h1 className="text-2xl font-bold text-white">Administration</h1>
</div>
<div className="flex items-center gap-4">
<button
onClick={refreshData}
disabled={refreshing}
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-all"
>
<RefreshCw className={`w-5 h-5 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-400 transition-all"
>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Tab Navigation */}
<div className="flex gap-2 mb-8 bg-black/20 p-2 rounded-xl w-fit">
{[
{ id: "overview", label: "Vue d'ensemble", icon: Activity },
{ id: "users", label: "Utilisateurs", icon: Users },
{ id: "config", label: "Configuration", icon: Server },
{ id: "settings", label: "Paramètres", icon: Settings }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
activeTab === tab.id
? "bg-purple-600 text-white shadow-lg"
: "text-gray-400 hover:text-white hover:bg-white/10"
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Overview Tab */}
{activeTab === "overview" && dashboardData && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Traductions Aujourd'hui"
value={dashboardData.translations_today ?? 0}
icon={FileText}
color="purple"
/>
<StatCard
title="Total Traductions"
value={dashboardData.translations_total ?? 0}
icon={TrendingUp}
color="blue"
/>
<StatCard
title="Utilisateurs Actifs"
value={dashboardData.active_users ?? 0}
icon={Users}
color="green"
/>
<StatCard
title="Taux Cache"
value={`${((dashboardData.cache_hit_rate ?? 0) * 100).toFixed(1)}%`}
icon={Zap}
color="yellow"
/>
</div>
{/* OpenRouter Usage */}
{dashboardData.openrouter_usage && (
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-purple-400" />
Utilisation OpenRouter
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-400 text-sm">Coût Total</p>
<p className="text-2xl font-bold text-green-400">
${dashboardData.openrouter_usage.total_cost?.toFixed(4) ?? '0.0000'}
</p>
</div>
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-400 text-sm">Requêtes</p>
<p className="text-2xl font-bold text-blue-400">
{dashboardData.openrouter_usage.requests_count ?? 0}
</p>
</div>
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-400 text-sm">Temps Moyen</p>
<p className="text-2xl font-bold text-yellow-400">
{(dashboardData.average_processing_time ?? 0).toFixed(2)}s
</p>
</div>
</div>
</div>
)}
{/* Popular Languages */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-purple-400" />
Langues Populaires
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(dashboardData.popular_languages || {}).slice(0, 8).map(([lang, count]) => (
<div key={lang} className="bg-white/5 rounded-xl p-4 text-center">
<p className="text-2xl font-bold text-white">{count}</p>
<p className="text-gray-400 text-sm uppercase">{lang}</p>
</div>
))}
</div>
</div>
</motion.div>
)}
{/* Users Tab */}
{activeTab === "users" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Search */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Rechercher un utilisateur..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
/>
</div>
<div className="text-gray-400">
{filteredUsers.length} utilisateur(s)
</div>
</div>
{/* Users Table */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
<th className="text-left p-4 text-gray-400 font-medium">Utilisateur</th>
<th className="text-left p-4 text-gray-400 font-medium">Plan</th>
<th className="text-left p-4 text-gray-400 font-medium">Traductions</th>
<th className="text-left p-4 text-gray-400 font-medium">Statut</th>
<th className="text-left p-4 text-gray-400 font-medium">Inscrit le</th>
<th className="text-left p-4 text-gray-400 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id} className="border-b border-white/5 hover:bg-white/5 transition-colors">
<td className="p-4">
<div>
<p className="text-white font-medium">{user.username || 'N/A'}</p>
<p className="text-gray-400 text-sm">{user.email}</p>
</div>
</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.plan === 'premium'
? 'bg-purple-500/20 text-purple-400'
: user.plan === 'pro'
? 'bg-blue-500/20 text-blue-400'
: 'bg-gray-500/20 text-gray-400'
}`}>
{user.plan || 'free'}
</span>
</td>
<td className="p-4 text-white">{user.translations_count ?? 0}</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.is_active
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}>
{user.is_active ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="p-4 text-gray-400 text-sm">
{user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : 'N/A'}
</td>
<td className="p-4">
<button className="p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-all">
<ChevronRight className="w-5 h-5" />
</button>
</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-400">
Aucun utilisateur trouvé
</td>
</tr>
)}
</tbody>
</table>
</div>
</motion.div>
)}
{/* Config Tab */}
{activeTab === "config" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Translation Providers */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Server className="w-5 h-5 text-purple-400" />
Fournisseurs de Traduction
</h3>
<div className="space-y-4">
{/* Google Translate */}
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
<Globe className="w-6 h-6 text-blue-400" />
</div>
<div>
<h4 className="text-white font-medium">Google Translate</h4>
<p className="text-gray-400 text-sm">API officielle Google Cloud</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.google_enabled}
onChange={(e) => setSettings({ ...settings, google_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{/* OpenRouter */}
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
<Zap className="w-6 h-6 text-purple-400" />
</div>
<div>
<h4 className="text-white font-medium">OpenRouter</h4>
<p className="text-gray-400 text-sm">Modèles IA avancés (GPT-4, Claude, etc.)</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.openrouter_enabled}
onChange={(e) => setSettings({ ...settings, openrouter_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
</div>
{/* API Keys */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Key className="w-5 h-5 text-purple-400" />
Clés API
</h3>
<div className="space-y-4">
<div className="p-4 bg-white/5 rounded-xl">
<label className="block text-gray-400 text-sm mb-2">Google Cloud API Key</label>
<div className="flex gap-2">
<input
type="password"
placeholder="••••••••••••••••"
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
/>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
Sauvegarder
</button>
</div>
</div>
<div className="p-4 bg-white/5 rounded-xl">
<label className="block text-gray-400 text-sm mb-2">OpenRouter API Key</label>
<div className="flex gap-2">
<input
type="password"
placeholder="••••••••••••••••"
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
/>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
Sauvegarder
</button>
</div>
</div>
</div>
</div>
{/* Default Provider */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-purple-400" />
Fournisseur par Défaut
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setSettings({ ...settings, default_provider: 'google' })}
className={`p-4 rounded-xl border-2 transition-all ${
settings.default_provider === 'google'
? 'border-purple-500 bg-purple-500/10'
: 'border-white/10 bg-white/5 hover:border-white/20'
}`}
>
<Globe className={`w-8 h-8 mb-2 ${settings.default_provider === 'google' ? 'text-purple-400' : 'text-gray-400'}`} />
<h4 className="text-white font-medium">Google Translate</h4>
<p className="text-gray-400 text-sm">Rapide et fiable</p>
</button>
<button
onClick={() => setSettings({ ...settings, default_provider: 'openrouter' })}
className={`p-4 rounded-xl border-2 transition-all ${
settings.default_provider === 'openrouter'
? 'border-purple-500 bg-purple-500/10'
: 'border-white/10 bg-white/5 hover:border-white/20'
}`}
>
<Zap className={`w-8 h-8 mb-2 ${settings.default_provider === 'openrouter' ? 'text-purple-400' : 'text-gray-400'}`} />
<h4 className="text-white font-medium">OpenRouter</h4>
<p className="text-gray-400 text-sm">IA avancée</p>
</button>
</div>
</div>
</motion.div>
)}
{/* Settings Tab */}
{activeTab === "settings" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Limits */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-purple-400" />
Limites
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-gray-400 text-sm mb-2">Taille max fichier (MB)</label>
<input
type="number"
value={settings.max_file_size_mb}
onChange={(e) => setSettings({ ...settings, max_file_size_mb: parseInt(e.target.value) || 10 })}
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">Requêtes/minute</label>
<input
type="number"
value={settings.rate_limit_per_minute}
onChange={(e) => setSettings({ ...settings, rate_limit_per_minute: parseInt(e.target.value) || 60 })}
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Cache */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Zap className="w-5 h-5 text-purple-400" />
Cache
</h3>
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div>
<h4 className="text-white font-medium">Cache des traductions</h4>
<p className="text-gray-400 text-sm">Améliore les performances et réduit les coûts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.cache_enabled}
onChange={(e) => setSettings({ ...settings, cache_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-medium transition-all flex items-center gap-2">
Sauvegarder les paramètres
</button>
</div>
</motion.div>
)}
</div>
</div>
);
}
function StatCard({ title, value, icon: Icon, color }: { title: string; value: string | number; icon: any; color: string }) {
const colorClasses = {
purple: 'bg-purple-500/20 text-purple-400',
blue: 'bg-blue-500/20 text-blue-400',
green: 'bg-green-500/20 text-green-400',
yellow: 'bg-yellow-500/20 text-yellow-400'
};
return (
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-xl ${colorClasses[color as keyof typeof colorClasses]}`}>
<Icon className="w-6 h-6" />
</div>
</div>
<p className="text-3xl font-bold text-white mb-1">{value}</p>
<p className="text-gray-400 text-sm">{title}</p>
</div>
);
}
import { Shield, RefreshCw, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SystemHealthCards } from "./SystemHealthCards";
import { ProviderStatus } from "./ProviderStatus";
import { useAdminDashboard } from "./useAdminDashboard";
import { useCleanup } from "./useCleanup";
import {
TooltipProvider,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function AdminPage() {
const { data, isLoading, error, refetch } = useAdminDashboard();
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
const handlePurge = async () => {
await triggerCleanup();
refetch();
};
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
<div className="text-white text-xl">Chargement...</div>
<TooltipProvider>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-purple-500/10">
<Shield className="size-5 text-purple-500" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">
Dashboard Admin
</h1>
<p className="text-sm text-muted-foreground">
Panneau de contrôle administrateur
</p>
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => refetch()}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Refresh
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh dashboard data</p>
</TooltipContent>
</Tooltip>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
)}
<SystemHealthCards
data={data}
isLoading={isLoading}
isPurging={isPurging}
onPurge={handlePurge}
purgeResult={purgeResult}
/>
<ProviderStatus data={data} isLoading={isLoading} />
{data?.config && (
<div className="rounded-lg border border-border bg-card px-4 py-3">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
System Configuration
</span>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span>
Max file size:{" "}
<strong className="text-foreground">
{data.config.max_file_size_mb}MB
</strong>
</span>
<span>
Translation service:{" "}
<strong className="text-foreground">
{data.config.translation_service}
</strong>
</span>
<span>
Formats:{" "}
<strong className="text-foreground">
{data.config.supported_extensions.join(", ")}
</strong>
</span>
</div>
</div>
)}
</div>
}>
<AdminContent />
</Suspense>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,574 @@
"use client";
import { useState, useEffect } from "react";
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNotification } from "@/components/ui/notification";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
interface ProviderConfig {
enabled: boolean;
api_key?: string;
base_url?: string;
model?: string;
timeout?: number;
max_retries?: number;
}
interface SettingsConfig {
google: ProviderConfig;
deepl: ProviderConfig;
openai: ProviderConfig;
ollama: ProviderConfig;
openrouter: ProviderConfig;
openrouter_premium: ProviderConfig;
zai: ProviderConfig;
fallback_chain: string;
fallback_chain_classic: string;
fallback_chain_llm: string;
}
interface EnvInfo {
deepl: boolean;
openai: boolean;
openrouter: boolean;
openrouter_premium: boolean;
zai: boolean;
ollama: boolean;
}
interface OllamaModel {
name: string;
size: number;
modified_at: string;
}
const defaultConfig: SettingsConfig = {
google: { enabled: true, timeout: 30, max_retries: 3 },
deepl: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
fallback_chain: "google,deepl,openai,ollama,openrouter,openrouter_premium,zai",
fallback_chain_classic: "google,deepl",
fallback_chain_llm: "ollama,openai,openrouter,zai",
};
const defaultEnvInfo: EnvInfo = {
deepl: false,
openai: false,
openrouter: false,
openrouter_premium: false,
zai: false,
ollama: false,
};
export default function AdminSettingsPage() {
const [config, setConfig] = useState<SettingsConfig>(defaultConfig);
const [envInfo, setEnvInfo] = useState<EnvInfo>(defaultEnvInfo);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [testResults, setTestResults] = useState<Record<string, "ok" | "error" | "testing" | "idle">>({});
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const { success, error, info } = useNotification();
useEffect(() => {
loadConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
const loadConfig = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (response.ok) {
const envelope = await response.json();
// API returns { data: {...settings...}, env_info: {...}, meta: {} }
const payload = envelope.data ?? envelope;
setConfig({ ...defaultConfig, ...payload });
if (envelope.env_info) {
setEnvInfo({ ...defaultEnvInfo, ...envelope.env_info });
}
} else {
error({ title: "Erreur de chargement", description: `HTTP ${response.status} — vérifiez votre token admin.` });
}
} catch (e) {
error({ title: "Erreur réseau", description: "Impossible de contacter le backend." });
console.error("Failed to load settings:", e);
} finally {
setIsLoading(false);
}
};
const saveConfig = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (response.ok) {
success({ title: "✅ Configuration sauvegardée", description: "Les paramètres ont été enregistrés avec succès." });
} else {
const body = await response.json().catch(() => ({}));
error({ title: "Erreur de sauvegarde", description: body.detail || `HTTP ${response.status}` });
}
} catch (e) {
error({ title: "Erreur réseau", description: "Impossible de contacter le backend pour la sauvegarde." });
} finally {
setIsSaving(false);
}
};
const testProvider = async (provider: string) => {
setTestResults((prev) => ({ ...prev, [provider]: "testing" }));
setTestMessages((prev) => ({ ...prev, [provider]: "" }));
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
{
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
}
);
const data = await response.json();
if (data.available) {
setTestResults((prev) => ({ ...prev, [provider]: "ok" }));
const detail = data.test_result || data.usage || data.models_count !== undefined
? `Connexion OK${data.models_count !== undefined ? `${data.models_count} modèles` : ""}${data.test_result ? ` — "${data.test_result}"` : ""}`
: "Connexion OK";
setTestMessages((prev) => ({ ...prev, [provider]: detail }));
} else {
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
setTestMessages((prev) => ({ ...prev, [provider]: data.error || "Échec" }));
}
} catch (e) {
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
setTestMessages((prev) => ({ ...prev, [provider]: "Erreur réseau" }));
}
};
const fetchOllamaModels = async () => {
setIsLoadingModels(true);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/ollama/models`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
);
if (response.ok) {
const data = await response.json();
setOllamaModels(data.data || []);
if (data.data?.length > 0 && !config.ollama.model) {
updateProvider("ollama", { model: data.data[0].name });
}
info({ title: `${data.data?.length || 0} modèles Ollama trouvés` });
} else {
error({ title: "Ollama inaccessible", description: "Vérifiez que Ollama tourne sur l'URL configurée." });
}
} catch (e) {
error({ title: "Erreur Ollama", description: "Impossible de contacter Ollama." });
} finally {
setIsLoadingModels(false);
}
};
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm">;
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
[provider]: { ...prev[provider], ...updates } as ProviderConfig,
}));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<Settings className="w-5 h-5 text-purple-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Paramètres des providers</h1>
<p className="text-sm text-muted-foreground">
Configurez les clés API. Les clés peuvent aussi être définies dans le fichier <code>.env</code>.
</p>
</div>
</div>
<div className="grid gap-4">
<ProviderCard
title="Google Translate"
description="Tier gratuit : 500 000 caractères/mois. Aucune clé requise."
enabled={config.google.enabled}
onToggle={(enabled) => updateProvider("google", { enabled })}
onTest={() => testProvider("google")}
testResult={testResults.google ?? "idle"}
testMessage={testMessages.google}
noApiKey
/>
<ProviderCard
title="DeepL"
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
enabled={config.deepl.enabled}
onToggle={(enabled) => updateProvider("deepl", { enabled })}
onTest={() => testProvider("deepl")}
testResult={testResults.deepl ?? "idle"}
testMessage={testMessages.deepl}
envKeySet={envInfo.deepl}
>
<div className="space-y-2">
<Label htmlFor="deepl-key">Clé API</Label>
<Input
id="deepl-key"
type="password"
placeholder={envInfo.deepl ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Entrez votre clé DeepL"}
value={config.deepl.api_key || ""}
onChange={(e) => updateProvider("deepl", { api_key: e.target.value })}
/>
</div>
</ProviderCard>
<ProviderCard
title="OpenAI"
description="Traductions GPT-4. Obtenez une clé sur platform.openai.com"
enabled={config.openai.enabled}
onToggle={(enabled) => updateProvider("openai", { enabled })}
onTest={() => testProvider("openai")}
testResult={testResults.openai ?? "idle"}
testMessage={testMessages.openai}
envKeySet={envInfo.openai}
>
<div className="space-y-2">
<Label htmlFor="openai-key">Clé API</Label>
<Input
id="openai-key"
type="password"
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
value={config.openai.api_key || ""}
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
/>
</div>
</ProviderCard>
<ProviderCard
title="Ollama"
description="LLM local. Nécessite Ollama en cours d'exécution."
enabled={config.ollama.enabled}
onToggle={(enabled) => updateProvider("ollama", { enabled })}
onTest={() => testProvider("ollama")}
testResult={testResults.ollama ?? "idle"}
testMessage={testMessages.ollama}
envKeySet={envInfo.ollama}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ollama-url">URL de base</Label>
<Input
id="ollama-url"
placeholder={envInfo.ollama ? "URL configurée dans .env" : "http://localhost:11434"}
value={config.ollama.base_url || ""}
onChange={(e) => updateProvider("ollama", { base_url: e.target.value })}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="ollama-model">Modèle</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchOllamaModels}
disabled={isLoadingModels}
className="h-7 px-2 text-xs"
>
{isLoadingModels ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1">Récupérer les modèles</span>
</Button>
</div>
{ollamaModels.length > 0 ? (
<Select
value={config.ollama.model || ""}
onValueChange={(value) => updateProvider("ollama", { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un modèle" />
</SelectTrigger>
<SelectContent>
{ollamaModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.name}
{model.size > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
({(model.size / 1e9).toFixed(1)} GB)
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="ollama-model"
placeholder="llama3"
value={config.ollama.model || ""}
onChange={(e) => updateProvider("ollama", { model: e.target.value })}
/>
)}
{ollamaModels.length === 0 && (
<p className="text-xs text-muted-foreground">
Cliquez sur &quot;Récupérer les modèles&quot; pour charger la liste depuis Ollama.
</p>
)}
</div>
</div>
</ProviderCard>
<ProviderCard
title="Traduction IA Essentielle"
description="Affichée aux utilisateurs comme 'Traduction IA Essentielle'. Modèles économiques recommandés : deepseek/deepseek-chat, google/gemini-2.0-flash, meta-llama/llama-3.3-70b-instruct. Clé API : openrouter.ai"
enabled={config.openrouter.enabled}
onToggle={(enabled) => updateProvider("openrouter", { enabled })}
onTest={() => testProvider("openrouter")}
testResult={testResults.openrouter ?? "idle"}
testMessage={testMessages.openrouter}
envKeySet={envInfo.openrouter}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="openrouter-key">Clé API OpenRouter</Label>
<Input
id="openrouter-key"
type="password"
placeholder={envInfo.openrouter ? "Clé configurée dans .env (partagée avec Premium)" : "sk-or-..."}
value={config.openrouter.api_key || ""}
onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
<Input
id="openrouter-model"
placeholder="deepseek/deepseek-chat"
value={config.openrouter.model || ""}
onChange={(e) => updateProvider("openrouter", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~0.04/doc)</p>
</div>
</div>
</ProviderCard>
<ProviderCard
title="Traduction IA Premium"
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-3.5-sonnet, google/gemini-1.5-pro. Partage la même clé OpenRouter."
enabled={config.openrouter_premium.enabled}
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
onTest={() => testProvider("openrouter_premium")}
testResult={testResults.openrouter_premium ?? "idle"}
testMessage={testMessages.openrouter_premium}
envKeySet={envInfo.openrouter_premium}
>
<div className="space-y-2">
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
<Input
id="openrouter-premium-model"
placeholder="openai/gpt-4o-mini"
value={config.openrouter_premium.model || ""}
onChange={(e) => updateProvider("openrouter_premium", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>openai/gpt-4o-mini</code> (~0.15/doc) ou <code>anthropic/claude-3.5-haiku</code> (~0.20/doc)
</p>
</div>
</ProviderCard>
<ProviderCard
title="z.AI / xAI Grok"
description="Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai"
enabled={config.zai.enabled}
onToggle={(enabled) => updateProvider("zai", { enabled })}
onTest={() => testProvider("zai")}
testResult={testResults.zai ?? "idle"}
testMessage={testMessages.zai}
envKeySet={envInfo.zai}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="zai-key">Clé API</Label>
<Input
id="zai-key"
type="password"
placeholder={envInfo.zai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "xai-..."}
value={config.zai.api_key || ""}
onChange={(e) => updateProvider("zai", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="zai-model">Modèle</Label>
<Input
id="zai-model"
placeholder="grok-2-1212"
value={config.zai.model || ""}
onChange={(e) => updateProvider("zai", { model: e.target.value })}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="zai-url">URL de base</Label>
<Input
id="zai-url"
placeholder="https://api.x.ai/v1"
value={config.zai.base_url || ""}
onChange={(e) => updateProvider("zai", { base_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Par défaut : <code>https://api.x.ai/v1</code> — à changer uniquement si vous utilisez un proxy.
</p>
</div>
</ProviderCard>
<Card>
<CardHeader>
<CardTitle className="text-base">Chaîne de fallback</CardTitle>
<CardDescription>Ordre de priorité pour la sélection des providers</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Mode classique (Google/DeepL)</Label>
<Input
value={config.fallback_chain_classic}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
placeholder="google,deepl"
/>
</div>
<div className="space-y-2">
<Label>Mode LLM (Ollama/OpenAI)</Label>
<Input
value={config.fallback_chain_llm}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))}
placeholder="ollama,openai"
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={saveConfig} disabled={isSaving} size="lg">
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Sauvegarde...
</>
) : (
<>
<Save className="mr-2 size-4" />
Sauvegarder la configuration
</>
)}
</Button>
</div>
</div>
);
}
function ProviderCard({
title,
description,
enabled,
onToggle,
onTest,
testResult,
testMessage,
noApiKey = false,
envKeySet = false,
children,
}: {
title: string;
description: string;
enabled: boolean;
onToggle: (enabled: boolean) => void;
onTest: () => void;
testResult: "ok" | "error" | "testing" | "idle";
testMessage?: string;
noApiKey?: boolean;
envKeySet?: boolean;
children?: React.ReactNode;
}) {
return (
<Card className={enabled ? "border-primary/30" : ""}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base">{title}</CardTitle>
<Badge variant={enabled ? "default" : "secondary"} className="text-xs">
{enabled ? "Activé" : "Désactivé"}
</Badge>
{envKeySet && !noApiKey && (
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
<KeyRound className="size-3" />
Clé dans .env
</Badge>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onTest}
disabled={testResult === "testing"}
className="h-8"
>
{testResult === "testing" ? (
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
) : testResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
) : testResult === "error" ? (
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
) : (
<><FlaskConical className="size-3 mr-1" />Tester</>
)}
</Button>
<Switch checked={enabled} onCheckedChange={onToggle} />
</div>
</div>
<CardDescription>{description}</CardDescription>
{testMessage && (
<p className={`text-xs mt-1 ${testResult === "ok" ? "text-green-400" : "text-red-400"}`}>
{testMessage}
</p>
)}
</CardHeader>
{!noApiKey && children && <CardContent className="pt-0">{children}</CardContent>}
</Card>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BarChart3, RefreshCw, Loader2, AlertCircle, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StatsOverview } from "../StatsOverview";
import { TopUsersTable } from "../TopUsersTable";
import { ProviderBreakdownChart } from "../ProviderBreakdownChart";
import { FormatBreakdownChart } from "../FormatBreakdownChart";
import { DateRangeFilter } from "../DateRangeFilter";
import { useTranslationStats } from "../useTranslationStats";
import type { StatsPeriod } from "../types";
import {
TooltipProvider,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const ERROR_RATE_WARNING_THRESHOLD = 5;
export default function StatsPage() {
const [period, setPeriod] = useState<StatsPeriod>("today");
const { data, isLoading, error, refetch, isMockData } = useTranslationStats(period);
return (
<TooltipProvider>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-500/10">
<BarChart3 className="size-5 text-blue-500" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">
Statistiques de Traduction
</h1>
<p className="text-sm text-muted-foreground">
Analyse des traductions et patterns d&apos;utilisation
</p>
</div>
</div>
<div className="flex items-center gap-3">
<DateRangeFilter value={period} onChange={setPeriod} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => refetch()}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Actualiser
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Actualiser les statistiques</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
)}
<StatsOverview data={data} isLoading={isLoading} />
<div className="grid gap-6 md:grid-cols-2">
<ProviderBreakdownChart data={data} isLoading={isLoading} />
<FormatBreakdownChart data={data} isLoading={isLoading} />
</div>
<TopUsersTable
topUsers={data?.top_users ?? []}
isLoading={isLoading}
/>
{data && (
<div className="rounded-lg border border-border bg-card px-4 py-3">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Informations
</span>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
{isMockData && (
<span className="flex items-center gap-1 text-amber-500">
<Info className="h-3 w-3" />
<strong>Mode Démo</strong> - Données simulées
</span>
)}
<span>
Rafraîchissement auto:{" "}
<strong className="text-foreground">toutes les 30 secondes</strong>
</span>
<span>
Période:{" "}
<strong className="text-foreground">
{period === "today"
? "Aujourd'hui"
: period === "week"
? "7 derniers jours"
: "30 derniers jours"}
</strong>
</span>
{data.error_rate > ERROR_RATE_WARNING_THRESHOLD && (
<span className="text-orange-500">
Taux d&apos;erreur élevé détecté ({data.error_rate.toFixed(1)}%)
</span>
)}
</div>
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2 } from "lucide-react";
import type { CleanupResponse } from "../types";
interface CleanupSectionProps {
trackedFilesCount: number;
isPurging: boolean;
purgeResult: CleanupResponse | null;
onCleanup: () => void;
}
export function CleanupSection({
trackedFilesCount,
isPurging,
purgeResult,
onCleanup,
}: CleanupSectionProps) {
return (
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-500/10">
<Trash2 className="size-4 text-red-500" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Fichiers Temporaires
</span>
<span className="text-sm font-semibold tabular-nums text-foreground">
{trackedFilesCount} fichier{trackedFilesCount !== 1 ? "s" : ""} orphelin{trackedFilesCount !== 1 ? "s" : ""}
</span>
{purgeResult && (
<span className="text-[10px] text-green-500">
{purgeResult.files_cleaned} fichier{purgeResult.files_cleaned !== 1 ? "s" : ""} supprimé{purgeResult.files_cleaned !== 1 ? "s" : ""}
</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 border-red-200/30 text-red-500 hover:bg-red-500/10 hover:text-red-500 text-xs"
onClick={onCleanup}
disabled={isPurging || trackedFilesCount === 0}
>
{isPurging ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
{isPurging
? "Nettoyage..."
: trackedFilesCount === 0
? "Propre"
: "Nettoyer"}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { HardDrive } from "lucide-react";
interface DiskSpaceCardProps {
usedPercent?: number;
totalGb?: number;
freeGb?: number;
}
export function DiskSpaceCard({
usedPercent = 0,
totalGb,
freeGb,
}: DiskSpaceCardProps) {
return (
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
<HardDrive className="size-4 text-blue-500" />
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Espace Disque
</span>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold tabular-nums text-foreground">
{usedPercent.toFixed(1)}% utilisé
</span>
{totalGb !== undefined && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{totalGb} GB total
</span>
)}
</div>
<Progress
value={usedPercent}
className="h-1.5 bg-muted [&>[data-slot=progress-indicator]]:bg-blue-500"
/>
{freeGb !== undefined && (
<span className="text-[10px] text-muted-foreground">
{freeGb.toFixed(1)} GB libres
</span>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { Settings, AlertCircle, Loader2 } from "lucide-react";
import { useSystemPage } from "./useSystemPage";
import { CleanupSection } from "./CleanupSection";
import { DiskSpaceCard } from "./DiskSpaceCard";
import { ProviderStatus } from "../ProviderStatus";
export default function AdminSystemPage() {
const { data, isLoading, error, isPurging, purgeResult, handleCleanup } = useSystemPage();
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<Settings className="w-5 h-5 text-purple-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Système</h1>
<p className="text-sm text-muted-foreground">
Surveiller l'état du système et gérer les ressources
</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
)}
{isLoading && !data ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{[1, 2].map((i) => (
<div
key={i}
className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<DiskSpaceCard
usedPercent={data?.system?.disk?.used_percent}
totalGb={data?.system?.disk?.total_gb}
freeGb={data?.system?.disk?.free_gb}
/>
<CleanupSection
trackedFilesCount={data?.cleanup?.tracked_files_count ?? 0}
isPurging={isPurging}
purgeResult={purgeResult}
onCleanup={handleCleanup}
/>
</div>
)}
<ProviderStatus data={data} isLoading={isLoading} />
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useAdminDashboard } from "../useAdminDashboard";
import { useCleanup } from "../useCleanup";
export function useSystemPage() {
const { data, isLoading, error } = useAdminDashboard();
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup();
const handleCleanup = () => triggerCleanup();
return {
data,
isLoading,
error: error || cleanupError,
isPurging,
purgeResult,
handleCleanup,
};
}

View File

@@ -0,0 +1,76 @@
export interface AdminDashboardData {
timestamp: string;
status: "healthy" | "unhealthy";
system: {
memory: Record<string, unknown>;
disk: {
used_percent?: number;
total_gb?: number;
free_gb?: number;
};
};
providers: Record<string, ProviderStatus>;
cleanup: {
files_cleaned: number;
tracked_files_count: number;
};
rate_limits: {
active_clients: number;
};
config: {
max_file_size_mb: number;
supported_extensions: string[];
translation_service: string;
};
}
export interface ProviderStatus {
name: string;
available: boolean;
last_check: string | null;
latency_ms?: number;
error?: string;
}
export interface CleanupResponse {
status: "success" | "error";
files_cleaned: number;
message: string;
}
export type StatsPeriod = "today" | "week" | "month";
export interface ProviderBreakdownItem {
count: number;
percentage: number;
}
export interface FormatBreakdownItem {
count: number;
percentage: number;
}
export interface TopUser {
user_id: string;
email: string;
translation_count: number;
}
export interface TranslationStatsData {
period: StatsPeriod;
total_translations: number;
total_translations_last_period: number;
error_rate: number;
error_count: number;
success_count: number;
top_users: TopUser[];
provider_breakdown: Record<string, ProviderBreakdownItem>;
format_breakdown: Record<string, FormatBreakdownItem>;
}
export interface TranslationStatsResponse {
data: TranslationStatsData;
meta: {
generated_at: string;
};
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { AdminDashboardData } from "./types";
const TIMEOUT_MS = 15000;
export const REFETCH_INTERVAL_MS = 30000;
export const QUERY_KEY = ["admin", "dashboard"];
async function fetchDashboardData(adminToken: string | null | undefined): Promise<AdminDashboardData> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useAdminDashboard() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const { data, isLoading, error, refetch } = useQuery({
queryKey: QUERY_KEY,
queryFn: () => fetchDashboardData(settings.adminToken),
enabled: !!settings.adminToken,
refetchInterval: REFETCH_INTERVAL_MS,
staleTime: 10000,
retry: 1,
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour accéder au tableau de bord",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_404: "Service indisponible. Veuillez réessayer plus tard.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
HTTP_ERROR_502: "Service temporairement indisponible.",
HTTP_ERROR_503: "Service en maintenance. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
if (err.message.includes("fetch") || err.message.includes("network")) {
return "Impossible de se connecter au serveur. Vérifiez votre connexion.";
}
return "Une erreur inattendue s'est produite. Veuillez réessayer.";
};
const errorMessage = error ? getErrorMessage(error as Error) : null;
return {
data: data ?? null,
isLoading,
error: errorMessage,
refetch,
queryClient,
queryKey: QUERY_KEY,
};
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { CleanupResponse } from "./types";
import { QUERY_KEY as DASHBOARD_QUERY_KEY } from "./useAdminDashboard";
const TIMEOUT_MS = 15000;
async function triggerCleanupApi(adminToken: string | null | undefined): Promise<CleanupResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/cleanup/trigger`, {
method: "POST",
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useCleanup() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => triggerCleanupApi(settings.adminToken),
onSuccess: () => {
// Invalidate dashboard cache after successful cleanup
queryClient.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEY });
},
});
// Map error codes to user-friendly messages
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_500: "Erreur serveur lors du nettoyage. Veuillez réessayer.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
return "Erreur lors du nettoyage. Veuillez réessayer.";
};
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
return {
isPurging: mutation.isPending,
purgeResult: mutation.data ?? null,
error: errorMessage,
triggerCleanup: mutation.mutateAsync,
};
}

View File

@@ -0,0 +1,159 @@
"use client";
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { StatsPeriod, TranslationStatsResponse } from "./types";
const TIMEOUT_MS = 15000;
export const REFETCH_INTERVAL_MS = 30000;
export const QUERY_KEY = (period: StatsPeriod) => ["admin", "stats", "translations", period];
async function fetchTranslationStats(
adminToken: string | null | undefined,
period: StatsPeriod
): Promise<TranslationStatsResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/stats/translations?period=${period}`,
{
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (response.status === 404) {
throw new Error("ENDPOINT_NOT_FOUND");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
function getMockData(period: StatsPeriod): TranslationStatsResponse {
const baseCount = period === "today" ? 42 : period === "week" ? 287 : 1156;
const lastPeriodCount = period === "today" ? 38 : period === "week" ? 254 : 1023;
return {
data: {
period,
total_translations: baseCount,
total_translations_last_period: lastPeriodCount,
error_rate: 2.3,
error_count: Math.floor(baseCount * 0.023),
success_count: Math.floor(baseCount * 0.977),
top_users: [
{ user_id: "user_1", email: "sarah.chen@acme.com", translation_count: 15 },
{ user_id: "user_2", email: "marc.dubois@example.fr", translation_count: 12 },
{ user_id: "user_3", email: "anna.mueller@corp.de", translation_count: 8 },
{ user_id: "user_4", email: "john.smith@company.uk", translation_count: 6 },
{ user_id: "user_5", email: "lisa.wong@startup.io", translation_count: 5 },
{ user_id: "user_6", email: "pierre.leroux@mail.fr", translation_count: 4 },
{ user_id: "user_7", email: "emma.johnson@tech.us", translation_count: 3 },
{ user_id: "user_8", email: "klaus.weber@firm.de", translation_count: 2 },
{ user_id: "user_9", email: "sofia.garcia@empresa.es", translation_count: 2 },
{ user_id: "user_10", email: "yuki.tanaka@office.jp", translation_count: 1 },
],
provider_breakdown: {
google: { count: Math.floor(baseCount * 0.476), percentage: 47.6 },
deepl: { count: Math.floor(baseCount * 0.357), percentage: 35.7 },
ollama: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
openai: { count: Math.floor(baseCount * 0.048), percentage: 4.8 },
},
format_breakdown: {
xlsx: { count: Math.floor(baseCount * 0.595), percentage: 59.5 },
docx: { count: Math.floor(baseCount * 0.286), percentage: 28.6 },
pptx: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
},
},
meta: {
generated_at: new Date().toISOString(),
},
};
}
export function useTranslationStats(period: StatsPeriod = "today") {
const { settings } = useTranslationStore();
const [isMockData, setIsMockData] = React.useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: QUERY_KEY(period),
queryFn: async () => {
try {
const result = await fetchTranslationStats(settings.adminToken, period);
setIsMockData(false);
return result;
} catch (err) {
if ((err as Error).message === "ENDPOINT_NOT_FOUND") {
setIsMockData(true);
return getMockData(period);
}
throw err;
}
},
enabled: !!settings.adminToken,
refetchInterval: REFETCH_INTERVAL_MS,
staleTime: 10000,
retry: 1,
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour accéder aux statistiques",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre.";
}
if (err.message.includes("fetch") || err.message.includes("network")) {
return "Impossible de se connecter au serveur.";
}
return "Une erreur inattendue s'est produite.";
};
const errorMessage = error ? getErrorMessage(error as Error) : null;
return {
data: data?.data ?? null,
isLoading,
error: errorMessage,
refetch,
queryKey: QUERY_KEY(period),
isMockData,
};
}

View File

@@ -0,0 +1,116 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Users, UserCheck, Crown, Zap } from "lucide-react";
import type { AdminUser } from "./types";
import { PLAN_LABELS } from "./types";
interface UserStatsProps {
users: AdminUser[];
total: number;
isLoading?: boolean;
}
export function UserStats({ users, total, isLoading }: UserStatsProps) {
const activeUsers = users.filter((u) => u.subscription_status === "active").length;
const proUsers = users.filter((u) => u.plan === "pro" || u.plan === "business" || u.plan === "enterprise").length;
const freeUsers = users.filter((u) => u.plan === "free" || u.plan === "starter").length;
const planDistribution = users.reduce(
(acc, user) => {
acc[user.plan] = (acc[user.plan] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="flex items-center gap-3 p-4">
<div className="size-9 rounded-lg bg-muted" />
<div className="flex-1 space-y-1">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-5 w-10 rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary">
<Users className="size-4 text-foreground" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Total Users
</span>
<span className="text-lg font-semibold text-foreground">{total}</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[oklch(0.59_0.16_145/0.1)]">
<UserCheck className="size-4 text-[oklch(0.59_0.16_145)]" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Active This Month
</span>
<span className="text-lg font-semibold text-foreground">{activeUsers}</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[oklch(0.70_0.14_255/0.1)]">
<Crown className="size-4 text-[oklch(0.70_0.14_255)]" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Pro Users
</span>
<span className="text-lg font-semibold text-foreground">{proUsers}</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Zap className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Free Users
</span>
<span className="text-lg font-semibold text-foreground">{freeUsers}</span>
</div>
</CardContent>
</Card>
{Object.entries(planDistribution).length > 0 && (
<div className="col-span-2 flex flex-wrap items-center gap-2 md:col-span-4">
<span className="text-xs text-muted-foreground">Distribution:</span>
{Object.entries(planDistribution).map(([plan, count]) => (
<Badge key={plan} variant="outline" className="text-xs">
{PLAN_LABELS[plan as keyof typeof PLAN_LABELS] || plan}: {count}
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,383 @@
"use client";
import { useState, useMemo } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Search, KeyRound, Loader2, Filter } from "lucide-react";
import type { AdminUser, PlanType } from "./types";
import { PLAN_LABELS, PLAN_TIERS } from "./types";
interface UserTableProps {
users: AdminUser[];
isLoading: boolean;
onTierChange: (userId: string, plan: PlanType) => Promise<void>;
onRevokeKeys: (userId: string, keyIds: string[]) => Promise<void>;
isUpdating: boolean;
isRevoking: boolean;
}
type TierFilter = "all" | "free" | "pro";
const statusConfig: Record<string, { label: string; dotClass: string; textClass: string }> = {
active: {
label: "Actif",
dotClass: "bg-[oklch(0.59_0.16_145)]",
textClass: "text-[oklch(0.45_0.12_145)]",
},
suspended: {
label: "Suspendu",
dotClass: "bg-destructive",
textClass: "text-destructive",
},
pending: {
label: "En attente",
dotClass: "bg-[oklch(0.75_0.18_55)]",
textClass: "text-[oklch(0.55_0.16_55)]",
},
cancelled: {
label: "Annulé",
dotClass: "bg-muted-foreground",
textClass: "text-muted-foreground",
},
};
function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
} catch {
return dateString;
}
}
export function UserTable({
users,
isLoading,
onTierChange,
onRevokeKeys,
isUpdating,
isRevoking,
}: UserTableProps) {
const [searchQuery, setSearchQuery] = useState("");
const [tierFilter, setTierFilter] = useState<TierFilter>("all");
const [revokedUsers, setRevokedUsers] = useState<Set<string>>(new Set());
const [errorUserId, setErrorUserId] = useState<string | null>(null);
const filteredUsers = useMemo(() => {
let result = users;
if (tierFilter !== "all") {
result = result.filter((user) => PLAN_TIERS[user.plan] === tierFilter);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((user) => user.email.toLowerCase().includes(query));
}
return result;
}, [users, searchQuery, tierFilter]);
const handleTierChange = async (userId: string, plan: PlanType) => {
setErrorUserId(null);
try {
await onTierChange(userId, plan);
} catch {
setErrorUserId(userId);
}
};
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
setErrorUserId(null);
try {
await onRevokeKeys(userId, keyIds);
setRevokedUsers((prev) => {
const next = new Set(prev);
next.add(userId);
return next;
});
setTimeout(() => {
setRevokedUsers((prev) => {
const next = new Set(prev);
next.delete(userId);
return next;
});
}, 2000);
} catch {
setErrorUserId(userId);
}
};
const activeCount = users.filter((u) => u.subscription_status === "active").length;
const proCount = users.filter((u) => PLAN_TIERS[u.plan] === "pro").length;
const freeCount = users.filter((u) => PLAN_TIERS[u.plan] === "free").length;
if (isLoading) {
return (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Chargement des utilisateurs...</span>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-base">Gestion des Utilisateurs</CardTitle>
<CardDescription className="text-xs mt-1">
{users.length} total
<span className="mx-1.5 text-border">|</span>
{activeCount} actifs
<span className="mx-1.5 text-border">|</span>
{proCount} pro
</CardDescription>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex items-center gap-2">
<Filter className="size-3.5 text-muted-foreground" />
<Select value={tierFilter} onValueChange={(val: TierFilter) => setTierFilter(val)}>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue placeholder="Tier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">Tous</SelectItem>
<SelectItem value="free" className="text-xs">Free</SelectItem>
<SelectItem value="pro" className="text-xs">Pro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Rechercher par email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-0 pb-0">
<div className="border-t border-border overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Email
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Statut
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Plan
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Usage
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Clés
</TableHead>
<TableHead className="h-8 pr-6 text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => {
const sConfig = statusConfig[user.subscription_status] || statusConfig.pending;
const maxDocs = user.plan_limits?.docs_per_month || 100;
const usagePercent = Math.min((user.docs_translated_this_month / maxDocs) * 100, 100);
const isOverQuota = user.docs_translated_this_month > maxDocs;
const justRevoked = revokedUsers.has(user.id);
const hasError = errorUserId === user.id;
const apiKeyIds = user.api_key_ids || [];
return (
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}>
<TableCell className="pl-6 py-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
{user.email}
</span>
<span className="text-[10px] text-muted-foreground">
Créé le {formatDate(user.created_at)}
</span>
</div>
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${sConfig.dotClass}`} />
<span className={`text-xs font-medium ${sConfig.textClass}`}>
{sConfig.label}
</span>
</div>
</TableCell>
<TableCell className="py-2">
<Select
value={user.plan}
onValueChange={(val: PlanType) => handleTierChange(user.id, val)}
disabled={isUpdating}
>
<SelectTrigger
size="sm"
className={`h-7 w-[90px] text-xs font-semibold uppercase tracking-wider ${
PLAN_TIERS[user.plan] === "pro"
? "border-[oklch(0.59_0.16_145)/30] bg-[oklch(0.59_0.16_145)/10] text-[oklch(0.45_0.12_145)]"
: "border-border text-muted-foreground"
}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free" className="text-xs">Free</SelectItem>
<SelectItem value="starter" className="text-xs">Starter</SelectItem>
<SelectItem value="pro" className="text-xs">Pro</SelectItem>
<SelectItem value="business" className="text-xs">Business</SelectItem>
<SelectItem value="enterprise" className="text-xs">Enterprise</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="py-2">
<div className="flex w-28 flex-col gap-1">
<div className="flex items-center justify-between">
<span
className={`text-[10px] font-medium tabular-nums ${
isOverQuota ? "text-destructive" : "text-muted-foreground"
}`}
>
{user.docs_translated_this_month} / {maxDocs}
</span>
{isOverQuota && (
<Badge
variant="outline"
className="h-4 border-destructive/30 bg-destructive/5 px-1 text-[9px] text-destructive"
>
Dépassement
</Badge>
)}
</div>
<Progress
value={usagePercent}
className={`h-1 bg-muted ${
isOverQuota
? "[&>[data-slot=progress-indicator]]:bg-destructive"
: usagePercent > 80
? "[&>[data-slot=progress-indicator]]:bg-[oklch(0.75_0.18_55)]"
: "[&>[data-slot=progress-indicator]]:bg-[oklch(0.59_0.16_145)]"
}`}
/>
</div>
</TableCell>
<TableCell className="py-2">
<span className="text-xs tabular-nums text-muted-foreground">
{user.api_keys_count ?? 0}
</span>
</TableCell>
<TableCell className="pr-6 py-2 text-right">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className={`h-7 gap-1 px-2 text-[10px] ${
justRevoked
? "border-[oklch(0.59_0.16_145/0.3)] text-[oklch(0.45_0.12_145)]"
: hasError
? "border-destructive text-destructive"
: "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
}`}
onClick={() => handleRevokeKeys(user.id, apiKeyIds)}
disabled={apiKeyIds.length === 0 || isRevoking || justRevoked}
>
<KeyRound className="size-3" />
{justRevoked ? "Révoquées" : "Révoquer"}
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
{apiKeyIds.length === 0
? "Aucune clé active"
: `Révoquer ${apiKeyIds.length} clé${apiKeyIds.length > 1 ? "s" : ""} active${apiKeyIds.length > 1 ? "s" : ""}`}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
{filteredUsers.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="py-8 text-center text-xs text-muted-foreground"
>
{searchQuery || tierFilter !== "all"
? "Aucun utilisateur ne correspond à vos filtres."
: "Aucun utilisateur trouvé."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between border-t border-border px-6 py-2">
<span className="text-[10px] text-muted-foreground">
Affichage de {filteredUsers.length} sur {users.length} utilisateurs
</span>
{tierFilter !== "all" && (
<Badge variant="outline" className="text-[10px]">
Filtre: {tierFilter === "pro" ? "Pro" : "Free"} ({tierFilter === "pro" ? proCount : freeCount})
</Badge>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Users } from "lucide-react";
import { useAdminUsers } from "./useAdminUsers";
import { useUpdateUserTier } from "./useUpdateUserTier";
import { useRevokeApiKey } from "./useRevokeApiKey";
import { UserStats } from "./UserStats";
import { UserTable } from "./UserTable";
import { useToast } from "@/components/ui/toast";
import type { PlanType } from "./types";
export default function AdminUsersPage() {
const { users, total, isLoading, error, refetch } = useAdminUsers();
const { updateTier, isUpdating } = useUpdateUserTier();
const { revokeKey, isRevoking } = useRevokeApiKey();
const toast = useToast();
const handleTierChange = async (userId: string, plan: PlanType) => {
try {
await updateTier({ userId, plan });
toast.success({
title: "Plan mis à jour",
description: `Le plan a été changé vers "${plan}" avec succès.`,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
toast.error({
title: "Erreur",
description: `Impossible de mettre à jour le plan: ${message}`,
});
throw err;
}
};
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
if (!keyIds || keyIds.length === 0) {
toast.warning({
title: "Aucune clé",
description: "Cet utilisateur n'a pas de clés API actives.",
});
return;
}
try {
await Promise.all(
keyIds.map((keyId) =>
revokeKey({ keyId, reason: "Admin revocation from user management" })
)
);
toast.success({
title: "Clés révoquées",
description: `${keyIds.length} clé${keyIds.length > 1 ? "s" : ""} API ${keyIds.length > 1 ? "ont été révoquées" : "a été révoquée"} avec succès.`,
});
refetch();
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
toast.error({
title: "Erreur",
description: `Impossible de révoquer les clés: ${message}`,
});
throw err;
}
};
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
</div>
</div>
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
<p className="text-sm text-destructive">{error}</p>
<button
onClick={() => refetch()}
className="mt-2 text-xs text-destructive hover:underline"
>
Réessayer
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
</div>
</div>
<UserStats users={users} isLoading={isLoading} total={total} />
<UserTable
users={users}
isLoading={isLoading}
onTierChange={handleTierChange}
onRevokeKeys={handleRevokeKeys}
isUpdating={isUpdating}
isRevoking={isRevoking}
/>
</div>
);
}

View File

@@ -0,0 +1,68 @@
export interface PlanLimits {
docs_per_month: number;
max_pages_per_doc: number;
}
export interface AdminUser {
id: string;
email: string;
name: string;
plan: "free" | "starter" | "pro" | "business" | "enterprise";
subscription_status: "active" | "suspended" | "pending" | "cancelled";
docs_translated_this_month: number;
pages_translated_this_month: number;
extra_credits: number;
created_at: string;
plan_limits: PlanLimits;
api_keys_count?: number;
api_key_ids?: string[];
}
export interface AdminUsersResponse {
total: number;
users: AdminUser[];
}
export interface UpdateTierRequest {
plan: "free" | "starter" | "pro" | "business" | "enterprise";
}
export interface UpdateTierResponse {
data: {
id: string;
email: string;
name: string;
plan: string;
tier: "free" | "pro";
};
meta: Record<string, unknown>;
}
export interface RevokeApiKeyResponse {
data: {
id: string;
revoked: boolean;
revoked_at: string;
owner_user_id: string;
reason?: string;
};
meta: Record<string, unknown>;
}
export type PlanType = "free" | "starter" | "pro" | "business" | "enterprise";
export const PLAN_LABELS: Record<PlanType, string> = {
free: "Free",
starter: "Starter",
pro: "Pro",
business: "Business",
enterprise: "Enterprise",
};
export const PLAN_TIERS: Record<PlanType, "free" | "pro"> = {
free: "free",
starter: "free",
pro: "pro",
business: "pro",
enterprise: "pro",
};

View File

@@ -0,0 +1,92 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { AdminUsersResponse } from "./types";
export const ADMIN_TIMEOUT_MS = 15000;
export const QUERY_KEY = ["admin", "users"];
async function fetchUsers(adminToken: string | null | undefined): Promise<AdminUsersResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/users`, {
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useAdminUsers() {
const { settings } = useTranslationStore();
const { data, isLoading, error, refetch } = useQuery({
queryKey: QUERY_KEY,
queryFn: () => fetchUsers(settings.adminToken),
enabled: !!settings.adminToken,
staleTime: 30000,
retry: 1,
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour accéder aux utilisateurs",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_404: "Service indisponible. Veuillez réessayer plus tard.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
if (err.message.includes("fetch") || err.message.includes("network")) {
return "Impossible de se connecter au serveur. Vérifiez votre connexion.";
}
return "Une erreur inattendue s'est produite. Veuillez réessayer.";
};
const errorMessage = error ? getErrorMessage(error as Error) : null;
return {
data: data ?? null,
users: data?.users ?? [],
total: data?.total ?? 0,
isLoading,
error: errorMessage,
refetch,
queryKey: QUERY_KEY,
};
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { RevokeApiKeyResponse } from "./types";
import { QUERY_KEY, ADMIN_TIMEOUT_MS } from "./useAdminUsers";
async function revokeApiKey(
keyId: string,
reason: string | undefined,
adminToken: string | null | undefined
): Promise<RevokeApiKeyResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/api-keys/${keyId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(reason ? { reason } : {}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (response.status === 404) {
throw new Error("API_KEY_NOT_FOUND");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useRevokeApiKey() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({
keyId,
reason,
}: {
keyId: string;
reason?: string;
}) => revokeApiKey(keyId, reason, settings.adminToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
API_KEY_NOT_FOUND: "Clé API non trouvée ou déjà révoquée.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
return "Erreur lors de la révocation. Veuillez réessayer.";
};
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
return {
isRevoking: mutation.isPending,
result: mutation.data ?? null,
error: errorMessage,
revokeKey: mutation.mutateAsync,
reset: mutation.reset,
};
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { UpdateTierRequest, UpdateTierResponse, PlanType } from "./types";
import { QUERY_KEY, ADMIN_TIMEOUT_MS } from "./useAdminUsers";
async function updateUserTier(
userId: string,
plan: PlanType,
adminToken: string | null | undefined
): Promise<UpdateTierResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/users/${userId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ plan } as UpdateTierRequest),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (response.status === 404) {
throw new Error("USER_NOT_FOUND");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useUpdateUserTier() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ userId, plan }: { userId: string; plan: PlanType }) =>
updateUserTier(userId, plan, settings.adminToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
USER_NOT_FOUND: "Utilisateur non trouvé.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_400: "Plan invalide. Veuillez réessayer.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
return "Erreur lors de la mise à jour. Veuillez réessayer.";
};
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
return {
isUpdating: mutation.isPending,
result: mutation.data ?? null,
error: errorMessage,
updateTier: mutation.mutateAsync,
reset: mutation.reset,
};
}