mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
`staleTime: Infinity` combiné à `refetchOnWindowFocus: false` empêchait toute revalidation : une donnée chargée à la première visite restait affichée pour toute la session. Or le serveur agit de son côté — le scheduler publie chaque minute, les jetons se renouvellent en tâche de fond — sans que le navigateur en soit averti. D'où la nécessité de recharger la page pour voir quoi que ce soit. Les compteurs du tableau de bord étaient le cas le plus visible : /api/stats et /api/posts n'étaient invalidés par aucune mutation de l'application, et ne pouvaient donc plus bouger du tout. - Péremption courte (30 s) et rafraîchissement au retour sur l'onglet ou sur reconnexion, en remplacement du cache éternel. - /api/stats et /api/posts, qui dérivent de tout le reste, sont invalidés depuis le MutationCache après chaque mutation réussie : aucune mutation ne peut raisonnablement se souvenir de le faire, et aucune ne le faisait. Les mutations d'authentification en sont exclues, la session étant déjà détruite quand la déconnexion aboutit. - Les trois sondages à 3 s (historique desktop et mobile, reels en cours) devenaient inutiles hors traitement en cours. Ils ne s'emballent plus que lorsqu'il y a effectivement quelque chose à suivre — une publication dont l'heure est passée, une génération en route — et retombent à 30 s sinon. La page Historique rechargeait tout l'historique 20 fois par minute. - La liste des modèles OpenRouter, servie par un appel sortant, garde une péremption d'une heure pour ne pas être redemandée à chaque retour d'onglet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
586 lines
24 KiB
TypeScript
586 lines
24 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Settings as SettingsIcon, Bell, Brain, Cloud, Image, Upload, X, Plug, Key } from "lucide-react";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import Sidebar from "@/components/sidebar";
|
|
import TopBar from "@/components/topbar";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
import { ModelCombobox } from "@/components/model-combobox";
|
|
|
|
export default function SettingsMobile() {
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [notifications, setNotifications] = useState(true);
|
|
const [autoPublish, setAutoPublish] = useState(true);
|
|
const [cloudName, setCloudName] = useState("");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [apiSecret, setApiSecret] = useState("");
|
|
const [endpointUrl, setEndpointUrl] = useState("");
|
|
const [publicUrl, setPublicUrl] = useState("");
|
|
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
|
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-sonnet-4.5");
|
|
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
const [externalApiKey, setExternalApiKey] = useState("");
|
|
const { toast } = useToast();
|
|
|
|
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
|
queryKey: ["/api/auth/session"],
|
|
retry: false,
|
|
});
|
|
|
|
const isAdmin = session?.role === "admin";
|
|
|
|
const { data: cloudinaryConfig } = useQuery({
|
|
queryKey: ['/api/cloudinary/config'],
|
|
});
|
|
|
|
const { data: openrouterConfig } = useQuery({
|
|
queryKey: ['/api/openrouter/config'],
|
|
});
|
|
|
|
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
|
|
queryKey: ['/api/openrouter/models'],
|
|
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
|
|
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
|
|
staleTime: 60 * 60 * 1000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (cloudinaryConfig) {
|
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
|
setApiKey((cloudinaryConfig as any).apiKey || "");
|
|
setEndpointUrl((cloudinaryConfig as any).endpointUrl || "");
|
|
setPublicUrl((cloudinaryConfig as any).publicUrl || "");
|
|
|
|
if ((cloudinaryConfig as any).logoUrl) {
|
|
setLogoPreview((cloudinaryConfig as any).logoUrl);
|
|
}
|
|
}
|
|
}, [cloudinaryConfig]);
|
|
|
|
useEffect(() => {
|
|
if (openrouterConfig) {
|
|
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-sonnet-4.5");
|
|
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
|
}
|
|
}, [openrouterConfig]);
|
|
|
|
const hasExistingConfig = !!openrouterConfig;
|
|
|
|
const { data: externalApiConfig } = useQuery({
|
|
queryKey: ['/api/settings/external-api'],
|
|
enabled: isAdmin,
|
|
});
|
|
|
|
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
|
|
|
|
const saveExternalApiMutation = useMutation({
|
|
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
|
setExternalApiKey("");
|
|
toast({ title: "Clé API sauvegardée", description: "La clé API externe a été enregistrée" });
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Erreur", description: "Impossible de sauvegarder la clé API", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const deleteExternalApiMutation = useMutation({
|
|
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/settings/external-api'] });
|
|
toast({ title: "Clé API supprimée", description: "L'accès à l'API externe a été révoqué" });
|
|
},
|
|
onError: () => {
|
|
toast({ title: "Erreur", description: "Impossible de supprimer la clé API", variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const saveCloudinaryMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = {
|
|
cloudName,
|
|
endpointUrl: endpointUrl || undefined,
|
|
publicUrl: publicUrl || undefined,
|
|
};
|
|
|
|
if (apiKey && apiKey.trim() !== "") {
|
|
payload.apiKey = apiKey;
|
|
}
|
|
if (apiSecret && apiSecret.trim() !== "") {
|
|
payload.apiSecret = apiSecret;
|
|
}
|
|
|
|
return apiRequest('POST', '/api/cloudinary/config', payload);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
|
toast({
|
|
title: "Configuration sauvegardée",
|
|
description: "Vos paramètres Cloudinary ont été enregistrés",
|
|
});
|
|
setApiKey("");
|
|
setApiSecret("");
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de sauvegarder la configuration",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const saveOpenrouterMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = {
|
|
model: openrouterModel,
|
|
systemPrompt: openrouterSystemPrompt,
|
|
};
|
|
|
|
if (openrouterApiKey && openrouterApiKey.trim() !== "") {
|
|
payload.apiKey = openrouterApiKey;
|
|
}
|
|
|
|
return apiRequest('POST', '/api/openrouter/config', payload);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/openrouter/config'] });
|
|
toast({
|
|
title: "Configuration sauvegardée",
|
|
description: "Vos paramètres OpenRouter ont été enregistrés",
|
|
});
|
|
setOpenrouterApiKey("");
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de sauvegarder la configuration OpenRouter",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const uploadLogoMutation = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
const formData = new FormData();
|
|
formData.append('logo', file);
|
|
|
|
const response = await fetch('/api/cloudinary/logo', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to upload logo');
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
|
toast({
|
|
title: "Logo uploadé",
|
|
description: "Votre logo a été enregistré avec succès",
|
|
});
|
|
setLogoFile(null);
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error.message || "Impossible d'uploader le logo",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const deleteLogoMutation = useMutation({
|
|
mutationFn: () => apiRequest('DELETE', '/api/cloudinary/logo', {}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
|
setLogoPreview(null);
|
|
setLogoFile(null);
|
|
toast({
|
|
title: "Logo supprimé",
|
|
description: "Votre logo a été supprimé avec succès",
|
|
});
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de supprimer le logo",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
if (!file.type.startsWith('image/')) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Veuillez sélectionner une image",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setLogoFile(file);
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setLogoPreview(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-background">
|
|
{sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-40"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<div className={`
|
|
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
`}>
|
|
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
|
</div>
|
|
|
|
<main className="flex-1 overflow-y-auto">
|
|
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
|
|
|
{/* MOBILE: Single column layout */}
|
|
<div className="p-4 space-y-6">
|
|
<div className="space-y-2">
|
|
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configurez votre application
|
|
</p>
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-5">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Bell className="w-5 h-5" />
|
|
Notifications
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Gérez vos préférences de notification
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-5 pt-0">
|
|
<div className="flex items-center justify-between p-3 rounded-lg border">
|
|
<div className="space-y-0.5 flex-1 pr-3">
|
|
<Label htmlFor="notifications" className="text-sm">Notifications activées</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Recevez des notifications pour les publications
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="notifications"
|
|
checked={notifications}
|
|
onCheckedChange={setNotifications}
|
|
data-testid="switch-notifications"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 rounded-lg border">
|
|
<div className="space-y-0.5 flex-1 pr-3">
|
|
<Label htmlFor="autoPublish" className="text-sm">Publication automatique</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Publier automatiquement à l'heure programmée
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="autoPublish"
|
|
checked={autoPublish}
|
|
onCheckedChange={setAutoPublish}
|
|
data-testid="switch-auto-publish"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* OpenRouter */}
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-5">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Brain className="w-5 h-5" />
|
|
Configuration OpenRouter (IA)
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Configurez OpenRouter pour la génération de texte avec l'IA
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-5 pt-0">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouterApiKey" className="text-sm">Clé API OpenRouter</Label>
|
|
<Input
|
|
id="openrouterApiKey"
|
|
type="password"
|
|
value={openrouterApiKey}
|
|
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
|
placeholder={hasExistingConfig ? "••••••••••••••••" : "sk-or-v1-..."}
|
|
className="min-h-[48px]"
|
|
data-testid="input-openrouter-api-key"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{hasExistingConfig ? (
|
|
<>
|
|
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
|
</>
|
|
) : (
|
|
<>
|
|
Obtenez votre clé API sur{" "}
|
|
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="underline">
|
|
openrouter.ai
|
|
</a>
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouterModel" className="text-sm">Modèle d'IA</Label>
|
|
<ModelCombobox
|
|
models={(openrouterModels as any)?.data || []}
|
|
value={openrouterModel}
|
|
onValueChange={setOpenrouterModel}
|
|
placeholder="Sélectionner un modèle"
|
|
isLoading={modelsLoading}
|
|
className="w-full"
|
|
testId="select-openrouter-model"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Choisissez le modèle d'IA pour générer vos publications
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouterSystemPrompt" className="text-sm">Prompt système</Label>
|
|
<Textarea
|
|
id="openrouterSystemPrompt"
|
|
value={openrouterSystemPrompt}
|
|
onChange={(e) => setOpenrouterSystemPrompt(e.target.value)}
|
|
placeholder="Instructions pour l'IA..."
|
|
rows={5}
|
|
data-testid="textarea-openrouter-system-prompt"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Définissez les instructions que l'IA doit suivre pour générer vos textes
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveOpenrouterMutation.mutate()}
|
|
disabled={
|
|
saveOpenrouterMutation.isPending ||
|
|
!openrouterModel ||
|
|
!openrouterSystemPrompt ||
|
|
(!hasExistingConfig && !openrouterApiKey)
|
|
}
|
|
data-testid="button-save-openrouter"
|
|
className="w-full min-h-[48px]"
|
|
>
|
|
{saveOpenrouterMutation.isPending ? "Enregistrement..." : "Enregistrer OpenRouter"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Stockage local */}
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-5">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Cloud className="w-5 h-5" />
|
|
Stockage des médias
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Les médias sont stockés localement sur le serveur
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-5 pt-0">
|
|
<div className="rounded-lg bg-muted p-4 space-y-1 text-sm text-muted-foreground">
|
|
<p>✓ Images et vidéos stockées sur le disque du serveur</p>
|
|
<p>✓ Vidéos supprimées automatiquement après publication</p>
|
|
<p>✓ Images supprimées automatiquement après 30 jours</p>
|
|
<p>✓ Vidéos non publiées supprimées après 7 jours</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Logo */}
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-5">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Image className="w-5 h-5" />
|
|
Logo de l'entreprise
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Uploadez votre logo pour l'ajouter aux images éditées
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-5 pt-0">
|
|
{logoPreview ? (
|
|
<div className="space-y-4">
|
|
<div className="relative w-full max-w-xs mx-auto">
|
|
<img
|
|
src={logoPreview}
|
|
alt="Logo"
|
|
className="w-full h-auto max-h-48 object-contain rounded-lg border-2 border-border"
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="destructive"
|
|
className="absolute top-2 right-2 h-10 w-10"
|
|
onClick={() => deleteLogoMutation.mutate()}
|
|
disabled={deleteLogoMutation.isPending}
|
|
data-testid="button-delete-logo"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full min-h-[48px]"
|
|
disabled={uploadLogoMutation.isPending}
|
|
data-testid="button-replace-logo"
|
|
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
|
>
|
|
<Upload className="w-5 h-5 mr-2" />
|
|
{uploadLogoMutation.isPending ? "Upload en cours..." : "Remplacer le logo"}
|
|
</Button>
|
|
</Label>
|
|
<input
|
|
id="logo-upload-replace"
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
handleLogoChange(e);
|
|
if (e.target.files?.[0]) {
|
|
uploadLogoMutation.mutate(e.target.files[0]);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
|
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Aucun logo uploadé. Choisissez une image PNG ou JPG.
|
|
</p>
|
|
<Label htmlFor="logo-upload" className="cursor-pointer">
|
|
<Button
|
|
variant="outline"
|
|
disabled={uploadLogoMutation.isPending || !cloudinaryConfig}
|
|
className="min-h-[48px]"
|
|
data-testid="button-upload-logo"
|
|
onClick={() => document.getElementById('logo-upload')?.click()}
|
|
>
|
|
<Upload className="w-5 h-5 mr-2" />
|
|
{uploadLogoMutation.isPending ? "Upload en cours..." : "Uploader un logo"}
|
|
</Button>
|
|
</Label>
|
|
<input
|
|
id="logo-upload"
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
handleLogoChange(e);
|
|
if (e.target.files?.[0]) {
|
|
uploadLogoMutation.mutate(e.target.files[0]);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{!cloudinaryConfig && (
|
|
<p className="text-xs text-orange-600 dark:text-orange-400 text-center">
|
|
Veuillez d'abord configurer Cloudinary ci-dessus
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{/* API externe */}
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-5">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Plug className="w-5 h-5" />
|
|
API externe
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Clé d'accès pour créer et programmer des publications via l'API externe
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-5 pt-0">
|
|
{hasExistingExternalApiConfig && (
|
|
<div className="rounded-lg bg-muted p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
|
<Key className="w-4 h-4" />
|
|
<span>Clé active</span>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => deleteExternalApiMutation.mutate()}
|
|
disabled={deleteExternalApiMutation.isPending}
|
|
>
|
|
{deleteExternalApiMutation.isPending ? "..." : "Révoquer"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="externalApiKeyMobile">
|
|
{hasExistingExternalApiConfig ? "Nouvelle clé (remplace l'actuelle)" : "Clé API"}
|
|
</Label>
|
|
<Input
|
|
id="externalApiKeyMobile"
|
|
type="password"
|
|
value={externalApiKey}
|
|
onChange={(e) => setExternalApiKey(e.target.value)}
|
|
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé..." : "Entrez une clé sécurisée"}
|
|
className="min-h-[48px]"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Header <code className="bg-muted px-1 rounded">X-API-Key</code> dans vos requêtes
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveExternalApiMutation.mutate()}
|
|
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
|
className="w-full min-h-[48px]"
|
|
>
|
|
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|