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

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