mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 11:49:30 +02:00
feat(facebook): renouvellement automatique des jetons de page
Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication en échec : le contrôle quotidien existant se contentait de marquer la page « expired ». On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de régénérer un jeton révoqué sans intervention humaine, et de se prolonger lui-même avant échéance. - facebook_config : App ID / App Secret de l'application, saisis dans les paramètres (chiffrés en base) ou par variables d'environnement. - /api/facebook : configuration, connexion OAuth, callback qui crée ou met à jour les pages, contrôle d'une page à la demande, contrôle global. - TokenManager : /debug_token pour connaître l'état réel du jeton, puis renouvellement automatique 15 jours avant l'échéance ou dès révocation ; repli sur le contrôle historique tant que l'application n'est pas configurée ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une panne réseau. Le cron passe à deux fois par jour. - Les comptes Instagram professionnels rattachés à une page reçoivent le même traitement, puisqu'ils publient avec le jeton de cette page. - L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à reconnecter. - /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur de renouvellement automatique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
This commit is contained in:
13 files changed
+1385
-249
No files matched your search
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Suivi et renouvellement des jetons d'accès.
|
||||
*
|
||||
* Un jeton de page Facebook n'affiche pas toujours de date d'expiration : celui
|
||||
* qui dérive d'une connexion OAuth est permanent, et c'est sa révocation — pas
|
||||
* son échéance — qui casse la publication. L'UI montre donc l'état réel remonté
|
||||
* par le contrôle serveur, pas seulement un décompte de jours.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Calendar, CheckCircle2, Facebook, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
|
||||
import type { ClientSocialPage } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error';
|
||||
|
||||
const STATUS_STYLES: Record<TokenStatus, { label: string; color: string; bgColor: string }> = {
|
||||
valid: { label: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' },
|
||||
expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' },
|
||||
expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' },
|
||||
error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' },
|
||||
};
|
||||
|
||||
function statusOf(page: ClientSocialPage): TokenStatus {
|
||||
return (page.tokenStatus as TokenStatus | null) ?? 'valid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance l'autorisation Facebook. La connexion se fait par navigation complète
|
||||
* du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook.
|
||||
*/
|
||||
export function ConnectFacebookButton({ className }: { className?: string }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: config } = useQuery<{ configured: boolean }>({
|
||||
queryKey: ['/api/facebook/config'],
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (config && !config.configured) {
|
||||
toast({
|
||||
title: "Facebook n'est pas configuré",
|
||||
description:
|
||||
"Renseignez l'App ID et l'App Secret de votre application Facebook dans les paramètres.",
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.location.href = '/api/facebook/connect';
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
data-testid="button-connect-facebook"
|
||||
>
|
||||
<Facebook className="w-4 h-4 mr-2" />
|
||||
Connecter des pages Facebook
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Relance le contrôle serveur, qui renouvelle le jeton s'il le peut. */
|
||||
export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await apiRequest('POST', `/api/facebook/pages/${page.id}/refresh`);
|
||||
return (await response.json()) as { status: TokenStatus; renewed: boolean; error?: string };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
toast({
|
||||
title: result.renewed
|
||||
? 'Jeton renouvelé'
|
||||
: result.status === 'valid'
|
||||
? 'Jeton valide'
|
||||
: STATUS_STYLES[result.status].label,
|
||||
description:
|
||||
result.error ||
|
||||
(result.renewed
|
||||
? `Un nouveau jeton a été généré pour ${page.pageName}.`
|
||||
: `Aucun renouvellement nécessaire pour ${page.pageName}.`),
|
||||
variant: result.status === 'valid' ? undefined : 'destructive',
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: 'Contrôle impossible',
|
||||
description: getErrorMessage(error, 'Facebook est injoignable, réessayez plus tard.'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending}
|
||||
data-testid={`button-refresh-token-${page.id}`}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloc d'état du jeton : ce que le dernier contrôle a constaté, l'échéance
|
||||
* quand il y en a une, et l'erreur à corriger le cas échéant.
|
||||
*/
|
||||
export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
|
||||
const status = statusOf(page);
|
||||
const style = STATUS_STYLES[status];
|
||||
const isTiktok = page.platform === 'tiktok';
|
||||
|
||||
// Pour TikTok, c'est l'autorisation (refresh token) qui porte l'échéance.
|
||||
const expiresAt = isTiktok ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
|
||||
|
||||
return (
|
||||
<div className={`text-xs px-3 py-2 rounded-lg space-y-1 ${style.bgColor}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'valid' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0" />
|
||||
)}
|
||||
<span className={`font-semibold ${style.color}`}>{style.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||
{expiresAt ? (
|
||||
<span>
|
||||
{isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '}
|
||||
{format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })}
|
||||
</span>
|
||||
) : page.autoRenew ? (
|
||||
<span>Sans expiration</span>
|
||||
) : (
|
||||
<span>Échéance inconnue</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{page.autoRenew && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ShieldCheck className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Renouvellement automatique actif</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{page.tokenError && (
|
||||
<p className={`${style.color} leading-relaxed`} data-testid={`text-token-error-${page.id}`}>
|
||||
{page.tokenError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{page.lastTokenCheck && (
|
||||
<p className="text-muted-foreground">
|
||||
Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bandeau global : les pages qui réclament une reconnexion manuelle. */
|
||||
export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
|
||||
const failing = pages.filter((page) => {
|
||||
const status = statusOf(page);
|
||||
return status === 'expired' || status === 'error' || status === 'expiring';
|
||||
});
|
||||
|
||||
if (failing.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-semibold text-red-600 dark:text-red-400">
|
||||
{failing.length === 1
|
||||
? 'Une page nécessite votre attention'
|
||||
: `${failing.length} pages nécessitent votre attention`}
|
||||
</p>
|
||||
{failing.map((page) => (
|
||||
<p key={page.id} className="text-muted-foreground">
|
||||
<strong>{page.pageName}</strong> — {STATUS_STYLES[statusOf(page)].label.toLowerCase()}
|
||||
{page.tokenError ? ` : ${page.tokenError}` : '.'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
@@ -12,69 +12,13 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import type { SocialPage } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
||||
if (!expiresAt) {
|
||||
return {
|
||||
status: 'unknown' as const,
|
||||
color: 'text-gray-500',
|
||||
bgColor: 'bg-gray-100',
|
||||
daysLeft: null,
|
||||
message: 'Date inconnue'
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiration = new Date(expiresAt);
|
||||
const diffTime = expiration.getTime() - now.getTime();
|
||||
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysLeft < 0) {
|
||||
return {
|
||||
status: 'expired' as const,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
daysLeft,
|
||||
message: 'Expiré'
|
||||
};
|
||||
} else if (daysLeft <= 7) {
|
||||
return {
|
||||
status: 'urgent' as const,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
daysLeft,
|
||||
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
|
||||
};
|
||||
} else if (daysLeft <= 15) {
|
||||
return {
|
||||
status: 'warning' as const,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
daysLeft,
|
||||
message: `${daysLeft} jours restants`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'good' as const,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
daysLeft,
|
||||
message: `${daysLeft} jours restants`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la
|
||||
* validité de l'autorisation, portée par le refresh token (1 an).
|
||||
*/
|
||||
function getConnectionExpiry(page: SocialPage): string | null {
|
||||
const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
|
||||
return date ? new Date(date).toISOString() : null;
|
||||
}
|
||||
import type { ClientSocialPage } from "@shared/schema";
|
||||
import {
|
||||
ConnectFacebookButton,
|
||||
RefreshTokenButton,
|
||||
TokenAlertBanner,
|
||||
TokenHealthPanel,
|
||||
} from "@/components/facebook-token-status";
|
||||
|
||||
/**
|
||||
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
||||
@@ -115,17 +59,21 @@ function ConnectTiktokButton() {
|
||||
export default function PagesManagementMobile() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
||||
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
|
||||
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
|
||||
// redirigent ici avec le résultat en paramètre)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result = params.get('tiktok');
|
||||
if (!result) return;
|
||||
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||
if (!network) return;
|
||||
|
||||
const result = params.get(network);
|
||||
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||
|
||||
toast({
|
||||
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
|
||||
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||
description: params.get('message') || undefined,
|
||||
variant: result === 'success' ? undefined : 'destructive',
|
||||
});
|
||||
@@ -134,7 +82,7 @@ export default function PagesManagementMobile() {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}, [toast]);
|
||||
|
||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
||||
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
@@ -185,6 +133,9 @@ export default function PagesManagementMobile() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TokenAlertBanner pages={pages} />
|
||||
|
||||
<ConnectFacebookButton className="w-full min-h-[48px]" />
|
||||
<ConnectTiktokButton />
|
||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||
@@ -267,32 +218,14 @@ export default function PagesManagementMobile() {
|
||||
ID: {page.pageId}
|
||||
</div>
|
||||
|
||||
{/* Token expiration status */}
|
||||
{(() => {
|
||||
const expiresAt = getConnectionExpiry(page);
|
||||
const expirationStatus = getTokenExpirationStatus(expiresAt);
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">
|
||||
{page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
|
||||
</div>
|
||||
<div className={`${expirationStatus.color} font-semibold`}>
|
||||
{expirationStatus.message}
|
||||
</div>
|
||||
{expiresAt && (
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||
<TokenHealthPanel page={page} />
|
||||
|
||||
{page.platform !== 'tiktok' && (
|
||||
<div className="flex">
|
||||
<RefreshTokenButton page={page} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{page.platform === 'tiktok' ? (
|
||||
@@ -497,7 +430,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||
function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
|
||||
+32
-102
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
|
||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
@@ -12,84 +12,32 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import type { SocialPage } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
||||
if (!expiresAt) {
|
||||
return {
|
||||
status: 'unknown' as const,
|
||||
color: 'text-gray-500',
|
||||
bgColor: 'bg-gray-100',
|
||||
daysLeft: null,
|
||||
message: 'Date inconnue'
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiration = new Date(expiresAt);
|
||||
const diffTime = expiration.getTime() - now.getTime();
|
||||
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysLeft < 0) {
|
||||
return {
|
||||
status: 'expired' as const,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
daysLeft,
|
||||
message: 'Expiré'
|
||||
};
|
||||
} else if (daysLeft <= 7) {
|
||||
return {
|
||||
status: 'urgent' as const,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
daysLeft,
|
||||
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
|
||||
};
|
||||
} else if (daysLeft <= 15) {
|
||||
return {
|
||||
status: 'warning' as const,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
daysLeft,
|
||||
message: `${daysLeft} jours restants`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'good' as const,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
daysLeft,
|
||||
message: `${daysLeft} jours restants`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la
|
||||
* validité de l'autorisation, portée par le refresh token (1 an).
|
||||
*/
|
||||
function getConnectionExpiry(page: SocialPage): string | null {
|
||||
const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
|
||||
return date ? new Date(date).toISOString() : null;
|
||||
}
|
||||
import type { ClientSocialPage } from "@shared/schema";
|
||||
import {
|
||||
ConnectFacebookButton,
|
||||
RefreshTokenButton,
|
||||
TokenAlertBanner,
|
||||
TokenHealthPanel,
|
||||
} from "@/components/facebook-token-status";
|
||||
|
||||
export default function PagesManagement() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
||||
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
|
||||
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
|
||||
// redirigent ici avec le résultat en paramètre)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result = params.get('tiktok');
|
||||
if (!result) return;
|
||||
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||
if (!network) return;
|
||||
|
||||
const result = params.get(network);
|
||||
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||
|
||||
toast({
|
||||
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
|
||||
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||
description: params.get('message') || undefined,
|
||||
variant: result === 'success' ? undefined : 'destructive',
|
||||
});
|
||||
@@ -98,7 +46,7 @@ export default function PagesManagement() {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}, [toast]);
|
||||
|
||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
||||
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
@@ -149,24 +97,29 @@ export default function PagesManagement() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectFacebookButton />
|
||||
<ConnectTiktokButton />
|
||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</div>
|
||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||
</div>
|
||||
|
||||
<TokenAlertBanner pages={pages} />
|
||||
|
||||
{/* Permissions required notice */}
|
||||
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Permissions Facebook requises pour la publication vidéo</p>
|
||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Connectez vos pages plutôt que de coller un jeton</p>
|
||||
<p className="text-muted-foreground mb-2">
|
||||
Le token de page doit inclure : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
|
||||
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
|
||||
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
|
||||
« Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
|
||||
ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
|
||||
main expire au bout de 60 jours et devra être remplacé manuellement.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via <code className="bg-muted px-1 rounded">/me/accounts</code> pour obtenir le Page Token.
|
||||
Le jeton doit couvrir : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
|
||||
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
|
||||
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,34 +199,11 @@ export default function PagesManagement() {
|
||||
ID: {page.pageId}
|
||||
</div>
|
||||
|
||||
{/* Token expiration status */}
|
||||
{(() => {
|
||||
const expiresAt = getConnectionExpiry(page);
|
||||
const expirationStatus = getTokenExpirationStatus(expiresAt);
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
|
||||
</div>
|
||||
<div className={`${expirationStatus.color} font-semibold`}>
|
||||
{expirationStatus.message}
|
||||
</div>
|
||||
{expiresAt && (
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
|
||||
<AlertTriangle className="w-4 h-4 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||
<TokenHealthPanel page={page} />
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
|
||||
{page.platform === 'tiktok' ? (
|
||||
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
||||
<Button
|
||||
@@ -495,7 +425,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||
function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
@@ -32,6 +32,8 @@ export default function Settings() {
|
||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||
const [externalApiKey, setExternalApiKey] = useState("");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
const [facebookAppId, setFacebookAppId] = useState("");
|
||||
const [facebookAppSecret, setFacebookAppSecret] = useState("");
|
||||
const [tiktokClientKey, setTiktokClientKey] = useState("");
|
||||
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
|
||||
const { toast } = useToast();
|
||||
@@ -68,6 +70,16 @@ export default function Settings() {
|
||||
queryKey: ['/api/tiktok/config'],
|
||||
});
|
||||
|
||||
const { data: facebookConfig } = useQuery<{
|
||||
configured: boolean;
|
||||
appId: string;
|
||||
hasAppSecret: boolean;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
}>({
|
||||
queryKey: ['/api/facebook/config'],
|
||||
});
|
||||
|
||||
const { data: externalApiConfig } = useQuery({
|
||||
queryKey: ['/api/settings/external-api'],
|
||||
enabled: isAdmin,
|
||||
@@ -111,6 +123,13 @@ export default function Settings() {
|
||||
}
|
||||
}, [tiktokConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (facebookConfig) {
|
||||
// L'App Secret n'est jamais renvoyé par l'API : il reste vide
|
||||
setFacebookAppId(facebookConfig.appId || "");
|
||||
}
|
||||
}, [facebookConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geminiConfig) {
|
||||
// Don't pre-fill - user must enter key manually
|
||||
@@ -283,6 +302,32 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
const saveFacebookMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = { appId: facebookAppId.trim() };
|
||||
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
|
||||
if (facebookAppSecret.trim() !== "") {
|
||||
payload.appSecret = facebookAppSecret.trim();
|
||||
}
|
||||
return apiRequest('PUT', '/api/facebook/config', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/facebook/config'] });
|
||||
setFacebookAppSecret("");
|
||||
toast({
|
||||
title: "Configuration Facebook enregistrée",
|
||||
description: "Les jetons de page se renouvellent désormais automatiquement",
|
||||
});
|
||||
},
|
||||
onError: async (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error?.message || "Impossible d'enregistrer la configuration Facebook",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const saveTiktokMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = { clientKey: tiktokClientKey.trim() };
|
||||
@@ -802,6 +847,80 @@ 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">
|
||||
<Facebook className="w-5 h-5" />
|
||||
Facebook
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Identifiants de votre application Facebook (developers.facebook.com). Ils
|
||||
permettent de connecter vos pages en un clic et de renouveler leurs jetons
|
||||
automatiquement, sans jamais recoller un token à la main.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{facebookConfig?.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 Facebook configurée</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="facebookAppId">App ID</Label>
|
||||
<Input
|
||||
id="facebookAppId"
|
||||
value={facebookAppId}
|
||||
onChange={(e) => setFacebookAppId(e.target.value)}
|
||||
placeholder="1234567890123456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="facebookAppSecret">
|
||||
{facebookConfig?.hasAppSecret ? "Nouvel App Secret (laisser vide pour conserver l'actuel)" : "App Secret"}
|
||||
</Label>
|
||||
<Input
|
||||
id="facebookAppSecret"
|
||||
type="password"
|
||||
value={facebookAppSecret}
|
||||
onChange={(e) => setFacebookAppSecret(e.target.value)}
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>URI de redirection à déclarer chez Facebook</Label>
|
||||
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
|
||||
{facebookConfig?.redirectUri || `${window.location.origin}/api/facebook/callback`}
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
À ajouter dans « Connexion Facebook › Paramètres › URI de redirection OAuth valides ».
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{facebookConfig?.scopes?.length ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Permissions demandées</Label>
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
{facebookConfig.scopes.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={() => saveFacebookMutation.mutate()}
|
||||
disabled={saveFacebookMutation.isPending || !facebookAppId.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{saveFacebookMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
|
||||
Reference in new issue
Block a user