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,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,
};
}