Files
office_translator/frontend/src/app/admin/settings/page.tsx
sepehr 52748ee653
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m57s
feat(pricing): remove DeepL from all user-facing surfaces
Pricing: 'deepl' removed from plan provider lists and the comparison
table row. Landing and pricing copy (13 locales) no longer mention
DeepL (65 values cleaned, 39 dead keys deleted: pricing.comparison.deepl,
providerTheme.classic.deepl.*).

Providers: available-provider responses are filtered so a DeepL entry
from the backend can never surface in the engine picker or the services
page; DeepL theme entry and static lib/api.ts list entry removed.

Admin: DeepL config card, type fields, defaults and fallback-chain
mentions removed; stats/chart/status maps and mock data cleaned.

Backend adapter untouched — DeepL is invisible app-wide via the UI
filter; removing the Python adapter itself is a separate step if wanted.

Verified: build exit 0, vitest 9/9, eslint clean on touched files,
zero 'deepl' occurrences outside the two intentional UI filters.
2026-08-30 22:20:53 +02:00

878 lines
35 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound, Mail } 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 { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNotification } from "@/components/ui/notification";
import { ModelCombobox, ModelOption } from "@/components/ui/model-combobox";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
interface ProviderConfig {
enabled: boolean;
api_key?: string;
base_url?: string;
model?: string;
timeout?: number;
max_retries?: number;
}
interface SmtpConfig {
enabled: boolean;
host?: string;
port: number;
username?: string;
password?: string;
from_email?: string;
use_tls: boolean;
}
interface SettingsConfig {
google: ProviderConfig;
google_cloud: ProviderConfig;
openai: ProviderConfig;
ollama: ProviderConfig;
openrouter: ProviderConfig;
openrouter_premium: ProviderConfig;
zai: ProviderConfig;
mistral: ProviderConfig;
smtp: SmtpConfig;
fallback_chain: string;
fallback_chain_classic: string;
fallback_chain_llm: string;
}
interface EnvInfo {
openai: boolean;
openrouter: boolean;
openrouter_premium: boolean;
zai: boolean;
mistral: boolean;
ollama: boolean;
google_cloud: boolean;
smtp: boolean;
}
interface OllamaModel {
name: string;
size: number;
modified_at: string;
}
const defaultConfig: SettingsConfig = {
google: { enabled: true, timeout: 30, max_retries: 3 },
google_cloud: { 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" },
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
mistral: { enabled: false, api_key: "", model: "mistral-ocr-latest", timeout: 180 },
smtp: { enabled: false, host: "", port: 587, username: "", password: "", from_email: "", use_tls: true },
fallback_chain: "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai",
fallback_chain_classic: "google,google_cloud",
fallback_chain_llm: "openrouter,openrouter_premium,openai,deepseek,zai",
};
const defaultEnvInfo: EnvInfo = {
openai: false,
openrouter: false,
openrouter_premium: false,
zai: false,
mistral: false,
ollama: false,
google_cloud: false,
smtp: false,
};
export default function AdminSettingsPage() {
const [config, setConfig] = useState<SettingsConfig>(defaultConfig);
const [envInfo, setEnvInfo] = useState<EnvInfo>(defaultEnvInfo);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [testResults, setTestResults] = useState<Record<string, "ok" | "error" | "testing" | "idle">>({});
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [openaiModels, setOpenaiModels] = useState<ModelOption[]>([]);
const [openrouterModels, setOpenrouterModels] = useState<ModelOption[]>([]);
const [zaiModels, setZaiModels] = useState<ModelOption[]>([]);
const [loadingModelsProvider, setLoadingModelsProvider] = useState<string | null>(null);
const [isSendingTestEmail, setIsSendingTestEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = useState<"idle" | "ok" | "error">("idle");
const [testEmailMessage, setTestEmailMessage] = useState<string>("");
const { success, error, info } = useNotification();
useEffect(() => {
loadConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
const loadConfig = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (response.ok) {
const envelope = await response.json();
// API returns { data: {...settings...}, env_info: {...}, meta: {} }
const payload = envelope.data ?? envelope;
setConfig({ ...defaultConfig, ...payload });
if (envelope.env_info) {
setEnvInfo({ ...defaultEnvInfo, ...envelope.env_info });
}
} else {
error({ title: "Erreur de chargement", description: `HTTP ${response.status} — vérifiez votre token admin.` });
}
} catch (e) {
error({ title: "Erreur réseau", description: "Impossible de contacter le backend." });
console.error("Failed to load settings:", e);
} finally {
setIsLoading(false);
}
};
const saveConfig = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (response.ok) {
success({ title: "✅ Configuration sauvegardée", description: "Les paramètres ont été enregistrés avec succès." });
} else {
const body = await response.json().catch(() => ({}));
error({ title: "Erreur de sauvegarde", description: body.detail || `HTTP ${response.status}` });
}
} catch (e) {
error({ title: "Erreur réseau", description: "Impossible de contacter le backend pour la sauvegarde." });
} finally {
setIsSaving(false);
}
};
const testProvider = async (provider: string) => {
setTestResults((prev) => ({ ...prev, [provider]: "testing" }));
setTestMessages((prev) => ({ ...prev, [provider]: "" }));
try {
// For SMTP, send current form values so unsaved changes are tested
const smtpBody = provider === "smtp"
? { body: JSON.stringify(config.smtp) }
: {};
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
{
method: "POST",
headers: {
Authorization: `Bearer ${getToken()}`,
...(provider === "smtp" ? { "Content-Type": "application/json" } : {}),
},
...smtpBody,
}
);
const data = await response.json();
if (data.available) {
setTestResults((prev) => ({ ...prev, [provider]: "ok" }));
const detail = data.test_result || data.usage || data.models_count !== undefined
? `Connexion OK${data.models_count !== undefined ? `${data.models_count} modèles` : ""}${data.test_result ? ` — "${data.test_result}"` : ""}`
: "Connexion OK";
setTestMessages((prev) => ({ ...prev, [provider]: detail }));
} else {
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
setTestMessages((prev) => ({ ...prev, [provider]: data.error || "Échec" }));
}
} catch (e) {
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
setTestMessages((prev) => ({ ...prev, [provider]: "Erreur réseau" }));
}
};
const fetchOllamaModels = async () => {
setIsLoadingModels(true);
try {
const url = config.ollama.base_url
? `${API_BASE}/api/v1/admin/providers/ollama/models?base_url=${encodeURIComponent(config.ollama.base_url)}`
: `${API_BASE}/api/v1/admin/providers/ollama/models`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (response.ok) {
const data = await response.json();
setOllamaModels(data.data || []);
if (data.data?.length > 0 && !config.ollama.model) {
updateProvider("ollama", { model: data.data[0].name });
}
info({ title: `${data.data?.length || 0} modèles Ollama trouvés` });
} else {
error({ title: "Ollama inaccessible", description: "Vérifiez que Ollama tourne sur l'URL configurée." });
}
} catch (e) {
error({ title: "Erreur Ollama", description: "Impossible de contacter Ollama." });
} finally {
setIsLoadingModels(false);
}
};
const fetchModels = async (provider: string) => {
setLoadingModelsProvider(provider);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/models`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
);
if (response.ok) {
const data = await response.json();
const models: ModelOption[] = data.data || [];
if (provider === "openai") setOpenaiModels(models);
else if (provider === "openrouter") setOpenrouterModels(models);
else if (provider === "zai") setZaiModels(models);
info({ title: `${models.length} modèles ${provider} trouvés` });
} else {
const body = await response.json().catch(() => ({}));
error({ title: `Erreur ${provider}`, description: body.message || `HTTP ${response.status}` });
}
} catch (e) {
error({ title: `Erreur ${provider}`, description: "Impossible de contacter le serveur." });
} finally {
setLoadingModelsProvider(null);
}
};
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm" | "smtp">;
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
[provider]: { ...prev[provider], ...updates } as ProviderConfig,
}));
};
const updateSmtp = (updates: Partial<SmtpConfig>) => {
setConfig((prev) => ({
...prev,
smtp: { ...prev.smtp, ...updates },
}));
};
const sendTestEmail = async () => {
setIsSendingTestEmail(true);
setTestEmailResult("idle");
setTestEmailMessage("");
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/smtp/test-send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config.smtp),
}
);
const data = await response.json();
if (data.available) {
setTestEmailResult("ok");
setTestEmailMessage(data.test_result || "Email envoyé avec succès");
} else {
setTestEmailResult("error");
setTestEmailMessage(data.error || "Échec de l'envoi");
}
} catch {
setTestEmailResult("error");
setTestEmailMessage("Erreur réseau");
} finally {
setIsSendingTestEmail(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<Settings className="w-5 h-5 text-purple-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Paramètres des providers</h1>
<p className="text-sm text-muted-foreground">
Configurez les clés API. Les clés peuvent aussi être définies dans le fichier <code>.env</code>.
</p>
</div>
</div>
<div className="grid gap-4">
<ProviderCard
title="Google Translate"
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")}
testResult={testResults.google ?? "idle"}
testMessage={testMessages.google}
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="OpenAI"
description="Traductions GPT-4. Obtenez une clé sur platform.openai.com"
enabled={config.openai.enabled}
onToggle={(enabled) => updateProvider("openai", { enabled })}
onTest={() => testProvider("openai")}
testResult={testResults.openai ?? "idle"}
testMessage={testMessages.openai}
envKeySet={envInfo.openai}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="openai-key">Clé API</Label>
<Input
id="openai-key"
type="password"
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
value={config.openai.api_key || ""}
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="openai-model">Modèle</Label>
<ModelCombobox
value={config.openai.model || ""}
onChange={(v) => updateProvider("openai", { model: v })}
models={openaiModels}
isLoading={loadingModelsProvider === "openai"}
onFetchModels={() => fetchModels("openai")}
providerLabel="OpenAI"
placeholder="gpt-4o"
/>
</div>
</div>
</ProviderCard>
<ProviderCard
title="Ollama"
description="LLM local. Nécessite Ollama en cours d'exécution."
enabled={config.ollama.enabled}
onToggle={(enabled) => updateProvider("ollama", { enabled })}
onTest={() => testProvider("ollama")}
testResult={testResults.ollama ?? "idle"}
testMessage={testMessages.ollama}
envKeySet={envInfo.ollama}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ollama-url">URL de base</Label>
<Input
id="ollama-url"
placeholder={envInfo.ollama ? "URL configurée dans .env" : "http://localhost:11434"}
value={config.ollama.base_url || ""}
onChange={(e) => updateProvider("ollama", { base_url: e.target.value })}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="ollama-model">Modèle</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchOllamaModels}
disabled={isLoadingModels}
className="h-7 px-2 text-xs"
>
{isLoadingModels ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ms-1">Récupérer les modèles</span>
</Button>
</div>
{ollamaModels.length > 0 ? (
<Select
value={config.ollama.model || ""}
onValueChange={(value) => updateProvider("ollama", { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un modèle" />
</SelectTrigger>
<SelectContent>
{ollamaModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.name}
{model.size > 0 && (
<span className="ms-2 text-xs text-muted-foreground">
({(model.size / 1e9).toFixed(1)} GB)
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="ollama-model"
placeholder="llama3"
value={config.ollama.model || ""}
onChange={(e) => updateProvider("ollama", { model: e.target.value })}
/>
)}
{ollamaModels.length === 0 && (
<p className="text-xs text-muted-foreground">
Cliquez sur &quot;Récupérer les modèles&quot; pour charger la liste depuis Ollama.
</p>
)}
</div>
</div>
</ProviderCard>
<ProviderCard
title="Traduction IA Essentielle"
description="Affichée aux utilisateurs comme 'Traduction IA Essentielle'. Modèles économiques recommandés : deepseek/deepseek-chat, google/gemini-2.0-flash, meta-llama/llama-3.3-70b-instruct. Clé API : openrouter.ai"
enabled={config.openrouter.enabled}
onToggle={(enabled) => updateProvider("openrouter", { enabled })}
onTest={() => testProvider("openrouter")}
testResult={testResults.openrouter ?? "idle"}
testMessage={testMessages.openrouter}
envKeySet={envInfo.openrouter}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="openrouter-key">Clé API OpenRouter</Label>
<Input
id="openrouter-key"
type="password"
placeholder={envInfo.openrouter ? "Clé configurée dans .env (partagée avec Premium)" : "sk-or-..."}
value={config.openrouter.api_key || ""}
onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
<ModelCombobox
value={config.openrouter.model || ""}
onChange={(v) => updateProvider("openrouter", { model: v })}
models={openrouterModels}
isLoading={loadingModelsProvider === "openrouter"}
onFetchModels={() => fetchModels("openrouter")}
providerLabel="OpenRouter"
placeholder="deepseek/deepseek-chat"
/>
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~0.04/doc)</p>
</div>
</div>
</ProviderCard>
<ProviderCard
title="Traduction IA Premium"
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-sonnet-4.6, google/gemini-3.5-pro. Partage la même clé OpenRouter."
enabled={config.openrouter_premium.enabled}
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
onTest={() => testProvider("openrouter_premium")}
testResult={testResults.openrouter_premium ?? "idle"}
testMessage={testMessages.openrouter_premium}
envKeySet={envInfo.openrouter_premium}
>
<div className="space-y-2">
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
<ModelCombobox
value={config.openrouter_premium.model || ""}
onChange={(v) => updateProvider("openrouter_premium", { model: v })}
models={openrouterModels}
isLoading={loadingModelsProvider === "openrouter"}
onFetchModels={() => fetchModels("openrouter")}
providerLabel="OpenRouter"
placeholder="anthropic/claude-sonnet-4.6"
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>anthropic/claude-sonnet-4.6</code> (~0.20/doc) ou <code>openai/gpt-4o</code> (~0.30/doc)
</p>
</div>
</ProviderCard>
<ProviderCard
title="z.AI / xAI Grok"
description="Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai"
enabled={config.zai.enabled}
onToggle={(enabled) => updateProvider("zai", { enabled })}
onTest={() => testProvider("zai")}
testResult={testResults.zai ?? "idle"}
testMessage={testMessages.zai}
envKeySet={envInfo.zai}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="zai-key">Clé API</Label>
<Input
id="zai-key"
type="password"
placeholder={envInfo.zai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "xai-..."}
value={config.zai.api_key || ""}
onChange={(e) => updateProvider("zai", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="zai-model">Modèle</Label>
<ModelCombobox
value={config.zai.model || ""}
onChange={(v) => updateProvider("zai", { model: v })}
models={zaiModels}
isLoading={loadingModelsProvider === "zai"}
onFetchModels={() => fetchModels("zai")}
providerLabel="xAI"
placeholder="grok-2-1212"
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="zai-url">URL de base</Label>
<Input
id="zai-url"
placeholder="https://api.x.ai/v1"
value={config.zai.base_url || ""}
onChange={(e) => updateProvider("zai", { base_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Par défaut : <code>https://api.x.ai/v1</code> — à changer uniquement si vous utilisez un proxy.
</p>
</div>
</ProviderCard>
<ProviderCard
title="OCR Mistral (PDF scannés)"
description="Extraction du texte des PDF scannés (pages image) avant traduction. Sans clé, ces fichiers sont refusés avec un message clair. ~1 € / 1 000 pages. Clé : console.mistral.ai"
enabled={config.mistral.enabled}
onToggle={(enabled) => updateProvider("mistral", { enabled })}
onTest={() => testProvider("mistral")}
testResult={testResults.mistral ?? "idle"}
testMessage={testMessages.mistral}
envKeySet={envInfo.mistral}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="mistral-key">Clé API Mistral</Label>
<Input
id="mistral-key"
type="password"
placeholder={envInfo.mistral ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Clé console.mistral.ai"}
value={config.mistral.api_key || ""}
onChange={(e) => updateProvider("mistral", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mistral-model">Modèle OCR</Label>
<Input
id="mistral-model"
placeholder="mistral-ocr-latest"
value={config.mistral.model || ""}
onChange={(e) => updateProvider("mistral", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>mistral-ocr-latest</code>
</p>
</div>
</div>
</ProviderCard>
<Card>
<CardHeader>
<CardTitle className="text-base">Chaîne de fallback</CardTitle>
<CardDescription>Ordre de priorité pour la sélection des providers</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Mode classique (Google)</Label>
<Input
value={config.fallback_chain_classic}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
placeholder="google,google_cloud"
/>
</div>
<div className="space-y-2">
<Label>Mode LLM (Ollama/OpenAI)</Label>
<Input
value={config.fallback_chain_llm}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))}
placeholder="openrouter,openai,deepseek,zai"
/>
</div>
</CardContent>
</Card>
<Card className={config.smtp.enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-600/20 rounded-md flex items-center justify-center">
<Mail className="w-4 h-4 text-blue-400" />
</div>
<CardTitle className="text-base">Email SMTP</CardTitle>
<Badge variant={config.smtp.enabled ? "default" : "secondary"} className="text-xs">
{config.smtp.enabled ? "Activé" : "Désactivé"}
</Badge>
{envInfo.smtp && (
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
<KeyRound className="size-3" />
Config dans .env
</Badge>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => testProvider("smtp")}
disabled={testResults.smtp === "testing"}
className="h-8"
>
{testResults.smtp === "testing" ? (
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
) : testResults.smtp === "ok" ? (
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
) : testResults.smtp === "error" ? (
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
) : (
<><FlaskConical className="size-3 me-1" />Tester</>
)}
</Button>
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
</div>
</div>
<CardDescription>
Configuration du serveur SMTP pour l&apos;envoi d&apos;emails (mot de passe oublié, notifications, etc.)
</CardDescription>
{testMessages.smtp && (
<p className={`text-xs mt-1 ${testResults.smtp === "ok" ? "text-green-400" : "text-red-400"}`}>
{testMessages.smtp}
</p>
)}
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtp-host">Hôte SMTP</Label>
<Input
id="smtp-host"
placeholder={envInfo.smtp ? "Configuré dans .env" : "smtp.example.com"}
value={config.smtp.host || ""}
onChange={(e) => updateSmtp({ host: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtp-port">Port</Label>
<Input
id="smtp-port"
type="number"
placeholder="587"
value={config.smtp.port}
onChange={(e) => updateSmtp({ port: parseInt(e.target.value) || 587 })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtp-username">Nom d&apos;utilisateur</Label>
<Input
id="smtp-username"
placeholder={envInfo.smtp ? "Configuré dans .env" : "user@example.com"}
value={config.smtp.username || ""}
onChange={(e) => updateSmtp({ username: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtp-password">Mot de passe</Label>
<Input
id="smtp-password"
type="password"
placeholder="••••••••"
value={config.smtp.password || ""}
onChange={(e) => updateSmtp({ password: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtp-from">Adresse d&apos;expédition</Label>
<Input
id="smtp-from"
type="email"
placeholder={envInfo.smtp ? "Configuré dans .env" : "noreply@example.com"}
value={config.smtp.from_email || ""}
onChange={(e) => updateSmtp({ from_email: e.target.value })}
/>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={config.smtp.use_tls}
onCheckedChange={(checked) => updateSmtp({ use_tls: checked })}
/>
<Label>Utiliser TLS</Label>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Button
variant="outline"
size="sm"
onClick={sendTestEmail}
disabled={isSendingTestEmail || !config.smtp.enabled}
className="h-8"
>
{isSendingTestEmail ? (
<><Loader2 className="size-3 animate-spin me-1" />Envoi...</>
) : testEmailResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</>
) : testEmailResult === "error" ? (
<><XCircle className="size-3 text-red-500 me-1" />Échec</>
) : (
<><Mail className="size-3 me-1" />Envoyer un email de test</>
)}
</Button>
{testEmailMessage && (
<p className={`text-xs ${testEmailResult === "ok" ? "text-green-400" : "text-red-400"}`}>
{testEmailMessage}
</p>
)}
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={saveConfig} disabled={isSaving} size="lg">
{isSaving ? (
<>
<Loader2 className="me-2 size-4 animate-spin" />
Sauvegarde...
</>
) : (
<>
<Save className="me-2 size-4" />
Sauvegarder la configuration
</>
)}
</Button>
</div>
</div>
);
}
function ProviderCard({
title,
description,
enabled,
onToggle,
onTest,
testResult,
testMessage,
noApiKey = false,
envKeySet = false,
children,
}: {
title: string;
description: string;
enabled: boolean;
onToggle: (enabled: boolean) => void;
onTest: () => void;
testResult: "ok" | "error" | "testing" | "idle";
testMessage?: string;
noApiKey?: boolean;
envKeySet?: boolean;
children?: React.ReactNode;
}) {
return (
<Card className={enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base">{title}</CardTitle>
<Badge variant={enabled ? "default" : "secondary"} className="text-xs">
{enabled ? "Activé" : "Désactivé"}
</Badge>
{envKeySet && !noApiKey && (
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
<KeyRound className="size-3" />
Clé dans .env
</Badge>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onTest}
disabled={testResult === "testing"}
className="h-8"
>
{testResult === "testing" ? (
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
) : testResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
) : testResult === "error" ? (
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
) : (
<><FlaskConical className="size-3 me-1" />Tester</>
)}
</Button>
<Switch checked={enabled} onCheckedChange={onToggle} />
</div>
</div>
<CardDescription>{description}</CardDescription>
{testMessage && (
<p className={`text-xs mt-1 ${testResult === "ok" ? "text-green-400" : "text-red-400"}`}>
{testMessage}
</p>
)}
</CardHeader>
{!noApiKey && children && <CardContent className="pt-0">{children}</CardContent>}
</Card>
);
}