mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-15 19:29:03 +02:00
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule publication. Un compte TikTok vit dans social_pages comme une page Facebook : il hérite donc des permissions par utilisateur, de la planification et du calendrier. Trois particularités de l'API TikTok structurent l'implémentation : - pas de jeton saisi à la main : chaque compte passe par OAuth, et l'access token (24h) est renouvelé via le refresh token (1 an) avant chaque publication ; - la publication est asynchrone : l'upload rend un publish_id, et un poller récupère l'identifiant définitif du post ; - creator_info doit être interrogé avant chaque envoi pour ne demander qu'un niveau de confidentialité réellement autorisé sur le compte. Serveur : - service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks, suivi de statut) et routes de connexion/configuration - schéma : platform 'tiktok', refresh token et scopes sur social_pages, publish_id/publish_status sur scheduled_posts, table tiktok_config - routage par plateforme dans les deux flux de reels et le planificateur - cron de suivi des publications, token manager et analytics adaptés Client : - connexion et reconnexion des comptes TikTok depuis « Pages gérées » - sélection combinée pages Facebook / comptes TikTok à la publication - configuration de l'application TikTok dans les paramètres (admin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
892 lines
36 KiB
TypeScript
892 lines
36 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } 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";
|
|
import { SiTiktok } from "react-icons/si";
|
|
|
|
export default function Settings() {
|
|
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 [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
|
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
|
const [externalApiKey, setExternalApiKey] = useState("");
|
|
const [geminiApiKey, setGeminiApiKey] = useState("");
|
|
const [tiktokClientKey, setTiktokClientKey] = useState("");
|
|
const [tiktokClientSecret, setTiktokClientSecret] = 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'],
|
|
});
|
|
|
|
const { data: ffmpegConfig } = useQuery({
|
|
queryKey: ['/api/ffmpeg/config'],
|
|
});
|
|
|
|
const { data: tiktokConfig } = useQuery<{
|
|
configured: boolean;
|
|
clientKey: string;
|
|
hasClientSecret: boolean;
|
|
redirectUri: string;
|
|
}>({
|
|
queryKey: ['/api/tiktok/config'],
|
|
});
|
|
|
|
const { data: externalApiConfig } = useQuery({
|
|
queryKey: ['/api/settings/external-api'],
|
|
enabled: isAdmin,
|
|
});
|
|
|
|
const { data: geminiConfig } = useQuery({
|
|
queryKey: ['/api/settings/gemini'],
|
|
enabled: isAdmin,
|
|
});
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (ffmpegConfig) {
|
|
setFfmpegApiUrl((ffmpegConfig as any).apiUrl || "");
|
|
}
|
|
}, [ffmpegConfig]);
|
|
|
|
useEffect(() => {
|
|
if (tiktokConfig) {
|
|
// Le client secret n'est jamais renvoyé par l'API : il reste vide
|
|
setTiktokClientKey(tiktokConfig.clientKey || "");
|
|
}
|
|
}, [tiktokConfig]);
|
|
|
|
useEffect(() => {
|
|
if (geminiConfig) {
|
|
// Don't pre-fill - user must enter key manually
|
|
}
|
|
}, [geminiConfig]);
|
|
|
|
// Vérifier si une config OpenRouter existe déjà
|
|
const hasExistingConfig = !!openrouterConfig;
|
|
const hasExistingFfmpegConfig = !!ffmpegConfig;
|
|
const hasExistingGeminiConfig = !!(geminiConfig as any)?.configured;
|
|
|
|
const saveCloudinaryMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = {
|
|
cloudName,
|
|
endpointUrl: endpointUrl || undefined,
|
|
publicUrl: publicUrl || undefined,
|
|
};
|
|
|
|
// N'inclure apiKey et apiSecret que s'ils ne sont pas vides
|
|
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(""); // Clear the API key after saving
|
|
setApiSecret(""); // Clear the secret after saving
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de sauvegarder la configuration",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const saveOpenrouterMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = {
|
|
model: openrouterModel,
|
|
systemPrompt: openrouterSystemPrompt,
|
|
};
|
|
|
|
// N'inclure apiKey que s'il n'est pas vide
|
|
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(""); // Clear the API key after saving
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de sauvegarder la configuration OpenRouter",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const saveGeminiMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = {};
|
|
if (geminiApiKey && geminiApiKey.trim() !== "") {
|
|
payload.apiKey = geminiApiKey;
|
|
}
|
|
return apiRequest('POST', '/api/settings/gemini', payload);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/settings/gemini'] });
|
|
toast({
|
|
title: "Configuration sauvegardée",
|
|
description: "Votre clé API Gemini a été enregistrée",
|
|
});
|
|
setGeminiApiKey("");
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de sauvegarder la configuration Gemini",
|
|
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 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 saveTiktokMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = { clientKey: tiktokClientKey.trim() };
|
|
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
|
|
if (tiktokClientSecret.trim() !== "") {
|
|
payload.clientSecret = tiktokClientSecret.trim();
|
|
}
|
|
return apiRequest('PUT', '/api/tiktok/config', payload);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/tiktok/config'] });
|
|
setTiktokClientSecret("");
|
|
toast({ title: "Configuration TikTok enregistrée", description: "Vous pouvez maintenant connecter les comptes TikTok de vos magasins" });
|
|
},
|
|
onError: async (error: any) => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error?.message || "Impossible d'enregistrer la configuration TikTok",
|
|
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 saveFfmpegMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload: any = {
|
|
apiUrl: ffmpegApiUrl,
|
|
};
|
|
|
|
// N'inclure apiKey que s'il n'est pas vide
|
|
if (ffmpegApiKey && ffmpegApiKey.trim() !== "") {
|
|
payload.apiKey = ffmpegApiKey;
|
|
}
|
|
|
|
return apiRequest('POST', '/api/ffmpeg/config', payload);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/ffmpeg/config'] });
|
|
toast({
|
|
title: "Configuration sauvegardée",
|
|
description: "Vos paramètres FFmpeg ont été enregistrés",
|
|
});
|
|
setFfmpegApiKey(""); // Clear the API key after saving
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de sauvegarder la configuration FFmpeg",
|
|
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 lg:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<div className={`
|
|
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
|
`}>
|
|
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
|
</div>
|
|
|
|
<main className="flex-1 overflow-y-auto">
|
|
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
|
|
|
<div className="p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
|
|
<p className="text-muted-foreground mt-2">
|
|
Configurez votre application
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-8">
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Bell className="w-5 h-5" />
|
|
Notifications
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Gérez vos préférences de notification
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="notifications">Notifications activées</Label>
|
|
<p className="text-sm 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">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="autoPublish">Publication automatique</Label>
|
|
<p className="text-sm 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>
|
|
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Brain className="w-5 h-5" />
|
|
Configuration OpenRouter (IA)
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Configurez OpenRouter pour la génération de texte avec l'IA
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouterApiKey">Clé API OpenRouter</Label>
|
|
<Input
|
|
id="openrouterApiKey"
|
|
type="password"
|
|
value={openrouterApiKey}
|
|
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
|
placeholder={hasExistingConfig ? "••••••••••••••••" : "sk-or-v1-..."}
|
|
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">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">Prompt système</Label>
|
|
<Textarea
|
|
id="openrouterSystemPrompt"
|
|
value={openrouterSystemPrompt}
|
|
onChange={(e) => setOpenrouterSystemPrompt(e.target.value)}
|
|
placeholder="Instructions pour l'IA..."
|
|
rows={6}
|
|
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"
|
|
>
|
|
{saveOpenrouterMutation.isPending ? "Enregistrement..." : "Enregistrer OpenRouter"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Cloud className="w-5 h-5" />
|
|
Stockage des médias
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Les médias sont stockés localement sur le serveur
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<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>
|
|
)}
|
|
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Video className="w-5 h-5" />
|
|
Configuration FFmpeg API (Reels)
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Configurez l'API FFmpeg Docker pour le traitement des vidéos Reels
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ffmpegApiUrl">URL de l'API FFmpeg</Label>
|
|
<Input
|
|
id="ffmpegApiUrl"
|
|
type="url"
|
|
value={ffmpegApiUrl}
|
|
onChange={(e) => setFfmpegApiUrl(e.target.value)}
|
|
placeholder="https://ffmpeg.votre-domaine.com:8100"
|
|
data-testid="input-ffmpeg-api-url"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
L'URL complète de votre API FFmpeg Docker (ex: https://ffmpeg.example.com:8100)
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ffmpegApiKey">Clé API FFmpeg</Label>
|
|
<Input
|
|
id="ffmpegApiKey"
|
|
type="password"
|
|
value={ffmpegApiKey}
|
|
onChange={(e) => setFfmpegApiKey(e.target.value)}
|
|
placeholder={hasExistingFfmpegConfig ? "••••••••••••••••" : "votre-clé-api"}
|
|
data-testid="input-ffmpeg-api-key"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{hasExistingFfmpegConfig ? (
|
|
<>
|
|
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
|
</>
|
|
) : (
|
|
"Clé de sécurité X-API-Key pour accéder à l'API FFmpeg"
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveFfmpegMutation.mutate()}
|
|
disabled={
|
|
saveFfmpegMutation.isPending ||
|
|
!ffmpegApiUrl ||
|
|
(!hasExistingFfmpegConfig && !ffmpegApiKey)
|
|
}
|
|
data-testid="button-save-ffmpeg"
|
|
className="w-full"
|
|
>
|
|
{saveFfmpegMutation.isPending ? "Enregistrement..." : "Enregistrer FFmpeg"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Google Gemini TTS */}
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Mic className="w-5 h-5" />
|
|
Google Gemini TTS
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
|
|
<Input
|
|
id="geminiApiKey"
|
|
type="password"
|
|
value={geminiApiKey}
|
|
onChange={(e) => setGeminiApiKey(e.target.value)}
|
|
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
|
|
data-testid="input-gemini-api-key"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{hasExistingGeminiConfig ? (
|
|
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span>
|
|
) : (
|
|
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveGeminiMutation.mutate()}
|
|
disabled={
|
|
saveGeminiMutation.isPending ||
|
|
(!hasExistingGeminiConfig && !geminiApiKey)
|
|
}
|
|
data-testid="button-save-gemini"
|
|
className="w-full"
|
|
>
|
|
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Image className="w-5 h-5" />
|
|
Logo de l'entreprise
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Uploadez votre logo pour l'ajouter aux images éditées
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{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-8 w-8"
|
|
onClick={() => deleteLogoMutation.mutate()}
|
|
disabled={deleteLogoMutation.isPending}
|
|
data-testid="button-delete-logo"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="text-center">
|
|
<Label htmlFor="logo-upload-replace" className="cursor-pointer">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={uploadLogoMutation.isPending}
|
|
data-testid="button-replace-logo"
|
|
onClick={() => document.getElementById('logo-upload-replace')?.click()}
|
|
>
|
|
<Upload className="w-4 h-4 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}
|
|
data-testid="button-upload-logo"
|
|
onClick={() => document.getElementById('logo-upload')?.click()}
|
|
>
|
|
<Upload className="w-4 h-4 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 MinIO ci-dessus
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Plug className="w-5 h-5" />
|
|
API externe
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Clé d'accès pour créer et programmer des publications via l'API externe (<code className="text-xs bg-muted px-1 rounded">POST /api/v1/publish</code>)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{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é API configurée et active</span>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => deleteExternalApiMutation.mutate()}
|
|
disabled={deleteExternalApiMutation.isPending}
|
|
>
|
|
{deleteExternalApiMutation.isPending ? "Suppression..." : "Révoquer"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="externalApiKey">
|
|
{hasExistingExternalApiConfig ? "Nouvelle clé API (remplace l'actuelle)" : "Clé API"}
|
|
</Label>
|
|
<Input
|
|
id="externalApiKey"
|
|
type="password"
|
|
value={externalApiKey}
|
|
onChange={(e) => setExternalApiKey(e.target.value)}
|
|
placeholder={hasExistingExternalApiConfig ? "Nouvelle clé pour remplacer l'actuelle" : "Générez une clé sécurisée"}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Utilisez cette clé dans le header <code className="bg-muted px-1 rounded">X-API-Key</code> de vos requêtes
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveExternalApiMutation.mutate()}
|
|
disabled={saveExternalApiMutation.isPending || !externalApiKey.trim()}
|
|
className="w-full"
|
|
>
|
|
{saveExternalApiMutation.isPending ? "Enregistrement..." : hasExistingExternalApiConfig ? "Remplacer la clé" : "Enregistrer la clé"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<SiTiktok className="w-5 h-5" />
|
|
TikTok
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
|
|
connecter les comptes TikTok de chaque magasin depuis la page « Pages gérées ».
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{tiktokConfig?.configured && (
|
|
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
|
<Key className="w-4 h-4" />
|
|
<span>Application TikTok configurée</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tiktokClientKey">Client key</Label>
|
|
<Input
|
|
id="tiktokClientKey"
|
|
value={tiktokClientKey}
|
|
onChange={(e) => setTiktokClientKey(e.target.value)}
|
|
placeholder="awxxxxxxxxxxxxxx"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tiktokClientSecret">
|
|
{tiktokConfig?.hasClientSecret ? "Nouveau client secret (laisser vide pour conserver l'actuel)" : "Client secret"}
|
|
</Label>
|
|
<Input
|
|
id="tiktokClientSecret"
|
|
type="password"
|
|
value={tiktokClientSecret}
|
|
onChange={(e) => setTiktokClientSecret(e.target.value)}
|
|
placeholder="••••••••••••••••"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>URL de redirection à déclarer chez TikTok</Label>
|
|
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
|
|
{tiktokConfig?.redirectUri || `${window.location.origin}/api/tiktok/callback`}
|
|
</code>
|
|
<p className="text-xs text-muted-foreground">
|
|
Tant que l'application n'a pas passé l'audit TikTok, les vidéos publiées
|
|
restent privées (visibles du seul propriétaire du compte).
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => saveTiktokMutation.mutate()}
|
|
disabled={saveTiktokMutation.isPending || !tiktokClientKey.trim()}
|
|
className="w-full"
|
|
>
|
|
{saveTiktokMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
|
<CardHeader className="p-6">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Shield className="w-5 h-5" />
|
|
Sécurité
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Paramètres de sécurité et confidentialité
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground">
|
|
Les paramètres de sécurité avancés seront disponibles prochainement.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|