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:
Claude committed 2026-09-10 06:11:43 +00:00
1 parent 55e502aa8d
commit d7f002a64f
13 files changed
+1385 -249

No files matched your search

+30 -97
View File
@@ -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();