feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,13 @@ import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function AdminHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,9 +40,9 @@ export function AdminHeader() {
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<h1 className="text-xs font-semibold text-foreground">System Administration</h1>
|
||||
<h1 className="text-xs font-semibold text-foreground">{t('admin.dashboard.title')}</h1>
|
||||
<Separator orientation="vertical" className="h-3" />
|
||||
<span className="text-xs text-muted-foreground">Monitor infrastructure and manage users</span>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.dashboard.subtitle')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -48,7 +50,7 @@ export function AdminHeader() {
|
||||
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" />
|
||||
<Shield className="me-1 size-2.5" />
|
||||
Superadmin
|
||||
</Badge>
|
||||
<Avatar className="size-6">
|
||||
@@ -66,7 +68,7 @@ export function AdminHeader() {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
@@ -77,7 +79,7 @@ export function AdminHeader() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
|
||||
@@ -38,7 +40,7 @@ export function AdminSidebar() {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.href}
|
||||
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",
|
||||
@@ -48,7 +50,7 @@ export function AdminSidebar() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useI18n } from "@/lib/i18n";
|
||||
import type { AdminDashboardData } from "./types";
|
||||
|
||||
interface SystemHealthCardsProps {
|
||||
@@ -21,6 +22,7 @@ export function SystemHealthCards({
|
||||
onPurge,
|
||||
purgeResult,
|
||||
}: SystemHealthCardsProps) {
|
||||
const { t } = useI18n();
|
||||
const diskUsed = data?.system?.disk?.used_percent ?? 0;
|
||||
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
|
||||
const systemStatus = data?.status ?? "unhealthy";
|
||||
@@ -82,14 +84,14 @@ export function SystemHealthCards({
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{systemStatus === "healthy"
|
||||
? "All Systems Operational"
|
||||
: "System Issues Detected"}
|
||||
? t('admin.system.allOperational')
|
||||
: t('admin.system.issuesDetected')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{data?.timestamp
|
||||
{data?.timestamp
|
||||
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
|
||||
: "Waiting for data..."}
|
||||
: t('admin.system.waitingData')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -151,10 +153,10 @@ export function SystemHealthCards({
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
{isPurging
|
||||
? "Purging..."
|
||||
? t('admin.system.purging')
|
||||
: trackedFilesCount === 0
|
||||
? "Clean"
|
||||
: "Purge"}
|
||||
? t('admin.system.clean')
|
||||
: t('admin.system.purge')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Rang</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="text-right">Traductions</TableHead>
|
||||
<TableHead className="text-end">Traductions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -120,7 +120,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell>{getRankBadge(index + 1)}</TableCell>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-end">
|
||||
<Badge variant="secondary">{user.translation_count}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Pricing & Stripe', href: '/admin/pricing', icon: CreditCard },
|
||||
{ label: 'Providers', href: '/admin/settings', icon: Settings },
|
||||
{ label: 'System', href: '/admin/system', icon: Settings },
|
||||
{ label: 'Logs', href: '/admin/logs', icon: FileText },
|
||||
{ labelKey: 'admin.nav.dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ labelKey: 'admin.nav.users', href: '/admin/users', icon: Users },
|
||||
{ labelKey: 'admin.nav.pricing', href: '/admin/pricing', icon: CreditCard },
|
||||
{ labelKey: 'admin.nav.providers', href: '/admin/settings', icon: Settings },
|
||||
{ labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings },
|
||||
{ labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import { AdminHeader } from "./AdminHeader";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -15,9 +16,9 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { settings, setAdminToken } = useTranslationStore();
|
||||
const { t } = useI18n();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
/** Sans ça, au premier rendu après un chargement complet le token n’est pas encore lu depuis localStorage → fausse absence de token → redirection /admin/login (bug sur F5 ou URL directe). */
|
||||
const [persistHydrated, setPersistHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,7 +76,7 @@ export default function AdminLayout({
|
||||
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'authentification...</div>
|
||||
<div className="text-muted-foreground text-sm">{t('admin.layout.checking')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAdminLogin } from "./useAdminLogin";
|
||||
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
function AdminLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, isLoading, error } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -25,8 +27,8 @@ function AdminLoginContent() {
|
||||
<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" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||
<h1 className="text-2xl font-bold text-white">{t('admin.login.title')}</h1>
|
||||
<p className="text-gray-400 mt-2">{t('admin.login.required')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||
@@ -39,7 +41,7 @@ function AdminLoginContent() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Mot de passe administrateur</label>
|
||||
<label className="block text-gray-400 text-sm mb-2">{t('admin.login.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
@@ -47,7 +49,7 @@ function AdminLoginContent() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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"
|
||||
className="w-full ps-12 pe-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}
|
||||
/>
|
||||
@@ -69,12 +71,12 @@ function AdminLoginContent() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion...
|
||||
{t('admin.login.connecting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Accéder au panneau admin
|
||||
{t('admin.login.access')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -82,7 +84,7 @@ function AdminLoginContent() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Accès réservé aux administrateurs
|
||||
{t('admin.login.restricted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,10 +92,11 @@ function AdminLoginContent() {
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { t } = useI18n();
|
||||
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>
|
||||
<div className="text-white text-xl">{t('common.loading')}</div>
|
||||
</div>
|
||||
}>
|
||||
<AdminLoginContent />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SystemHealthCards } from "./SystemHealthCards";
|
||||
import { ProviderStatus } from "./ProviderStatus";
|
||||
import { useAdminDashboard } from "./useAdminDashboard";
|
||||
import { useCleanup } from "./useCleanup";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
export default function AdminPage() {
|
||||
const { data, isLoading, error, refetch } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handlePurge = async () => {
|
||||
await triggerCleanup();
|
||||
@@ -32,10 +34,10 @@ export default function AdminPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Dashboard Admin
|
||||
{t('admin.dashboard.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Panneau de contrôle administrateur
|
||||
{t('admin.dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,11 +56,11 @@ export default function AdminPage() {
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
{t('admin.dashboard.refresh')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Refresh dashboard data</p>
|
||||
<p>{t('admin.dashboard.refreshTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -83,23 +85,23 @@ export default function AdminPage() {
|
||||
{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
|
||||
{t('admin.dashboard.config')}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Max file size:{" "}
|
||||
{t('admin.dashboard.maxFileSize')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.max_file_size_mb}MB
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Translation service:{" "}
|
||||
{t('admin.dashboard.translationService')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.translation_service}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Formats:{" "}
|
||||
{t('admin.dashboard.formats')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.supported_extensions.join(", ")}
|
||||
</strong>
|
||||
|
||||
@@ -455,7 +455,7 @@ export default function AdminSettingsPage() {
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1">Récupérer les modèles</span>
|
||||
<span className="ms-1">Récupérer les modèles</span>
|
||||
</Button>
|
||||
</div>
|
||||
{ollamaModels.length > 0 ? (
|
||||
@@ -471,7 +471,7 @@ export default function AdminSettingsPage() {
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
{model.size > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
({(model.size / 1e9).toFixed(1)} GB)
|
||||
</span>
|
||||
)}
|
||||
@@ -660,13 +660,13 @@ export default function AdminSettingsPage() {
|
||||
className="h-8"
|
||||
>
|
||||
{testResults.smtp === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
||||
) : testResults.smtp === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
||||
) : testResults.smtp === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
<><FlaskConical className="size-3 me-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
|
||||
@@ -752,13 +752,13 @@ export default function AdminSettingsPage() {
|
||||
className="h-8"
|
||||
>
|
||||
{isSendingTestEmail ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Envoi...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Envoi...</>
|
||||
) : testEmailResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />Envoyé</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</>
|
||||
) : testEmailResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Échec</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Échec</>
|
||||
) : (
|
||||
<><Mail className="size-3 mr-1" />Envoyer un email de test</>
|
||||
<><Mail className="size-3 me-1" />Envoyer un email de test</>
|
||||
)}
|
||||
</Button>
|
||||
{testEmailMessage && (
|
||||
@@ -775,12 +775,12 @@ export default function AdminSettingsPage() {
|
||||
<Button onClick={saveConfig} disabled={isSaving} size="lg">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<Loader2 className="me-2 size-4 animate-spin" />
|
||||
Sauvegarde...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 size-4" />
|
||||
<Save className="me-2 size-4" />
|
||||
Sauvegarder la configuration
|
||||
</>
|
||||
)}
|
||||
@@ -838,13 +838,13 @@ function ProviderCard({
|
||||
className="h-8"
|
||||
>
|
||||
{testResult === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
||||
) : testResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
||||
) : testResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
<><FlaskConical className="size-3 me-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} />
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Settings, AlertCircle, Loader2, RotateCcw, CheckCircle2 } from "lucide-react";
|
||||
import { useSystemPage } from "./useSystemPage";
|
||||
import { CleanupSection } from "./CleanupSection";
|
||||
import { DiskSpaceCard } from "./DiskSpaceCard";
|
||||
import { ProviderStatus } from "../ProviderStatus";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AdminSystemPage() {
|
||||
const { data, isLoading, error, isPurging, purgeResult, handleCleanup } = useSystemPage();
|
||||
const {
|
||||
data, isLoading, error,
|
||||
isPurging, purgeResult, handleCleanup,
|
||||
isResetting, resetResult, handleResetQuotas,
|
||||
} = useSystemPage();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -16,9 +24,9 @@ export default function AdminSystemPage() {
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Système</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.system.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Surveiller l'état du système et gérer les ressources
|
||||
{t('admin.system.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +40,7 @@ export default function AdminSystemPage() {
|
||||
|
||||
{isLoading && !data ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
|
||||
@@ -52,6 +60,43 @@ export default function AdminSystemPage() {
|
||||
purgeResult={purgeResult}
|
||||
onCleanup={handleCleanup}
|
||||
/>
|
||||
|
||||
{/* Reset Translation Quotas */}
|
||||
<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-amber-500/10">
|
||||
<RotateCcw className="size-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('admin.system.quotas')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t('admin.system.resetQuotas')}
|
||||
</span>
|
||||
{resetResult && (
|
||||
<span className="text-[10px] text-green-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
{resetResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 border-amber-200/30 text-amber-600 hover:bg-amber-500/10 hover:text-amber-600 text-xs"
|
||||
onClick={handleResetQuotas}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{isResetting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3" />
|
||||
)}
|
||||
{isResetting ? t('admin.system.resetting') : t('admin.system.reset')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAdminDashboard } from "../useAdminDashboard";
|
||||
import { useCleanup } from "../useCleanup";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
|
||||
export function useSystemPage() {
|
||||
const { data, isLoading, error } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup();
|
||||
const { settings } = useTranslationStore();
|
||||
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetResult, setResetResult] = useState<{ keys_deleted: number; message: string } | null>(null);
|
||||
|
||||
const handleCleanup = () => triggerCleanup();
|
||||
|
||||
const handleResetQuotas = async () => {
|
||||
if (!settings.adminToken) return;
|
||||
setIsResetting(true);
|
||||
setResetResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/quota/reset`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${settings.adminToken}` },
|
||||
});
|
||||
const json = await res.json();
|
||||
setResetResult(json);
|
||||
} catch {
|
||||
setResetResult({ keys_deleted: 0, message: "Erreur réseau" });
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -16,5 +41,8 @@ export function useSystemPage() {
|
||||
isPurging,
|
||||
purgeResult,
|
||||
handleCleanup,
|
||||
isResetting,
|
||||
resetResult,
|
||||
handleResetQuotas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const statusConfig: Record<string, { label: string; dotClass: string; textClass:
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
@@ -208,7 +208,7 @@ export function UserTable({
|
||||
placeholder="Rechercher par email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
className="h-8 ps-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export function UserTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<TableHead className="h-8 ps-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">
|
||||
@@ -238,7 +238,7 @@ export function UserTable({
|
||||
<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">
|
||||
<TableHead className="h-8 pe-6 text-end text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -255,7 +255,7 @@ export function UserTable({
|
||||
|
||||
return (
|
||||
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}>
|
||||
<TableCell className="pl-6 py-2">
|
||||
<TableCell className="ps-6 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{user.email}
|
||||
@@ -357,7 +357,7 @@ export function UserTable({
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="pr-6 py-2 text-right">
|
||||
<TableCell className="pe-6 py-2 text-end">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRevokeApiKey } from "./useRevokeApiKey";
|
||||
import { UserStats } from "./UserStats";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { PlanType } from "./types";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
@@ -16,19 +17,20 @@ export default function AdminUsersPage() {
|
||||
const { revokeKey, isRevoking } = useRevokeApiKey();
|
||||
const { resetPassword, isResetting } = useAdminResetPassword();
|
||||
const toast = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
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.`,
|
||||
title: t('admin.users.planUpdated'),
|
||||
description: t('admin.users.planChanged', { plan }),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
const message = err instanceof Error ? err.message : t('admin.users.unknownError');
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de mettre à jour le plan: ${message}`,
|
||||
title: t('admin.users.error'),
|
||||
description: t('admin.users.planUpdateError', { message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -37,8 +39,8 @@ export default function AdminUsersPage() {
|
||||
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.",
|
||||
title: t('admin.users.noKeys'),
|
||||
description: t('admin.users.noKeysDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -50,15 +52,15 @@ export default function AdminUsersPage() {
|
||||
)
|
||||
);
|
||||
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.`,
|
||||
title: t('admin.users.keysRevoked'),
|
||||
description: t('admin.users.keysRevokedDesc', { count: keyIds.length }),
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
const message = err instanceof Error ? err.message : t('admin.users.unknownError');
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de révoquer les clés: ${message}`,
|
||||
title: t('admin.users.error'),
|
||||
description: t('admin.users.revokeError', { message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -72,8 +74,8 @@ export default function AdminUsersPage() {
|
||||
<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>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
|
||||
@@ -82,7 +84,7 @@ export default function AdminUsersPage() {
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-xs text-destructive hover:underline"
|
||||
>
|
||||
Réessayer
|
||||
{t('admin.users.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,8 +98,8 @@ export default function AdminUsersPage() {
|
||||
<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>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user