feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,15 @@ import {
|
||||
} 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 { Search, KeyRound, Loader2, Filter, Lock } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import type { AdminUser, PlanType } from "./types";
|
||||
import { PLAN_LABELS, PLAN_TIERS } from "./types";
|
||||
|
||||
@@ -41,8 +49,10 @@ interface UserTableProps {
|
||||
isLoading: boolean;
|
||||
onTierChange: (userId: string, plan: PlanType) => Promise<void>;
|
||||
onRevokeKeys: (userId: string, keyIds: string[]) => Promise<void>;
|
||||
onResetPassword: (userId: string, newPassword: string) => Promise<void>;
|
||||
isUpdating: boolean;
|
||||
isRevoking: boolean;
|
||||
isResettingPassword: boolean;
|
||||
}
|
||||
|
||||
type TierFilter = "all" | "free" | "pro";
|
||||
@@ -88,13 +98,19 @@ export function UserTable({
|
||||
isLoading,
|
||||
onTierChange,
|
||||
onRevokeKeys,
|
||||
onResetPassword,
|
||||
isUpdating,
|
||||
isRevoking,
|
||||
isResettingPassword,
|
||||
}: UserTableProps) {
|
||||
const toast = useToast();
|
||||
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 [pwdDialogUser, setPwdDialogUser] = useState<AdminUser | null>(null);
|
||||
const [newPwd, setNewPwd] = useState("");
|
||||
const [confirmPwd, setConfirmPwd] = useState("");
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = users;
|
||||
@@ -210,6 +226,9 @@ export function UserTable({
|
||||
<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">
|
||||
Stockage
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Plan
|
||||
</TableHead>
|
||||
@@ -256,6 +275,24 @@ export function UserTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] font-normal ${
|
||||
user.storage === "json_file"
|
||||
? "border-amber-500/40 text-amber-600"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
title={
|
||||
user.storage === "json_file"
|
||||
? "Compte uniquement dans data/users.json (legacy)"
|
||||
: "Compte en base SQLite / PostgreSQL"
|
||||
}
|
||||
>
|
||||
{user.storage === "json_file" ? "JSON" : "Base"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<Select
|
||||
value={user.plan}
|
||||
@@ -321,31 +358,54 @@ export function UserTable({
|
||||
</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>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-[10px] border-primary/30 text-primary hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
setPwdDialogUser(user);
|
||||
setNewPwd("");
|
||||
setConfirmPwd("");
|
||||
}}
|
||||
disabled={isResettingPassword}
|
||||
>
|
||||
<Lock className="size-3" />
|
||||
MDP
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
Définir un nouveau mot de passe (sans e-mail)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -354,7 +414,7 @@ export function UserTable({
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{searchQuery || tierFilter !== "all"
|
||||
@@ -378,6 +438,69 @@ export function UserTable({
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={!!pwdDialogUser} onOpenChange={(o) => !o && setPwdDialogUser(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouveau mot de passe</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pwdDialogUser?.email} — minimum 8 caractères.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Nouveau mot de passe</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPwd}
|
||||
onChange={(e) => setNewPwd(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Confirmation</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPwd}
|
||||
onChange={(e) => setConfirmPwd(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setPwdDialogUser(null)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isResettingPassword || newPwd.length < 8}
|
||||
onClick={async () => {
|
||||
if (!pwdDialogUser) return;
|
||||
if (newPwd !== confirmPwd) {
|
||||
toast.error({ title: "Les mots de passe ne correspondent pas" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onResetPassword(pwdDialogUser.id, newPwd);
|
||||
toast.success({ title: "Mot de passe mis à jour" });
|
||||
setPwdDialogUser(null);
|
||||
setNewPwd("");
|
||||
setConfirmPwd("");
|
||||
} catch (e) {
|
||||
toast.error({
|
||||
title: "Échec",
|
||||
description: e instanceof Error ? e.message : "Erreur",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isResettingPassword ? <Loader2 className="size-4 animate-spin" /> : "Enregistrer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Users } from "lucide-react";
|
||||
import { useAdminUsers } from "./useAdminUsers";
|
||||
import { useAdminResetPassword } from "./useAdminResetPassword";
|
||||
import { useUpdateUserTier } from "./useUpdateUserTier";
|
||||
import { useRevokeApiKey } from "./useRevokeApiKey";
|
||||
import { UserStats } from "./UserStats";
|
||||
@@ -13,6 +14,7 @@ export default function AdminUsersPage() {
|
||||
const { users, total, isLoading, error, refetch } = useAdminUsers();
|
||||
const { updateTier, isUpdating } = useUpdateUserTier();
|
||||
const { revokeKey, isRevoking } = useRevokeApiKey();
|
||||
const { resetPassword, isResetting } = useAdminResetPassword();
|
||||
const toast = useToast();
|
||||
|
||||
const handleTierChange = async (userId: string, plan: PlanType) => {
|
||||
@@ -106,8 +108,13 @@ export default function AdminUsersPage() {
|
||||
isLoading={isLoading}
|
||||
onTierChange={handleTierChange}
|
||||
onRevokeKeys={handleRevokeKeys}
|
||||
onResetPassword={async (userId, pwd) => {
|
||||
await resetPassword(userId, pwd);
|
||||
await refetch();
|
||||
}}
|
||||
isUpdating={isUpdating}
|
||||
isRevoking={isRevoking}
|
||||
isResettingPassword={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface AdminUser {
|
||||
plan_limits: PlanLimits;
|
||||
api_keys_count?: number;
|
||||
api_key_ids?: string[];
|
||||
/** Présent si le backend expose la source du compte */
|
||||
storage?: "database" | "json_file";
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
|
||||
47
frontend/src/app/admin/users/useAdminResetPassword.ts
Normal file
47
frontend/src/app/admin/users/useAdminResetPassword.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { ADMIN_TIMEOUT_MS } from "./useAdminUsers";
|
||||
|
||||
export function useAdminResetPassword() {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const resetPassword = useCallback(async (userId: string, newPassword: string) => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
throw new Error("Session admin requise");
|
||||
}
|
||||
setIsResetting(true);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/users/${encodeURIComponent(userId)}/password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
const detail = j.detail;
|
||||
const msg =
|
||||
typeof detail === "string"
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail[0]?.msg
|
||||
: j.message;
|
||||
throw new Error(msg || `Erreur HTTP ${res.status}`);
|
||||
}
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { resetPassword, isResetting };
|
||||
}
|
||||
Reference in New Issue
Block a user