feat(tiktok): publier les reels sur les comptes TikTok des magasins

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
This commit is contained in:
Claude committed 2026-08-11 14:29:38 +00:00
1 parent 2188fa458d
commit b95cd23ebc
27 files changed
+1679 -183

No files matched your search

+107
View File
@@ -12,6 +12,7 @@ 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);
@@ -31,6 +32,8 @@ export default function Settings() {
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 }>({
@@ -56,6 +59,15 @@ export default function Settings() {
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,
@@ -92,6 +104,13 @@ export default function Settings() {
}
}, [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
@@ -264,6 +283,29 @@ export default function Settings() {
},
});
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: () => {
@@ -760,6 +802,71 @@ export default function Settings() {
</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">