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:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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 {

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