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:
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, Users, Settings, FileText, Key, type LucideIcon } from 'lucide-react';
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
label: string;
|
||||
@@ -9,7 +9,8 @@ export interface AdminNavItem {
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Providers', href: '/admin/settings', icon: Key },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -17,6 +17,18 @@ export default function AdminLayout({
|
||||
const { settings, setAdminToken } = useTranslationStore();
|
||||
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(() => {
|
||||
const unsub = useTranslationStore.persist.onFinishHydration(() => {
|
||||
setPersistHydrated(true);
|
||||
});
|
||||
if (useTranslationStore.persist.hasHydrated()) {
|
||||
setPersistHydrated(true);
|
||||
}
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const verifyToken = useCallback(async (token: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -39,6 +51,10 @@ export default function AdminLayout({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!persistHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminToken = settings.adminToken;
|
||||
if (!adminToken) {
|
||||
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
@@ -54,7 +70,7 @@ export default function AdminLayout({
|
||||
setIsValid(true);
|
||||
setIsChecking(false);
|
||||
});
|
||||
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken]);
|
||||
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken, persistHydrated]);
|
||||
|
||||
if (isChecking && pathname !== "/admin/login") {
|
||||
return (
|
||||
|
||||
552
frontend/src/app/admin/pricing/page.tsx
Normal file
552
frontend/src/app/admin/pricing/page.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
CreditCard, Save, Loader2, CheckCircle, XCircle,
|
||||
Zap, RefreshCw, ExternalLink, AlertTriangle, KeyRound,
|
||||
Crown, Building2, Sparkles
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import {
|
||||
ANNUAL_DISCOUNT_PERCENT,
|
||||
YEARLY_DISCOUNT_FACTOR,
|
||||
computeYearlyFromMonthly,
|
||||
} from "@/lib/pricing";
|
||||
|
||||
/* ─── Types ─── */
|
||||
interface PlanConfig {
|
||||
name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
stripe_price_id_monthly: string;
|
||||
stripe_price_id_yearly: string;
|
||||
}
|
||||
interface PricingData {
|
||||
starter: PlanConfig;
|
||||
pro: PlanConfig;
|
||||
business: PlanConfig;
|
||||
}
|
||||
interface StripeStatus {
|
||||
configured: boolean;
|
||||
has_secret_key: boolean;
|
||||
has_publishable_key: boolean;
|
||||
has_webhook_secret: boolean;
|
||||
publishable_key: string;
|
||||
}
|
||||
|
||||
const PLAN_ICONS: Record<string, React.ElementType> = {
|
||||
starter: Zap,
|
||||
pro: Crown,
|
||||
business: Building2,
|
||||
};
|
||||
|
||||
/** Le backend refuse les placeholders (xxx) — envoyer une chaîne vide pour ne pas bloquer la sauvegarde des prix. */
|
||||
function sanitizeStripePriceIdForSave(raw: string): string {
|
||||
const s = raw.trim();
|
||||
if (!s) return "";
|
||||
const lower = s.toLowerCase();
|
||||
if (lower.includes("xxx") || lower.includes("placeholder")) return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: PlanConfig = {
|
||||
name: "",
|
||||
price_monthly: 0,
|
||||
price_yearly: 0,
|
||||
stripe_price_id_monthly: "",
|
||||
stripe_price_id_yearly: "",
|
||||
};
|
||||
|
||||
/** Aligné sur models/subscription.py — éviter 0 € initial : le backend refuse < 0,01 € et la sauvegarde échouait pour Pro/Business. */
|
||||
function defaultPricingData(): PricingData {
|
||||
const s = 9;
|
||||
const p = 19;
|
||||
const b = 49;
|
||||
return {
|
||||
starter: {
|
||||
...EMPTY_PLAN,
|
||||
name: "Starter",
|
||||
price_monthly: s,
|
||||
price_yearly: computeYearlyFromMonthly(s),
|
||||
},
|
||||
pro: {
|
||||
...EMPTY_PLAN,
|
||||
name: "Pro",
|
||||
price_monthly: p,
|
||||
price_yearly: computeYearlyFromMonthly(p),
|
||||
},
|
||||
business: {
|
||||
...EMPTY_PLAN,
|
||||
name: "Business",
|
||||
price_monthly: b,
|
||||
price_yearly: computeYearlyFromMonthly(b),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPricingPage() {
|
||||
const [pricing, setPricing] = useState<PricingData>(defaultPricingData);
|
||||
const [stripeStatus, setStripeStatus] = useState<StripeStatus | null>(null);
|
||||
const [stripeKeys, setStripeKeys] = useState({
|
||||
secret: "",
|
||||
publishable: "",
|
||||
webhook: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<any>(null);
|
||||
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
|
||||
const showToast = (type: "ok" | "err", text: string) => {
|
||||
setToast({ type, text });
|
||||
setTimeout(() => setToast(null), 5000);
|
||||
};
|
||||
|
||||
const loadPricing = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
if (j.data) {
|
||||
const d = j.data as PricingData;
|
||||
(["starter", "pro", "business"] as const).forEach((pid) => {
|
||||
const m = d[pid].price_monthly;
|
||||
d[pid].price_yearly = computeYearlyFromMonthly(m);
|
||||
});
|
||||
setPricing(d);
|
||||
}
|
||||
if (j.stripe) setStripeStatus(j.stripe);
|
||||
} else if (res.status === 401) {
|
||||
showToast("err", "Session admin expirée. Reconnectez-vous.");
|
||||
} else {
|
||||
showToast("err", `Impossible de charger les tarifs (HTTP ${res.status}).`);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau lors du chargement.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadPricing(); }, [loadPricing]);
|
||||
|
||||
const parseApiError = async (res: Response): Promise<string> => {
|
||||
const err: {
|
||||
detail?: string | Array<{ msg?: string; type?: string }>;
|
||||
message?: string;
|
||||
} = await res.json().catch(() => ({}));
|
||||
if (Array.isArray(err.detail)) {
|
||||
return err.detail.map((e) => e.msg || JSON.stringify(e)).join(" — ") || `Erreur HTTP ${res.status}`;
|
||||
}
|
||||
if (typeof err.detail === "string") return err.detail;
|
||||
if (err.message) return err.message;
|
||||
return `Erreur HTTP ${res.status}`;
|
||||
};
|
||||
|
||||
const savePricing = async () => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
showToast("err", "Session admin absente. Reconnectez-vous sur /admin/login.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const pid of ["starter", "pro", "business"] as const) {
|
||||
const m = pricing[pid].price_monthly;
|
||||
if (!Number.isFinite(m) || m < 0.01) {
|
||||
showToast(
|
||||
"err",
|
||||
`Prix mensuel invalide pour ${pricing[pid].name} : minimum 0,01 € (valeur actuelle : ${m}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
starter: {
|
||||
price_monthly: pricing.starter.price_monthly,
|
||||
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
|
||||
pricing.starter.stripe_price_id_monthly,
|
||||
),
|
||||
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
|
||||
pricing.starter.stripe_price_id_yearly,
|
||||
),
|
||||
},
|
||||
pro: {
|
||||
price_monthly: pricing.pro.price_monthly,
|
||||
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
|
||||
pricing.pro.stripe_price_id_monthly,
|
||||
),
|
||||
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
|
||||
pricing.pro.stripe_price_id_yearly,
|
||||
),
|
||||
},
|
||||
business: {
|
||||
price_monthly: pricing.business.price_monthly,
|
||||
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
|
||||
pricing.business.stripe_price_id_monthly,
|
||||
),
|
||||
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
|
||||
pricing.business.stripe_price_id_yearly,
|
||||
),
|
||||
},
|
||||
};
|
||||
// Include Stripe keys only if filled in
|
||||
if (stripeKeys.secret) body.stripe_secret_key = stripeKeys.secret;
|
||||
if (stripeKeys.publishable) body.stripe_publishable_key = stripeKeys.publishable;
|
||||
if (stripeKeys.webhook) body.stripe_webhook_secret = stripeKeys.webhook;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, {
|
||||
method: "PUT",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("ok", "✅ Tarifs enregistrés (fichier data/pricing_overrides.json). Les clés Stripe vont dans .env si renseignées.");
|
||||
setStripeKeys({ secret: "", publishable: "", webhook: "" });
|
||||
loadPricing();
|
||||
} else {
|
||||
const msg = await parseApiError(res);
|
||||
showToast("err", msg);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau (vérifiez que le backend tourne et que l’URL API est correcte).");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const autoSetupStripe = async () => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
showToast("err", "Session admin absente. Reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
setSetupLoading(true);
|
||||
setSetupResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const j = await res.json();
|
||||
if (res.ok) {
|
||||
setSetupResult(j);
|
||||
if (j.errors?.length === 0) {
|
||||
showToast("ok", "✅ Produits Stripe créés et Price IDs enregistrés dans .env !");
|
||||
} else if (j.data && Object.keys(j.data).length > 0) {
|
||||
showToast("ok", `⚠️ Partiellement réussi. ${j.errors?.length} erreur(s).`);
|
||||
} else {
|
||||
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
|
||||
}
|
||||
loadPricing();
|
||||
} else {
|
||||
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
|
||||
setSetupResult(j);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau.");
|
||||
} finally {
|
||||
setSetupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
|
||||
setPricing((prev) => {
|
||||
const next = { ...prev[planId], [field]: value } as PlanConfig;
|
||||
if (field === "price_monthly") {
|
||||
const m = typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
next.price_yearly = computeYearlyFromMonthly(m);
|
||||
}
|
||||
return { ...prev, [planId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center">
|
||||
<CreditCard className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Pricing & Stripe</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gérez les prix des forfaits et la configuration Stripe. Enregistrer ici applique les tarifs tout de suite (API publique, checkout) —{" "}
|
||||
<strong className="text-foreground/90">aucun redémarrage du serveur requis</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`flex items-start gap-3 p-4 rounded-xl border text-sm ${
|
||||
toast.type === "ok"
|
||||
? "bg-emerald-900/20 border-emerald-600/30 text-emerald-300"
|
||||
: "bg-red-900/20 border-red-600/30 text-red-300"
|
||||
}`}>
|
||||
{toast.type === "ok" ? <CheckCircle className="w-4 h-4 flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
|
||||
<span className="flex-1">{toast.text}</span>
|
||||
<button onClick={() => setToast(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stripe Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-violet-400" />
|
||||
Statut Stripe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
|
||||
{ label: "Clé publique", ok: stripeStatus?.has_publishable_key },
|
||||
{ label: "Webhook secret", ok: stripeStatus?.has_webhook_secret },
|
||||
].map(({ label, ok }) => (
|
||||
<div key={label} className={`rounded-lg border p-3 flex items-center gap-2 ${
|
||||
ok ? "border-emerald-600/30 bg-emerald-900/10" : "border-red-600/30 bg-red-900/10"
|
||||
}`}>
|
||||
{ok
|
||||
? <CheckCircle className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
||||
: <XCircle className="w-4 h-4 text-red-400 flex-shrink-0" />}
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Auto-setup button */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Configuration automatique Stripe</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
|
||||
</p>
|
||||
{setupResult && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
|
||||
{Object.entries(setupResult.data || {}).map(([plan, info]: any) => (
|
||||
<div key={plan} className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||
<span className="capitalize font-medium">{plan}</span>
|
||||
<span className="text-muted-foreground">— mensuel: <code>{info.monthly_price_id}</code></span>
|
||||
</div>
|
||||
))}
|
||||
{setupResult.errors?.map((e: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-red-400">
|
||||
<XCircle className="w-3 h-3" />
|
||||
<span>{e.plan}: {e.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={autoSetupStripe}
|
||||
disabled={setupLoading || !stripeStatus?.has_secret_key}
|
||||
className="bg-violet-600 hover:bg-violet-500 gap-2 flex-shrink-0"
|
||||
>
|
||||
{setupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{setupLoading ? "Configuration…" : "Créer automatiquement"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Manual Stripe keys */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="stripe-secret" className="text-xs text-muted-foreground">Clé secrète (sk_test_... ou sk_live_...)</Label>
|
||||
<Input
|
||||
id="stripe-secret"
|
||||
type="password"
|
||||
placeholder={stripeStatus?.has_secret_key ? "Clé déjà configurée — laisser vide pour garder" : "sk_test_..."}
|
||||
value={stripeKeys.secret}
|
||||
onChange={e => setStripeKeys(prev => ({ ...prev, secret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="stripe-pub" className="text-xs text-muted-foreground">Clé publique (pk_test_... ou pk_live_...)</Label>
|
||||
<Input
|
||||
id="stripe-pub"
|
||||
placeholder={stripeStatus?.has_publishable_key ? "Clé déjà configurée" : "pk_test_..."}
|
||||
value={stripeKeys.publishable}
|
||||
onChange={e => setStripeKeys(prev => ({ ...prev, publishable: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="stripe-webhook" className="text-xs text-muted-foreground">Webhook secret (whsec_...)</Label>
|
||||
<Input
|
||||
id="stripe-webhook"
|
||||
type="password"
|
||||
placeholder={stripeStatus?.has_webhook_secret ? "Secret déjà configuré" : "whsec_..."}
|
||||
value={stripeKeys.webhook}
|
||||
onChange={e => setStripeKeys(prev => ({ ...prev, webhook: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 flex items-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer" className="underline hover:text-foreground">
|
||||
Obtenir vos clés sur dashboard.stripe.com/apikeys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plan pricing cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{(["starter", "pro", "business"] as const).map((planId) => {
|
||||
const plan = pricing[planId];
|
||||
const Icon = PLAN_ICONS[planId] || Sparkles;
|
||||
const hasMonthlyId = plan.stripe_price_id_monthly && !plan.stripe_price_id_monthly.includes("xxx");
|
||||
const hasYearlyId = plan.stripe_price_id_yearly && !plan.stripe_price_id_yearly.includes("xxx");
|
||||
|
||||
return (
|
||||
<Card key={planId} className={`${
|
||||
planId === "pro" ? "border-violet-500/30" :
|
||||
planId === "business" ? "border-emerald-500/30" : ""
|
||||
}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${
|
||||
planId === "pro" ? "text-violet-400" :
|
||||
planId === "business" ? "text-emerald-400" : "text-blue-400"
|
||||
}`} />
|
||||
{plan.name}
|
||||
{hasMonthlyId && hasYearlyId
|
||||
? <Badge className="ml-auto text-xs bg-emerald-900/30 text-emerald-300 border-emerald-500/30">✓ Stripe OK</Badge>
|
||||
: <Badge variant="outline" className="ml-auto text-xs text-amber-400 border-amber-500/30">Price IDs manquants</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Prices */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Prix mensuel (€)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={plan.price_monthly}
|
||||
onChange={e => updatePlan(planId, "price_monthly", parseFloat(e.target.value) || 0)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Prix annuel (€) — auto (−{ANNUAL_DISCOUNT_PERCENT} %)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
value={plan.price_yearly}
|
||||
className="h-8 text-sm bg-muted/40 cursor-not-allowed"
|
||||
aria-readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Facturation annuelle = 12 × mensuel × {YEARLY_DISCOUNT_FACTOR} (équivalent −{ANNUAL_DISCOUNT_PERCENT} % vs 12 mois au tarif mensuel). Le serveur impose cette règle à l’enregistrement et pour Stripe.
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stripe Price IDs */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KeyRound className="w-3 h-3" /> Price ID mensuel
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="price_..."
|
||||
value={plan.stripe_price_id_monthly}
|
||||
onChange={e => updatePlan(planId, "stripe_price_id_monthly", e.target.value)}
|
||||
className={`h-8 text-xs font-mono ${hasMonthlyId ? "border-emerald-500/30" : "border-amber-500/30"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KeyRound className="w-3 h-3" /> Price ID annuel
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="price_..."
|
||||
value={plan.stripe_price_id_yearly}
|
||||
onChange={e => updatePlan(planId, "stripe_price_id_yearly", e.target.value)}
|
||||
className={`h-8 text-xs font-mono ${hasYearlyId ? "border-emerald-500/30" : "border-amber-500/30"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Warning if Price IDs missing */}
|
||||
{["starter", "pro", "business"].some(p => {
|
||||
const plan = pricing[p as keyof PricingData];
|
||||
return !plan.stripe_price_id_monthly || plan.stripe_price_id_monthly.includes("xxx");
|
||||
}) && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border border-amber-500/30 bg-amber-900/10 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-amber-300 font-medium">Price IDs Stripe manquants</p>
|
||||
<p className="text-amber-400/80 text-xs mt-1">
|
||||
Cliquez sur <strong>"Créer automatiquement"</strong> pour que le backend crée les produits dans Stripe et remplisse les IDs automatiquement. Ou entrez-les manuellement depuis votre{" "}
|
||||
<a href="https://dashboard.stripe.com/products" target="_blank" rel="noreferrer" className="underline">
|
||||
tableau de bord Stripe
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={savePricing} disabled={saving} size="lg" className="gap-2">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{saving ? "Sauvegarde…" : "Sauvegarder la configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface ProviderConfig {
|
||||
|
||||
interface SettingsConfig {
|
||||
google: ProviderConfig;
|
||||
google_cloud: ProviderConfig;
|
||||
deepl: ProviderConfig;
|
||||
openai: ProviderConfig;
|
||||
ollama: ProviderConfig;
|
||||
@@ -42,6 +43,7 @@ interface EnvInfo {
|
||||
openrouter_premium: boolean;
|
||||
zai: boolean;
|
||||
ollama: boolean;
|
||||
google_cloud: boolean;
|
||||
}
|
||||
|
||||
interface OllamaModel {
|
||||
@@ -52,6 +54,7 @@ interface OllamaModel {
|
||||
|
||||
const defaultConfig: SettingsConfig = {
|
||||
google: { enabled: true, timeout: 30, max_retries: 3 },
|
||||
google_cloud: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
|
||||
deepl: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
|
||||
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
|
||||
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
|
||||
@@ -70,6 +73,7 @@ const defaultEnvInfo: EnvInfo = {
|
||||
openrouter_premium: false,
|
||||
zai: false,
|
||||
ollama: false,
|
||||
google_cloud: false,
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
@@ -224,7 +228,7 @@ export default function AdminSettingsPage() {
|
||||
<div className="grid gap-4">
|
||||
<ProviderCard
|
||||
title="Google Translate"
|
||||
description="Tier gratuit : 500 000 caractères/mois. Aucune clé requise."
|
||||
description="Accès web non officiel via deep_translator. Aucune clé requise, usage raisonnable recommandé."
|
||||
enabled={config.google.enabled}
|
||||
onToggle={(enabled) => updateProvider("google", { enabled })}
|
||||
onTest={() => testProvider("google")}
|
||||
@@ -233,6 +237,35 @@ export default function AdminSettingsPage() {
|
||||
noApiKey
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
title="Google Cloud Translation (officiel)"
|
||||
description="API officielle Google Cloud. 500 000 car./mois offerts, puis ~$20/M car. Clé API sur console.cloud.google.com"
|
||||
enabled={config.google_cloud.enabled}
|
||||
onToggle={(enabled) => updateProvider("google_cloud", { enabled })}
|
||||
onTest={() => testProvider("google_cloud")}
|
||||
testResult={testResults.google_cloud ?? "idle"}
|
||||
testMessage={testMessages.google_cloud}
|
||||
envKeySet={envInfo.google_cloud}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="google-cloud-key">Clé API</Label>
|
||||
<Input
|
||||
id="google-cloud-key"
|
||||
type="password"
|
||||
placeholder={
|
||||
envInfo.google_cloud
|
||||
? "Clé configurée dans .env (laisser vide pour l'utiliser)"
|
||||
: "AIza..."
|
||||
}
|
||||
value={config.google_cloud.api_key || ""}
|
||||
onChange={(e) => updateProvider("google_cloud", { api_key: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Activez <code>Cloud Translation API</code> dans Google Cloud Console, puis créez une clé API restreinte à cette API.
|
||||
</p>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="DeepL"
|
||||
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
|
||||
|
||||
@@ -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