fix(admin): secure routes, add real IP detection, SMTP header validation, and fix Next.js layout hydration mismatch
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m5s

This commit is contained in:
2026-06-01 23:16:03 +02:00
parent 6d27dc4cda
commit 6da8a85b1d
10 changed files with 1165 additions and 96 deletions

View File

@@ -20,8 +20,10 @@ export default function AdminLayout({
const [isChecking, setIsChecking] = useState(true); const [isChecking, setIsChecking] = useState(true);
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
const [persistHydrated, setPersistHydrated] = useState(false); const [persistHydrated, setPersistHydrated] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => { useEffect(() => {
setIsMounted(true);
const unsub = useTranslationStore.persist.onFinishHydration(() => { const unsub = useTranslationStore.persist.onFinishHydration(() => {
setPersistHydrated(true); setPersistHydrated(true);
}); });
@@ -31,6 +33,10 @@ export default function AdminLayout({
return unsub; return unsub;
}, []); }, []);
if (!isMounted) {
return null;
}
const verifyToken = useCallback(async (token: string): Promise<boolean> => { const verifyToken = useCallback(async (token: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_BASE}/api/v1/admin/verify`, { const response = await fetch(`${API_BASE}/api/v1/admin/verify`, {

View File

@@ -25,14 +25,14 @@ import { useTranslationStore } from '@/lib/store';
import { API_BASE } from '@/lib/config'; import { API_BASE } from '@/lib/config';
const PRESETS = [ const PRESETS = [
{ key: 'it', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps', icon: Monitor, templateId: 'technology' }, { key: 'it', titleKey: 'glossaries.presets.it.title', descKey: 'glossaries.presets.it.desc', icon: Monitor, templateId: 'technology' },
{ key: 'legal', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux', icon: Scale, templateId: 'legal' }, { key: 'legal', titleKey: 'glossaries.presets.legal.title', descKey: 'glossaries.presets.legal.desc', icon: Scale, templateId: 'legal' },
{ key: 'medical', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic', icon: Stethoscope, templateId: 'medical' }, { key: 'medical', titleKey: 'glossaries.presets.medical.title', descKey: 'glossaries.presets.medical.desc', icon: Stethoscope, templateId: 'medical' },
{ key: 'finance', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité', icon: BarChart3, templateId: 'finance' }, { key: 'finance', titleKey: 'glossaries.presets.finance.title', descKey: 'glossaries.presets.finance.desc', icon: BarChart3, templateId: 'finance' },
{ key: 'marketing', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics', icon: Megaphone, templateId: 'marketing' }, { key: 'marketing', titleKey: 'glossaries.presets.marketing.title', descKey: 'glossaries.presets.marketing.desc', icon: Megaphone, templateId: 'marketing' },
{ key: 'hr', title: 'RH / Ressources Humaines', desc: 'Contrats, politiques, recrutement', icon: Users, templateId: 'hr' }, { key: 'hr', titleKey: 'glossaries.presets.hr.title', descKey: 'glossaries.presets.hr.desc', icon: Users, templateId: 'hr' },
{ key: 'scientific', title: 'Scientifique / Recherche', desc: 'Publications, thèses, articles', icon: FlaskConical, templateId: 'scientific' }, { key: 'scientific', titleKey: 'glossaries.presets.scientific.title', descKey: 'glossaries.presets.scientific.desc', icon: FlaskConical, templateId: 'scientific' },
{ key: 'ecommerce', title: 'E-commerce / Vente', desc: 'Boutiques en ligne, catalogues, CRM', icon: ShoppingCart, templateId: 'ecommerce' }, { key: 'ecommerce', titleKey: 'glossaries.presets.ecommerce.title', descKey: 'glossaries.presets.ecommerce.desc', icon: ShoppingCart, templateId: 'ecommerce' },
]; ];
export default function GlossariesPage() { export default function GlossariesPage() {
@@ -120,7 +120,7 @@ export default function GlossariesPage() {
toast({ toast({
title: t('context.presets.created'), title: t('context.presets.created'),
description: t('context.presets.createdDesc', { description: t('context.presets.createdDesc', {
name: glossary?.name ?? preset.title, name: glossary?.name ?? t(preset.titleKey),
count: String(glossary?.terms?.length ?? 0), count: String(glossary?.terms?.length ?? 0),
}), }),
}); });
@@ -228,7 +228,7 @@ export default function GlossariesPage() {
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">Chargement...</p> <p className="text-xs text-brand-dark/40 dark:text-white/40 font-light">{t('glossaries.loading')}</p>
</div> </div>
</div> </div>
); );
@@ -280,16 +280,16 @@ export default function GlossariesPage() {
<div className="mb-10 p-5 rounded-2xl bg-brand-accent/5 dark:bg-brand-accent/10 border border-brand-accent/20 dark:border-brand-accent/15"> <div className="mb-10 p-5 rounded-2xl bg-brand-accent/5 dark:bg-brand-accent/10 border border-brand-accent/20 dark:border-brand-accent/15">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Info size={15} className="text-brand-accent shrink-0" /> <Info size={15} className="text-brand-accent shrink-0" />
<span className="text-xs font-bold uppercase tracking-widest text-brand-accent">Comment ces paramètres sont utilisés</span> <span className="text-xs font-bold uppercase tracking-widest text-brand-accent">{t('glossaries.howItWorks.title')}</span>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Step 1 */} {/* Step 1 */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">1</div> <div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">1</div>
<div> <div>
<p className="text-xs font-bold text-brand-dark dark:text-white mb-1">Configurez ici</p> <p className="text-xs font-bold text-brand-dark dark:text-white mb-1">{t('glossaries.howItWorks.step1Title')}</p>
<p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed"> <p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed">
Rédigez vos instructions de contexte ou créez/importez un glossaire de termes. {t('glossaries.howItWorks.step1Desc')}
</p> </p>
</div> </div>
</div> </div>
@@ -301,22 +301,22 @@ export default function GlossariesPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">2</div> <div className="w-7 h-7 rounded-full bg-brand-accent text-white flex items-center justify-center text-[11px] font-black shrink-0 mt-0.5">2</div>
<div> <div>
<p className="text-xs font-bold text-brand-dark dark:text-white mb-1">Activez dans Traduire</p> <p className="text-xs font-bold text-brand-dark dark:text-white mb-1">{t('glossaries.howItWorks.step2Title')}</p>
<p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed"> <p className="text-[11px] text-brand-dark/55 dark:text-white/50 font-light leading-relaxed">
Sur la page de traduction, dans la colonne de droite, sélectionnez votre glossaire. {t('glossaries.howItWorks.step2Desc')}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 pt-4 border-t border-brand-accent/10 flex items-center justify-between"> <div className="mt-4 pt-4 border-t border-brand-accent/10 flex items-center justify-between">
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light"> <p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light">
Les instructions de contexte s'appliquent <strong>automatiquement</strong> à toutes vos traductions IA une fois enregistrées. Les glossaires doivent être <strong>sélectionnés manuellement</strong> sur la page Traduire. {t('glossaries.howItWorks.warning')}
</p> </p>
<Link <Link
href="/dashboard/translate" href="/dashboard/translate"
className="ml-4 shrink-0 flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline" className="ml-4 shrink-0 flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline"
> >
Aller à Traduire <ExternalLink size={11} /> {t('glossaries.howItWorks.goToTranslate')} <ExternalLink size={11} />
</Link> </Link>
</div> </div>
</div> </div>
@@ -337,16 +337,16 @@ export default function GlossariesPage() {
{promptHasUnsavedChanges ? ( {promptHasUnsavedChanges ? (
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20"> <span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-500/10 px-3 py-1 rounded-full border border-amber-500/20">
<AlertCircle size={11} /> <AlertCircle size={11} />
Non enregistré {t('glossaries.status.unsaved')}
</span> </span>
) : promptIsActive ? ( ) : promptIsActive ? (
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-3 py-1 rounded-full border border-emerald-500/20"> <span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-3 py-1 rounded-full border border-emerald-500/20">
<CheckCircle2 size={11} /> <CheckCircle2 size={11} />
Actif · s'applique à toutes les traductions IA {t('glossaries.status.active')}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/5 dark:border-white/5"> <span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 bg-brand-muted dark:bg-white/5 px-3 py-1 rounded-full border border-black/5 dark:border-white/5">
Inactif {t('glossaries.status.inactive')}
</span> </span>
)} )}
</div> </div>
@@ -354,10 +354,10 @@ export default function GlossariesPage() {
{/* Explanation box */} {/* Explanation box */}
<div className="mb-5 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5"> <div className="mb-5 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5">
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed"> <p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
<strong className="font-bold text-brand-dark/80 dark:text-white/80">À quoi ça sert ?</strong> Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction, sans que vous ayez besoin de faire quoi que ce soit sur la page Traduire. Utilisez-les pour guider le style, le registre ou la terminologie générale. <strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.instructions.whatForBold')}</strong> {t('glossaries.instructions.whatForDesc')}
</p> </p>
<p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic"> <p className="text-[11px] text-brand-accent/80 dark:text-brand-accent/70 font-medium mt-2 italic">
Exemple : «&nbsp;Vous traduisez des rapports financiers. Soyez formel, précis et conservez tous les chiffres.&nbsp;» {t('glossaries.instructions.example')}
</p> </p>
</div> </div>
@@ -369,14 +369,14 @@ export default function GlossariesPage() {
/> />
<div className="flex justify-between items-center mt-4"> <div className="flex justify-between items-center mt-4">
<p className="text-[10px] text-brand-dark/30 dark:text-white/25 font-light"> <p className="text-[10px] text-brand-dark/30 dark:text-white/25 font-light">
{systemPrompt.length > 0 ? `${systemPrompt.length} caractères` : 'Vide aucune instruction envoyée à l\'IA'} {systemPrompt.length > 0 ? t('glossaries.instructions.charCount', { count: systemPrompt.length }) : t('glossaries.instructions.emptyHint')}
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={handleClearPrompt} onClick={handleClearPrompt}
className="px-5 py-2.5 bg-brand-muted dark:bg-white/5 text-brand-dark/50 dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer" className="px-5 py-2.5 bg-brand-muted dark:bg-white/5 text-brand-dark/50 dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
> >
<Trash2 size={12} className="inline mr-1.5" />Tout effacer <Trash2 size={12} className="inline mr-1.5" />{t('glossaries.instructions.clearAll')}
</button> </button>
<button <button
onClick={handleSavePrompt} onClick={handleSavePrompt}
@@ -384,9 +384,9 @@ export default function GlossariesPage() {
className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold" className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
> >
{isSavingPrompt {isSavingPrompt
? <><Loader2 size={14} className="animate-spin" /> Enregistrement</> ? <><Loader2 size={14} className="animate-spin" /> {t('glossaries.instructions.saving')}</>
: promptSaved : promptSaved
? <><CheckCircle2 size={14} /> Enregistré</> ? <><CheckCircle2 size={14} /> {t('glossaries.instructions.saved')}</>
: <><Save size={14} /> {t('context.save')}</> : <><Save size={14} /> {t('context.save')}</>
} }
</button> </button>
@@ -406,12 +406,12 @@ export default function GlossariesPage() {
{/* Explanation box */} {/* Explanation box */}
<div className="mb-6 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5"> <div className="mb-6 p-3.5 rounded-xl bg-brand-muted/40 dark:bg-white/[0.03] border border-black/5 dark:border-white/5">
<p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed"> <p className="text-[11px] text-brand-dark/60 dark:text-white/50 font-light leading-relaxed">
<strong className="font-bold text-brand-dark/80 dark:text-white/80">À quoi ça sert ?</strong> Cliquer sur une carte crée un glossaire pré-rempli avec les termes spécialisés du domaine. Ce glossaire apparaîtra dans vos glossaires ci-dessous, et vous pourrez le <strong>sélectionner manuellement</strong> sur la page Traduire pour forcer des traductions de termes précis. <strong className="font-bold text-brand-dark/80 dark:text-white/80">{t('glossaries.presets.whatForBold')}</strong> {t('glossaries.presets.whatForDesc')}
</p> </p>
<div className="flex items-center gap-2 mt-2.5"> <div className="flex items-center gap-2 mt-2.5">
<MousePointerClick size={11} className="text-brand-accent shrink-0" /> <MousePointerClick size={11} className="text-brand-accent shrink-0" />
<p className="text-[11px] text-brand-accent/80 font-medium"> <p className="text-[11px] text-brand-accent/80 font-medium">
Cliquez sur une carte glossaire créé sélectionnez-le dans Traduire {t('glossaries.presets.clickHint')}
</p> </p>
</div> </div>
</div> </div>
@@ -431,14 +431,14 @@ export default function GlossariesPage() {
<div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-110 transition-transform"> <div className="p-1.5 bg-brand-accent/10 rounded-lg text-brand-accent group-hover:scale-110 transition-transform">
{isCreatingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon size={16} />} {isCreatingThis ? <Loader2 size={16} className="animate-spin" /> : <Icon size={16} />}
</div> </div>
{isCreatingThis && <span className="text-[10px] text-brand-accent font-bold uppercase">Création</span>} {isCreatingThis && <span className="text-[10px] text-brand-accent font-bold uppercase">{t('glossaries.presets.creating')}</span>}
</div> </div>
<div> <div>
<h4 className="text-xs font-bold text-brand-dark dark:text-white mb-1"> <h4 className="text-xs font-bold text-brand-dark dark:text-white mb-1">
{p.title} {t(p.titleKey)}
</h4> </h4>
<p className="text-[10px] text-brand-dark/45 dark:text-white/45 leading-normal font-light"> <p className="text-[10px] text-brand-dark/45 dark:text-white/45 leading-normal font-light">
{p.desc} {t(p.descKey)}
</p> </p>
</div> </div>
</button> </button>
@@ -452,18 +452,18 @@ export default function GlossariesPage() {
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight"> <h2 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight">
Vos <span className="italic">glossaires</span> {t('glossaries.grid.title')} <span className="italic">{t('glossaries.grid.titleHighlight')}</span>
</h2> </h2>
<p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-1"> <p className="text-[11px] text-brand-dark/45 dark:text-white/40 font-light mt-1">
{glossaries.length > 0 {glossaries.length > 0
? `${glossaries.length} glossaire${glossaries.length > 1 ? 's' : ''} — cliquez sur une carte pour la modifier` ? t('glossaries.grid.countWithAction', { count: glossaries.length, plural: glossaries.length > 1 ? 's' : '' })
: 'Créez votre premier glossaire ou importez un preset ci-dessus'} : t('glossaries.grid.emptyAction')}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{currentTargetInfo && ( {currentTargetInfo && (
<span className="flex items-center gap-1.5 text-[10px] font-bold text-brand-dark/50 dark:text-white/40 bg-brand-muted dark:bg-white/5 border border-black/5 dark:border-white/5 px-3 py-1.5 rounded-full"> <span className="flex items-center gap-1.5 text-[10px] font-bold text-brand-dark/50 dark:text-white/40 bg-brand-muted dark:bg-white/5 border border-black/5 dark:border-white/5 px-3 py-1.5 rounded-full">
<span>Traduction active :</span> <span>{t('glossaries.grid.activeTranslation')}</span>
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span> <span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
</span> </span>
)} )}
@@ -473,7 +473,7 @@ export default function GlossariesPage() {
className="flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline shrink-0" className="flex items-center gap-1.5 text-[11px] font-bold text-brand-accent hover:underline shrink-0"
> >
<ExternalLink size={12} /> <ExternalLink size={12} />
Aller à Traduire pour activer {t('glossaries.grid.goToTranslate')}
</Link> </Link>
)} )}
</div> </div>
@@ -512,12 +512,12 @@ export default function GlossariesPage() {
{/* Match / mismatch badge */} {/* Match / mismatch badge */}
{matchesTarget && ( {matchesTarget && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider"> <div className="absolute top-3 right-3 flex items-center gap-1 bg-brand-accent/10 text-brand-accent px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
<CheckCircle2 size={9} /> Compatible <CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
</div> </div>
)} )}
{mismatch && ( {mismatch && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider"> <div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider">
<AlertCircle size={9} /> Autre cible <AlertCircle size={9} /> {t('glossaries.badge.otherTarget')}
</div> </div>
)} )}
@@ -556,7 +556,7 @@ export default function GlossariesPage() {
onClick={() => handleEditClick(glossary.id)} onClick={() => handleEditClick(glossary.id)}
className="flex-1 py-2 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 dark:hover:bg-brand-accent/15 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center justify-center gap-1.5" className="flex-1 py-2 px-3 rounded-lg bg-brand-muted/60 dark:bg-white/5 hover:bg-brand-accent/10 dark:hover:bg-brand-accent/15 text-brand-dark/70 dark:text-white/60 hover:text-brand-accent text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center justify-center gap-1.5"
> >
<Save size={10} /> Modifier les termes <Save size={10} /> {t('glossaries.card.editTerms')}
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -565,7 +565,7 @@ export default function GlossariesPage() {
}} }}
className="py-2 px-3 rounded-lg bg-red-500/5 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1.5" className="py-2 px-3 rounded-lg bg-red-500/5 hover:bg-red-500 hover:text-white text-red-500 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1.5"
> >
<Trash2 size={10} /> Supprimer <Trash2 size={10} /> {t('glossaries.card.delete')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -162,10 +162,10 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
setIsOpen(false); setIsOpen(false);
} else { } else {
const errData = await res.json().catch(() => null); const errData = await res.json().catch(() => null);
setError(errData?.message || `Import failed (${res.status})`); setError(errData?.message || t('translate.glossary.importFailed').replace('{status}', String(res.status)));
} }
} catch { } catch {
setError('Network error'); setError(t('translate.glossary.networkError'));
} finally { } finally {
setImportingId(null); setImportingId(null);
} }
@@ -217,10 +217,10 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
fetchData(); fetchData();
} else { } else {
const errData = await res.json().catch(() => null); const errData = await res.json().catch(() => null);
setError(errData?.message || 'Erreur lors de l\'ajout du terme'); setError(errData?.message || t('translate.glossary.addTermError'));
} }
} catch { } catch {
setError('Erreur réseau'); setError(t('translate.glossary.networkError'));
} finally { } finally {
setIsAddingTerm(false); setIsAddingTerm(false);
} }
@@ -316,7 +316,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{mode === 'classic' ? ( {mode === 'classic' ? (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center"> <div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center">
<span className="text-xs font-black uppercase opacity-45 block"> <span className="text-xs font-black uppercase opacity-45 block">
Moteur neutre sans glossaire (IA uniquement) {t('translate.glossary.classicMode') || 'Moteur neutre sans glossaire (IA uniquement)'}
</span> </span>
</div> </div>
) : !isPro ? ( ) : !isPro ? (
@@ -330,7 +330,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{/* Help Info text */} {/* Help Info text */}
<p className="text-[10.5px] text-brand-dark/60 dark:text-white/40 leading-normal font-light"> <p className="text-[10.5px] text-brand-dark/60 dark:text-white/40 leading-normal font-light">
Le glossaire force la traduction de termes précis. Choisissez un glossaire dont la <strong>langue source</strong> correspond à la langue d'origine de votre document. {t('translate.glossary.helpText') || 'Le glossaire force la traduction de termes précis. Choisissez un glossaire dont la langue source correspond à la langue d\'origine de votre document.'}
</p> </p>
{/* Mismatch Warning — source language */} {/* Mismatch Warning — source language */}
@@ -338,7 +338,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 text-[10px] leading-normal font-medium animate-fade-in"> <div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 text-[10px] leading-normal font-medium animate-fade-in">
<span className="shrink-0 text-amber-500"></span> <span className="shrink-0 text-amber-500"></span>
<span> <span>
<strong>Attention :</strong> Ce glossaire utilise la langue source <strong>{getFlag(selected.source_language)} {selected.source_language.toUpperCase()}</strong>, mais votre document est configuré en <strong>{getFlag(sourceLang)} {sourceLang.toUpperCase()}</strong>. <strong>{t('translate.glossary.sourceWarning') || 'Attention :'} Ce glossaire utilise la langue source</strong> <strong>{getFlag(selected.source_language)} {selected.source_language.toUpperCase()}</strong>, {t('translate.glossary.sourceWarningBut') || 'mais votre document est configuré en'} <strong>{getFlag(sourceLang)} {sourceLang.toUpperCase()}</strong>.
</span> </span>
</div> </div>
)} )}
@@ -348,7 +348,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-[10px] leading-normal font-medium animate-fade-in"> <div className="flex items-start gap-1.5 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-[10px] leading-normal font-medium animate-fade-in">
<span className="shrink-0">🎯</span> <span className="shrink-0">🎯</span>
<span> <span>
<strong>Incompatibilité de cible :</strong> Ce glossaire est prévu pour traduire vers <strong>{getFlag(selected.target_language)} {selected.target_language.toUpperCase()}</strong>, mais votre document cible <strong>{targetFlag} {targetLang.toUpperCase()}</strong>. Les termes risquent de ne pas être pertinents. <strong>{t('translate.glossary.targetWarning') || 'Incompatibilité de cible :'}</strong> <strong>{getFlag(selected.target_language)} {selected.target_language.toUpperCase()}</strong>, {t('translate.glossary.targetWarningBut') || 'mais votre document cible'} <strong>{targetFlag} {targetLang.toUpperCase()}</strong>. {t('translate.glossary.targetWarningEnd') || 'Les termes risquent de ne pas être pertinents.'}
</span> </span>
</div> </div>
)} )}
@@ -367,12 +367,12 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
> >
<div className="text-left min-w-0 flex-1 pr-2"> <div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold text-brand-dark dark:text-white tracking-tight block truncate"> <span className="text-xs font-bold text-brand-dark dark:text-white tracking-tight block truncate">
{selected ? selected.name : (isLoading ? "Chargement..." : "Sélectionner un glossaire...")} {selected ? selected.name : (isLoading ? t('translate.glossary.loading') || 'Chargement...' : t('translate.glossary.selectPlaceholder') || 'Sélectionner un glossaire...')}
</span> </span>
<span className="text-[10px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5"> <span className="text-[10px] font-extrabold uppercase tracking-wider text-brand-dark/40 dark:text-white/40 block mt-0.5">
{selected {selected
? `${getFlag(selected.source_language)} ➜ ${selected.target_language === 'multi' ? '🌐 MULTILINGUE' : getFlag(selected.target_language || targetLang)} • ${selected.terms_count} termes` ? `${getFlag(selected.source_language)}${selected.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(selected.target_language || targetLang)}${selected.terms_count} ${t('translate.glossary.terms') || 'termes'}`
: (filteredGlossaries.length > 0 || filteredTemplates.length > 0 ? "Sélectionnez un glossaire" : "Aucun glossaire disponible") : (filteredGlossaries.length > 0 || filteredTemplates.length > 0 ? t('translate.glossary.select') || 'Sélectionnez un glossaire' : t('translate.glossary.noGlossaryAvailable') || 'Aucun glossaire disponible')
} }
</span> </span>
</div> </div>
@@ -391,7 +391,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{sourceLang !== 'auto' && (glossaries.length > 0 || templates.length > 0) && ( {sourceLang !== 'auto' && (glossaries.length > 0 || templates.length > 0) && (
<div className="px-2 py-1.5 border-b border-black/[0.03] dark:border-white/[0.03] flex justify-between items-center mb-1"> <div className="px-2 py-1.5 border-b border-black/[0.03] dark:border-white/[0.03] flex justify-between items-center mb-1">
<span className="text-[10px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest"> <span className="text-[10px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">
Filtrer par langue ({sourceFlag}) {t('translate.glossary.filterByLang') || 'Filtrer par langue'} ({sourceFlag})
</span> </span>
<button <button
type="button" type="button"
@@ -401,7 +401,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
filterByLang ? "bg-brand-accent/10 text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50" filterByLang ? "bg-brand-accent/10 text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50"
)} )}
> >
{filterByLang ? "Actif" : "Inactif"} {filterByLang ? t('translate.glossary.active') || 'Actif' : t('translate.glossary.inactive') || 'Inactif'}
</button> </button>
</div> </div>
)} )}
@@ -410,7 +410,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{filteredGlossaries.length > 0 && ( {filteredGlossaries.length > 0 && (
<div className="mb-2"> <div className="mb-2">
<div className="px-2 py-1 text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest"> <div className="px-2 py-1 text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">
Mes Glossaires {t('translate.glossary.myGlossaries') || 'Mes Glossaires'}
</div> </div>
{filteredGlossaries.map(g => { {filteredGlossaries.map(g => {
const flag = getFlag(g.source_language); const flag = getFlag(g.source_language);
@@ -433,7 +433,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<div className="text-left min-w-0 flex-1 pr-2"> <div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold block leading-none truncate">{g.name}</span> <span className="text-xs font-bold block leading-none truncate">{g.name}</span>
<span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1"> <span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1">
{flag} ➜ {g.target_language === 'multi' ? '🌐 MULTILINGUE' : getFlag(g.target_language || targetLang)} • {g.terms_count} termes {flag} {g.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(g.target_language || targetLang)} {g.terms_count} {t('translate.glossary.terms') || 'termes'}
</span> </span>
</div> </div>
{isSelected && <Check size={12} className="text-brand-accent shrink-0" />} {isSelected && <Check size={12} className="text-brand-accent shrink-0" />}
@@ -447,7 +447,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{filteredTemplates.length > 0 && ( {filteredTemplates.length > 0 && (
<div> <div>
<div className="px-2 py-1 text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest border-t border-black/[0.03] dark:border-white/[0.03] pt-2 mt-1"> <div className="px-2 py-1 text-[10px] font-bold text-brand-dark/40 dark:text-white/40 uppercase tracking-widest border-t border-black/[0.03] dark:border-white/[0.03] pt-2 mt-1">
Modèles disponibles {t('translate.glossary.availableTemplates') || 'Modèles disponibles'}
</div> </div>
<div className="grid grid-cols-1 gap-0.5"> <div className="grid grid-cols-1 gap-0.5">
{filteredTemplates.map(tmpl => { {filteredTemplates.map(tmpl => {
@@ -457,7 +457,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
); );
const isAlreadySelected = existingGlossary?.id === glossaryId; const isAlreadySelected = existingGlossary?.id === glossaryId;
const flag = getFlag(tmpl.source_lang); const flag = getFlag(tmpl.source_lang);
const tFlag = tmpl.target_lang === 'multi' ? '🌐 MULTILINGUE' : getFlag(tmpl.target_lang); const tFlag = tmpl.target_lang === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(tmpl.target_lang);
return ( return (
<button <button
@@ -481,10 +481,10 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
> >
<div className="text-left min-w-0 flex-1 pr-2"> <div className="text-left min-w-0 flex-1 pr-2">
<span className="text-xs font-bold block leading-none truncate"> <span className="text-xs font-bold block leading-none truncate">
{isImporting ? 'Importation...' : tmpl.name.split('/')[0].trim()} {isImporting ? t('translate.glossary.importing') || 'Importation...' : tmpl.name.split('/')[0].trim()}
</span> </span>
<span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1"> <span className="text-[10px] uppercase tracking-wider text-brand-dark/40 dark:text-white/45 font-bold block mt-1">
{flag} ➜ {tFlag} • {tmpl.terms_count} termes {existingGlossary ? '(Importé)' : ''} {flag} {tFlag} {tmpl.terms_count} {t('translate.glossary.terms') || 'termes'} {existingGlossary ? t('translate.glossary.imported') || '(Importé)' : ''}
</span> </span>
</div> </div>
{isImporting ? ( {isImporting ? (
@@ -503,14 +503,14 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{filteredGlossaries.length === 0 && filteredTemplates.length === 0 && ( {filteredGlossaries.length === 0 && filteredTemplates.length === 0 && (
<div className="px-2.5 py-4 text-center"> <div className="px-2.5 py-4 text-center">
<p className="text-xs text-brand-dark/45 dark:text-white/45 italic mb-3 font-light"> <p className="text-xs text-brand-dark/45 dark:text-white/45 italic mb-3 font-light">
Aucun glossaire ni modèle pour la langue source {sourceFlag || sourceLang.toUpperCase()}. {t('translate.glossary.noGlossaryForSource') || 'Aucun glossaire ni modèle pour la langue source'} {sourceFlag || sourceLang.toUpperCase()}.
</p> </p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<a <a
href={`/dashboard/glossaries?new=true&source=${sourceLang}`} href={`/dashboard/glossaries?new=true&source=${sourceLang}`}
className="w-full py-2 px-2 bg-brand-dark dark:bg-white text-white dark:text-brand-dark hover:opacity-90 rounded-lg text-xs font-bold uppercase tracking-wider block text-center transition-opacity cursor-pointer" className="w-full py-2 px-2 bg-brand-dark dark:bg-white text-white dark:text-brand-dark hover:opacity-90 rounded-lg text-xs font-bold uppercase tracking-wider block text-center transition-opacity cursor-pointer"
> >
Créer un glossaire {sourceLang === 'auto' ? '' : sourceLang.toUpperCase()} {t('translate.glossary.createGlossary') || 'Créer un glossaire'} {sourceLang === 'auto' ? '' : sourceLang.toUpperCase()}
</a> </a>
{filterByLang && (glossaries.length > 0 || templates.length > 0) && ( {filterByLang && (glossaries.length > 0 || templates.length > 0) && (
<button <button
@@ -518,7 +518,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
onClick={() => setFilterByLang(false)} onClick={() => setFilterByLang(false)}
className="w-full py-2 px-2 bg-brand-muted dark:bg-white/5 hover:bg-brand-muted/70 text-brand-dark dark:text-white rounded-lg text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer" className="w-full py-2 px-2 bg-brand-muted dark:bg-white/5 hover:bg-brand-muted/70 text-brand-dark dark:text-white rounded-lg text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer"
> >
Afficher tous les glossaires {t('translate.glossary.showAll') || 'Afficher tous les glossaires'}
</button> </button>
)} )}
</div> </div>
@@ -533,16 +533,16 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<div className="bg-white/80 dark:bg-[#1a1a1a]/40 rounded-lg p-2.5 border border-black/[0.02] dark:border-white/[0.02] space-y-2"> <div className="bg-white/80 dark:bg-[#1a1a1a]/40 rounded-lg p-2.5 border border-black/[0.02] dark:border-white/[0.02] space-y-2">
<div className="flex justify-between items-center border-b border-black/[0.02] dark:border-white/[0.02] pb-1"> <div className="flex justify-between items-center border-b border-black/[0.02] dark:border-white/[0.02] pb-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 block"> <span className="text-xs font-bold uppercase tracking-wider text-brand-dark/30 dark:text-white/30 block">
Aperçu des correspondances actives : {t('translate.glossary.activePreview') || 'Aperçu des correspondances actives :'}
</span> </span>
<span className="text-xs font-bold text-brand-dark/40 dark:text-white/40"> <span className="text-xs font-bold text-brand-dark/40 dark:text-white/40">
{selectedGlossaryDetail?.terms?.length || selected.terms_count} au total {selectedGlossaryDetail?.terms?.length || selected.terms_count} {t('translate.glossary.total') || 'au total'}
</span> </span>
</div> </div>
{isLoadingDetail ? ( {isLoadingDetail ? (
<div className="flex items-center justify-center py-3 gap-1.5 text-xs text-brand-dark/40 dark:text-white/30 font-light"> <div className="flex items-center justify-center py-3 gap-1.5 text-xs text-brand-dark/40 dark:text-white/30 font-light">
<Loader2 size={12} className="animate-spin text-brand-accent" /> Chargement... <Loader2 size={12} className="animate-spin text-brand-accent" /> {t('translate.glossary.loading') || 'Chargement...'}
</div> </div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? ( ) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
<div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1"> <div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1">
@@ -560,12 +560,12 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
})} })}
{selectedGlossaryDetail.terms.length > 4 && ( {selectedGlossaryDetail.terms.length > 4 && (
<span className="text-[10px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1"> <span className="text-[10px] text-brand-dark/40 dark:text-white/40 block text-right font-medium mt-1">
+ {selectedGlossaryDetail.terms.length - 4} autres termes + {selectedGlossaryDetail.terms.length - 4} {t('translate.glossary.moreTerms') || 'autres termes'}
</span> </span>
)} )}
</div> </div>
) : ( ) : (
<p className="py-3 text-xs text-brand-dark/40 dark:text-white/30 italic text-center font-light">Aucun terme dans ce glossaire.</p> <p className="py-3 text-xs text-brand-dark/40 dark:text-white/30 italic text-center font-light">{t('translate.glossary.noTerms') || 'Aucun terme dans ce glossaire.'}</p>
)} )}
</div> </div>
)} )}
@@ -576,7 +576,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<input <input
type="text" type="text"
required required
placeholder="Terme Source" placeholder={t('translate.glossary.sourceTerm') || 'Terme Source'}
value={newSource} value={newSource}
onChange={(e) => setNewSource(e.target.value)} onChange={(e) => setNewSource(e.target.value)}
disabled={isAddingTerm || disabled} disabled={isAddingTerm || disabled}
@@ -586,7 +586,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
<input <input
type="text" type="text"
required required
placeholder="Traduction" placeholder={t('translate.glossary.translation') || 'Traduction'}
value={newTarget} value={newTarget}
onChange={(e) => setNewTarget(e.target.value)} onChange={(e) => setNewTarget(e.target.value)}
disabled={isAddingTerm || disabled} disabled={isAddingTerm || disabled}
@@ -596,7 +596,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
type="submit" type="submit"
disabled={isAddingTerm || disabled || !newSource.trim() || !newTarget.trim()} disabled={isAddingTerm || disabled || !newSource.trim() || !newTarget.trim()}
className="px-3 bg-brand-dark dark:bg-white text-white dark:text-brand-dark rounded-lg flex items-center justify-center disabled:opacity-35 transition-colors cursor-pointer shrink-0" className="px-3 bg-brand-dark dark:bg-white text-white dark:text-brand-dark rounded-lg flex items-center justify-center disabled:opacity-35 transition-colors cursor-pointer shrink-0"
title="Ajouter le terme" title={t('translate.glossary.addTerm') || 'Ajouter le terme'}
> >
{isAddingTerm ? ( {isAddingTerm ? (
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin" />
@@ -610,7 +610,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
</div> </div>
) : ( ) : (
<div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center animate-fade-in"> <div className="p-2.5 bg-brand-dark/5 dark:bg-white/5 rounded-lg text-center animate-fade-in">
<span className="text-xs font-black uppercase opacity-40">Moteur neutre sans glossaire appliqué</span> <span className="text-xs font-black uppercase opacity-40">{t('translate.glossary.disabledMode') || 'Moteur neutre sans glossaire appliqué'}</span>
</div> </div>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -173,10 +173,26 @@ def _subscription_status_str(raw) -> str:
return raw.value if hasattr(raw, "value") else str(raw) return raw.value if hasattr(raw, "value") else str(raw)
def _get_client_ip(request: Request) -> str:
"""Get real client IP from headers or connection"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
if request.client:
return request.client.host
return "unknown"
@router.post("/login") @router.post("/login")
async def admin_login(request: AdminLoginRequest, req: Request): async def admin_login(request: AdminLoginRequest, req: Request):
"""Admin login endpoint - Returns a bearer token for authenticated admin access""" """Admin login endpoint - Returns a bearer token for authenticated admin access"""
client_ip = req.client.host if req.client else "unknown" client_ip = _get_client_ip(req)
# Brute-force protection # Brute-force protection
now = time.time() now = time.time()
@@ -225,13 +241,13 @@ async def admin_logout(authorization: Optional[str] = Header(None)):
@router.get("/verify") @router.get("/verify")
async def verify_admin_session(is_admin: bool = Depends(require_admin)): async def verify_admin_session(admin_id: str = Depends(require_admin)):
"""Verify admin token is still valid""" """Verify admin token is still valid"""
return {"status": "valid", "authenticated": True} return {"status": "valid", "authenticated": True}
@router.get("/dashboard") @router.get("/dashboard")
async def get_admin_dashboard(is_admin: bool = Depends(require_admin)): async def get_admin_dashboard(admin_id: str = Depends(require_admin)):
"""Get comprehensive admin dashboard data""" """Get comprehensive admin dashboard data"""
from middleware.cleanup import create_cleanup_manager from middleware.cleanup import create_cleanup_manager
from middleware.rate_limiting import RateLimitManager, RateLimitConfig from middleware.rate_limiting import RateLimitManager, RateLimitConfig
@@ -285,7 +301,7 @@ async def get_admin_dashboard(is_admin: bool = Depends(require_admin)):
@router.get("/users") @router.get("/users")
async def get_admin_users(is_admin: bool = Depends(require_admin)): async def get_admin_users(admin_id: str = Depends(require_admin)):
"""Liste tous les utilisateurs (base SQLite/PostgreSQL + comptes uniquement dans users.json).""" """Liste tous les utilisateurs (base SQLite/PostgreSQL + comptes uniquement dans users.json)."""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
from database.connection import get_sync_session from database.connection import get_sync_session
@@ -397,7 +413,7 @@ async def get_admin_users(is_admin: bool = Depends(require_admin)):
async def patch_admin_user_tier( async def patch_admin_user_tier(
user_id: str, user_id: str,
body: AdminUpdateUserTierRequest, body: AdminUpdateUserTierRequest,
is_admin: bool = Depends(require_admin), admin_id: str = Depends(require_admin),
): ):
"""Update a user's plan/tier - Admin only""" """Update a user's plan/tier - Admin only"""
from services.auth_service import get_user_by_id, update_user_plan from services.auth_service import get_user_by_id, update_user_plan
@@ -459,7 +475,7 @@ async def patch_admin_user_tier(
async def admin_reset_user_password( async def admin_reset_user_password(
user_id: str, user_id: str,
body: AdminResetPasswordRequest, body: AdminResetPasswordRequest,
is_admin: bool = Depends(require_admin), admin_id: str = Depends(require_admin),
): ):
"""Définit un nouveau mot de passe pour un utilisateur (sans email de réinitialisation).""" """Définit un nouveau mot de passe pour un utilisateur (sans email de réinitialisation)."""
from services.auth_service import admin_set_user_password from services.auth_service import admin_set_user_password
@@ -484,7 +500,7 @@ async def admin_reset_user_password(
@router.get("/stats") @router.get("/stats")
async def get_admin_stats(is_admin: bool = Depends(require_admin)): async def get_admin_stats(admin_id: str = Depends(require_admin)):
"""Get comprehensive admin statistics""" """Get comprehensive admin statistics"""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
from services.translation_service import _translation_cache from services.translation_service import _translation_cache
@@ -565,7 +581,7 @@ async def get_admin_stats(is_admin: bool = Depends(require_admin)):
@router.post("/cleanup/trigger") @router.post("/cleanup/trigger")
async def trigger_cleanup(is_admin: bool = Depends(require_admin)): async def trigger_cleanup(admin_id: str = Depends(require_admin)):
"""Trigger manual cleanup of expired files""" """Trigger manual cleanup of expired files"""
from middleware.cleanup import create_cleanup_manager from middleware.cleanup import create_cleanup_manager
@@ -583,7 +599,7 @@ async def trigger_cleanup(is_admin: bool = Depends(require_admin)):
@router.get("/files/tracked") @router.get("/files/tracked")
async def get_tracked_files(is_admin: bool = Depends(require_admin)): async def get_tracked_files(admin_id: str = Depends(require_admin)):
"""Get list of currently tracked files""" """Get list of currently tracked files"""
from middleware.cleanup import create_cleanup_manager from middleware.cleanup import create_cleanup_manager
@@ -593,7 +609,7 @@ async def get_tracked_files(is_admin: bool = Depends(require_admin)):
@router.post("/quota/reset") @router.post("/quota/reset")
async def reset_translation_quotas(is_admin: bool = Depends(require_admin)): async def reset_translation_quotas(admin_id: str = Depends(require_admin)):
"""Reset monthly translation quotas for all free-tier users. """Reset monthly translation quotas for all free-tier users.
Clears Redis keys matching quota:monthly:* Clears Redis keys matching quota:monthly:*
""" """
@@ -623,7 +639,7 @@ async def reset_translation_quotas(is_admin: bool = Depends(require_admin)):
@router.post("/config/provider") @router.post("/config/provider")
async def update_default_provider( async def update_default_provider(
provider: str = Form(...), provider: str = Form(...),
is_admin: bool = Depends(require_admin), admin_id: str = Depends(require_admin),
): ):
"""Update the default translation provider""" """Update the default translation provider"""
valid_providers = [ valid_providers = [
@@ -732,7 +748,7 @@ def _extract_error_code(error_message: Optional[str]) -> Optional[str]:
@router.get("/logs") @router.get("/logs")
def get_admin_logs( def get_admin_logs(
is_admin: str = Depends(require_admin), admin_id: str = Depends(require_admin),
level: str = Query(default="all", pattern="^(all|error|warning|info)$"), level: str = Query(default="all", pattern="^(all|error|warning|info)$"),
search: str = Query(default="", max_length=200), search: str = Query(default="", max_length=200),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
@@ -1261,6 +1277,8 @@ async def test_send_email(
username = ((smtp_body and smtp_body.username) or "").strip() or (smtp.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip() username = ((smtp_body and smtp_body.username) or "").strip() or (smtp.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
password = ((smtp_body and smtp_body.password) or "").strip() or (smtp.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip() password = ((smtp_body and smtp_body.password) or "").strip() or (smtp.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
from_email = ((smtp_body and smtp_body.from_email) or "").strip() or (smtp.from_email or "").strip() or os.getenv("SMTP_FROM_EMAIL", "").strip() or username from_email = ((smtp_body and smtp_body.from_email) or "").strip() or (smtp.from_email or "").strip() or os.getenv("SMTP_FROM_EMAIL", "").strip() or username
if from_email:
from_email = from_email.replace("\r", "").replace("\n", "")
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (smtp.use_tls if smtp.use_tls is not None else True) use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (smtp.use_tls if smtp.use_tls is not None else True)
if not from_email: if not from_email:

View File

@@ -42,6 +42,7 @@ from pydantic import BaseModel, Field, field_validator
from typing_extensions import Annotated from typing_extensions import Annotated
from config import config from config import config
from translators import ExcelTranslator, WordTranslator, PowerPointTranslator
from models.subscription import PlanType from models.subscription import PlanType
from middleware.tier_quota import tier_quota_service from middleware.tier_quota import tier_quota_service
from services.auth_service import record_usage from services.auth_service import record_usage
@@ -978,7 +979,6 @@ async def _run_translation_job(
) )
output_path = config.OUTPUT_DIR / output_filename output_path = config.OUTPUT_DIR / output_filename
from translators import ExcelTranslator, WordTranslator, PowerPointTranslator
from services.translation_service import ( from services.translation_service import (
OpenRouterTranslationProvider, OpenRouterTranslationProvider,
OllamaTranslationProvider, OllamaTranslationProvider,
@@ -1192,6 +1192,7 @@ async def _run_translation_job(
# One translator instance per job so concurrent jobs never share mutable # One translator instance per job so concurrent jobs never share mutable
# provider state (singleton set_provider was racy under parallel translations). # provider state (singleton set_provider was racy under parallel translations).
if file_extension == ".xlsx": if file_extension == ".xlsx":
logger.info(f"DEBUG: ExcelTranslator class is {ExcelTranslator} and translate_file is {ExcelTranslator.translate_file}")
job_translator = ExcelTranslator(provider=translation_provider) job_translator = ExcelTranslator(provider=translation_provider)
await asyncio.to_thread( await asyncio.to_thread(
job_translator.translate_file, job_translator.translate_file,

View File

@@ -1,16 +1,21 @@
""" import pytest
Test configuration and fixtures
"""
from typing import AsyncGenerator
import pytest_asyncio import pytest_asyncio
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from database.connection import sync_engine
from database.models import Base
# In-memory SQLite: fully isolated, no disk state between test sessions # In-memory SQLite: fully isolated, no disk state between test sessions
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session", autouse=True)
def initialize_test_database():
Base.metadata.create_all(bind=sync_engine)
yield
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def async_engine(): async def async_engine():
from database.models import Base from database.models import Base

View File

@@ -123,7 +123,7 @@ def test_admin_logs_returns_200_and_shape(client_with_admin, admin_token):
def test_admin_logs_no_original_filename_in_response(client_with_admin, admin_token): def test_admin_logs_no_original_filename_in_response(client_with_admin, admin_token):
"""NFR11/NFR16: response must never contain original_filename or document content.""" """NFR11/NFR16: response must never contain original_filename or document content."""
row = _make_mock_translation(original_filename="sensitive.docx") row = _make_mock_translation(original_filename="sensitive.docx")
with patch("routes.admin_routes.get_sync_session") as mock_get_session: with patch("database.connection.get_sync_session") as mock_get_session:
session_mock = MagicMock() session_mock = MagicMock()
mock_get_session.return_value.__enter__.return_value = session_mock mock_get_session.return_value.__enter__.return_value = session_mock
mock_get_session.return_value.__exit__.return_value = None mock_get_session.return_value.__exit__.return_value = None

View File

@@ -200,7 +200,7 @@ def app_client_for_quota(tmp_path, monkeypatch, admin_password):
monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
monkeypatch.setattr(tier_quota_mod, "_async_redis", None) monkeypatch.setattr(tier_quota_mod, "_get_async_redis", lambda: None)
monkeypatch.setenv("REDIS_URL", "") monkeypatch.setenv("REDIS_URL", "")
_memory_usage.clear() _memory_usage.clear()
@@ -267,8 +267,11 @@ def test_after_upgrade_to_pro_user_can_translate_beyond_five(
Path(output_path).write_bytes(b"dummy") Path(output_path).write_bytes(b"dummy")
with patch( with patch(
"translators.excel_translator.excel_translator.translate_file", "routes.translate_routes.ExcelTranslator.translate_file",
side_effect=_fake_translate, side_effect=_fake_translate,
), patch(
"routes.translate_routes.ExcelTranslator.get_translation_stats",
return_value={"attempted": 1, "changed": 1},
): ):
for _ in range(6): for _ in range(6):
with open(minimal_xlsx, "rb") as f: with open(minimal_xlsx, "rb") as f:
@@ -333,8 +336,11 @@ def test_after_downgrade_to_free_quota_five_applies(
Path(output_path).write_bytes(b"dummy") Path(output_path).write_bytes(b"dummy")
with patch( with patch(
"translators.excel_translator.excel_translator.translate_file", "routes.translate_routes.ExcelTranslator.translate_file",
side_effect=_fake_translate, side_effect=_fake_translate,
), patch(
"routes.translate_routes.ExcelTranslator.get_translation_stats",
return_value={"attempted": 1, "changed": 1},
): ):
for _ in range(5): for _ in range(5):
with open(minimal_xlsx, "rb") as f: with open(minimal_xlsx, "rb") as f:
@@ -350,6 +356,8 @@ def test_after_downgrade_to_free_quota_five_applies(
data={"target_lang": "fr", "provider": "google"}, data={"target_lang": "fr", "provider": "google"},
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
) )
import time
time.sleep(0.5)
client.patch( client.patch(
f"{ADMIN_USERS_PATCH}/{user_id}", f"{ADMIN_USERS_PATCH}/{user_id}",
json={"plan": "free"}, json={"plan": "free"},

View File

@@ -19,11 +19,15 @@ from middleware import tier_quota as tier_quota_mod
from middleware.tier_quota import ( from middleware.tier_quota import (
TierQuotaService, TierQuotaService,
QuotaResult, QuotaResult,
FREE_TIER_DAILY_LIMIT, FREE_TIER_MONTHLY_LIMIT as FREE_TIER_DAILY_LIMIT,
_memory_usage, _memory_usage,
_seconds_until_midnight_utc,
) )
def _seconds_until_midnight_utc():
from middleware.tier_quota import _seconds_until_next_month
return _seconds_until_next_month()
# Force in-memory backend and reset state so tests are isolated # Force in-memory backend and reset state so tests are isolated
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)