feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
116
frontend/src/app/admin/users/UserStats.tsx
Normal file
116
frontend/src/app/admin/users/UserStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
383
frontend/src/app/admin/users/UserTable.tsx
Normal file
383
frontend/src/app/admin/users/UserTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/app/admin/users/page.tsx
Normal file
114
frontend/src/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/app/admin/users/types.ts
Normal file
68
frontend/src/app/admin/users/types.ts
Normal 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",
|
||||
};
|
||||
92
frontend/src/app/admin/users/useAdminUsers.ts
Normal file
92
frontend/src/app/admin/users/useAdminUsers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
100
frontend/src/app/admin/users/useRevokeApiKey.ts
Normal file
100
frontend/src/app/admin/users/useRevokeApiKey.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
96
frontend/src/app/admin/users/useUpdateUserTier.ts
Normal file
96
frontend/src/app/admin/users/useUpdateUserTier.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user