mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +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
+19
-1
@@ -30,9 +30,27 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here
|
|||||||
MINIO_ENDPOINT=http://minio:9000
|
MINIO_ENDPOINT=http://minio:9000
|
||||||
MINIO_PUBLIC_URL=https://media.example.com
|
MINIO_PUBLIC_URL=https://media.example.com
|
||||||
|
|
||||||
# Optionnel: Configuration Facebook API
|
# ==========================================
|
||||||
|
# FACEBOOK (connexion des pages et renouvellement des jetons)
|
||||||
|
# ==========================================
|
||||||
|
# Identifiants de votre application sur https://developers.facebook.com
|
||||||
|
# Ils peuvent aussi être saisis dans l'application (Paramètres → Facebook),
|
||||||
|
# ce qui est prioritaire sur ces variables.
|
||||||
|
# Sans eux, les jetons de page doivent être collés et remplacés à la main.
|
||||||
# FACEBOOK_APP_ID=
|
# FACEBOOK_APP_ID=
|
||||||
# FACEBOOK_APP_SECRET=
|
# FACEBOOK_APP_SECRET=
|
||||||
|
#
|
||||||
|
# URI de redirection OAuth à déclarer chez Facebook.
|
||||||
|
# Par défaut : ${APP_URL}/api/facebook/callback
|
||||||
|
# FACEBOOK_REDIRECT_URI=
|
||||||
|
#
|
||||||
|
# Permissions demandées à l'autorisation. Défaut : pages_show_list,
|
||||||
|
# pages_manage_posts, pages_read_engagement, pages_manage_engagement,
|
||||||
|
# read_insights, business_management, instagram_basic, instagram_content_publish
|
||||||
|
# FACEBOOK_SCOPES=
|
||||||
|
#
|
||||||
|
# Version de la Graph API utilisée pour l'OAuth (défaut : v19.0).
|
||||||
|
# FACEBOOK_GRAPH_VERSION=
|
||||||
|
|
||||||
# Optionnel: Configuration Instagram API
|
# Optionnel: Configuration Instagram API
|
||||||
# INSTAGRAM_APP_ID=
|
# INSTAGRAM_APP_ID=
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
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 { SiTiktok } from "react-icons/si";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { SocialPage } from "@shared/schema";
|
import type { ClientSocialPage } from "@shared/schema";
|
||||||
import { format } from "date-fns";
|
import {
|
||||||
import { fr } from "date-fns/locale";
|
ConnectFacebookButton,
|
||||||
|
RefreshTokenButton,
|
||||||
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
TokenAlertBanner,
|
||||||
if (!expiresAt) {
|
TokenHealthPanel,
|
||||||
return {
|
} from "@/components/facebook-token-status";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
||||||
@@ -115,17 +59,21 @@ function ConnectTiktokButton() {
|
|||||||
export default function PagesManagementMobile() {
|
export default function PagesManagementMobile() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||||
const { toast } = useToast();
|
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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const result = params.get('tiktok');
|
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||||
if (!result) return;
|
if (!network) return;
|
||||||
|
|
||||||
|
const result = params.get(network);
|
||||||
|
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
|
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||||
description: params.get('message') || undefined,
|
description: params.get('message') || undefined,
|
||||||
variant: result === 'success' ? undefined : 'destructive',
|
variant: result === 'success' ? undefined : 'destructive',
|
||||||
});
|
});
|
||||||
@@ -134,7 +82,7 @@ export default function PagesManagementMobile() {
|
|||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +133,9 @@ export default function PagesManagementMobile() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TokenAlertBanner pages={pages} />
|
||||||
|
|
||||||
|
<ConnectFacebookButton className="w-full min-h-[48px]" />
|
||||||
<ConnectTiktokButton />
|
<ConnectTiktokButton />
|
||||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||||
@@ -267,32 +218,14 @@ export default function PagesManagementMobile() {
|
|||||||
ID: {page.pageId}
|
ID: {page.pageId}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token expiration status */}
|
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||||
{(() => {
|
<TokenHealthPanel page={page} />
|
||||||
const expiresAt = getConnectionExpiry(page);
|
|
||||||
const expirationStatus = getTokenExpirationStatus(expiresAt);
|
{page.platform !== 'tiktok' && (
|
||||||
return (
|
<div className="flex">
|
||||||
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
|
<RefreshTokenButton page={page} />
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
</div>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
{page.platform === 'tiktok' ? (
|
{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 [accessToken, setAccessToken] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|||||||
+32
-102
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
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 { SiTiktok } from "react-icons/si";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { SocialPage } from "@shared/schema";
|
import type { ClientSocialPage } from "@shared/schema";
|
||||||
import { format } from "date-fns";
|
import {
|
||||||
import { fr } from "date-fns/locale";
|
ConnectFacebookButton,
|
||||||
|
RefreshTokenButton,
|
||||||
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
TokenAlertBanner,
|
||||||
if (!expiresAt) {
|
TokenHealthPanel,
|
||||||
return {
|
} from "@/components/facebook-token-status";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PagesManagement() {
|
export default function PagesManagement() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||||
const { toast } = useToast();
|
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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const result = params.get('tiktok');
|
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||||
if (!result) return;
|
if (!network) return;
|
||||||
|
|
||||||
|
const result = params.get(network);
|
||||||
|
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
|
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||||
description: params.get('message') || undefined,
|
description: params.get('message') || undefined,
|
||||||
variant: result === 'success' ? undefined : 'destructive',
|
variant: result === 'success' ? undefined : 'destructive',
|
||||||
});
|
});
|
||||||
@@ -98,7 +46,7 @@ export default function PagesManagement() {
|
|||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,24 +97,29 @@ export default function PagesManagement() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<ConnectFacebookButton />
|
||||||
<ConnectTiktokButton />
|
<ConnectTiktokButton />
|
||||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
</div>
|
</div>
|
||||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TokenAlertBanner pages={pages} />
|
||||||
|
|
||||||
{/* Permissions required notice */}
|
{/* Permissions required notice */}
|
||||||
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
|
<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" />
|
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
<div className="text-sm">
|
<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">
|
<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>{" "}
|
« Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
|
||||||
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
|
ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
|
||||||
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
|
main expire au bout de 60 jours et devra être remplacé manuellement.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,34 +199,11 @@ export default function PagesManagement() {
|
|||||||
ID: {page.pageId}
|
ID: {page.pageId}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token expiration status */}
|
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||||
{(() => {
|
<TokenHealthPanel page={page} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
{page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
|
||||||
{page.platform === 'tiktok' ? (
|
{page.platform === 'tiktok' ? (
|
||||||
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
||||||
<Button
|
<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 [accessToken, setAccessToken] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
@@ -32,6 +32,8 @@ export default function Settings() {
|
|||||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||||
const [externalApiKey, setExternalApiKey] = useState("");
|
const [externalApiKey, setExternalApiKey] = useState("");
|
||||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||||
|
const [facebookAppId, setFacebookAppId] = useState("");
|
||||||
|
const [facebookAppSecret, setFacebookAppSecret] = useState("");
|
||||||
const [tiktokClientKey, setTiktokClientKey] = useState("");
|
const [tiktokClientKey, setTiktokClientKey] = useState("");
|
||||||
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
|
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -68,6 +70,16 @@ export default function Settings() {
|
|||||||
queryKey: ['/api/tiktok/config'],
|
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({
|
const { data: externalApiConfig } = useQuery({
|
||||||
queryKey: ['/api/settings/external-api'],
|
queryKey: ['/api/settings/external-api'],
|
||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
@@ -111,6 +123,13 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [tiktokConfig]);
|
}, [tiktokConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (facebookConfig) {
|
||||||
|
// L'App Secret n'est jamais renvoyé par l'API : il reste vide
|
||||||
|
setFacebookAppId(facebookConfig.appId || "");
|
||||||
|
}
|
||||||
|
}, [facebookConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (geminiConfig) {
|
if (geminiConfig) {
|
||||||
// Don't pre-fill - user must enter key manually
|
// 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({
|
const saveTiktokMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const payload: any = { clientKey: tiktokClientKey.trim() };
|
const payload: any = { clientKey: tiktokClientKey.trim() };
|
||||||
@@ -802,6 +847,80 @@ export default function Settings() {
|
|||||||
</Card>
|
</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 && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
<CardHeader className="p-6">
|
<CardHeader className="p-6">
|
||||||
|
|||||||
+10
-5
@@ -15,12 +15,17 @@ export function startTokenCron() {
|
|||||||
});
|
});
|
||||||
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
|
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
|
||||||
|
|
||||||
// Run every day at midnight (00:00) — Token health check
|
// Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons.
|
||||||
cron.schedule('0 0 * * *', async () => {
|
// Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour
|
||||||
console.log('[Cron] Running daily token check...');
|
// réduit d'autant la fenêtre pendant laquelle une publication échouerait.
|
||||||
await TokenManager.checkAndRefreshTokens();
|
cron.schedule('0 0,12 * * *', async () => {
|
||||||
|
console.log('[Cron] Running token check...');
|
||||||
|
const results = await TokenManager.checkAndRefreshTokens();
|
||||||
|
const renewed = results.filter(r => r.renewed).length;
|
||||||
|
const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length;
|
||||||
|
console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`);
|
||||||
});
|
});
|
||||||
console.log('[Cron] Token refresh job scheduled (0 0 * * *).');
|
console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).');
|
||||||
|
|
||||||
// Run twice daily (08:00 and 20:00) — Analytics sync for all pages
|
// Run twice daily (08:00 and 20:00) — Analytics sync for all pages
|
||||||
cron.schedule('0 8,20 * * *', async () => {
|
cron.schedule('0 8,20 * * *', async () => {
|
||||||
|
|||||||
@@ -187,6 +187,25 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "avatar_url" text;
|
ADD COLUMN IF NOT EXISTS "avatar_url" text;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// facebook_config (application développeur Facebook, globale)
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "facebook_config" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"app_id" text NOT NULL,
|
||||||
|
"app_secret" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// social_pages : renouvellement automatique des tokens Facebook
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "social_pages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "user_access_token" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp,
|
||||||
|
ADD COLUMN IF NOT EXISTS "token_error" text;
|
||||||
|
`);
|
||||||
|
|
||||||
// scheduled_posts : suivi de la publication asynchrone TikTok
|
// scheduled_posts : suivi de la publication asynchrone TikTok
|
||||||
await client.query(`
|
await client.query(`
|
||||||
ALTER TABLE "scheduled_posts"
|
ALTER TABLE "scheduled_posts"
|
||||||
|
|||||||
+20
-3
@@ -18,6 +18,7 @@ import { reelsRouter } from "./routes/reels";
|
|||||||
import { remotionRouter } from "./routes/remotion";
|
import { remotionRouter } from "./routes/remotion";
|
||||||
import { externalRouter } from "./routes/external";
|
import { externalRouter } from "./routes/external";
|
||||||
import { tiktokRouter } from "./routes/tiktok";
|
import { tiktokRouter } from "./routes/tiktok";
|
||||||
|
import { facebookRouter } from "./routes/facebook";
|
||||||
import { legalRouter } from "./routes/legal";
|
import { legalRouter } from "./routes/legal";
|
||||||
import { insertAudioTrackSchema } from "@shared/schema";
|
import { insertAudioTrackSchema } from "@shared/schema";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
@@ -475,6 +476,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
|
|
||||||
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
|
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
|
||||||
app.use("/api/tiktok", requireAuth, tiktokRouter);
|
app.use("/api/tiktok", requireAuth, tiktokRouter);
|
||||||
|
app.use("/api/facebook", requireAuth, facebookRouter);
|
||||||
|
|
||||||
// Reels & Music Routes
|
// Reels & Music Routes
|
||||||
app.use("/api", requireAuth, reelsRouter);
|
app.use("/api", requireAuth, reelsRouter);
|
||||||
@@ -1553,6 +1555,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Social pages
|
// Social pages
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue client d'une page : sans jetons, avec un indicateur de renouvellement
|
||||||
|
* automatique (vrai quand un jeton utilisateur longue durée est enregistré).
|
||||||
|
*/
|
||||||
|
function toClientPage(page: SocialPage) {
|
||||||
|
const { accessToken, userAccessToken, refreshToken, ...safe } = page;
|
||||||
|
return {
|
||||||
|
...safe,
|
||||||
|
autoRenew: page.platform === 'tiktok' ? !!refreshToken : !!userAccessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.get("/api/pages", requireAuth, async (req, res) => {
|
app.get("/api/pages", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
@@ -1572,7 +1587,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
pages = await storage.getUserAccessiblePages(userId);
|
pages = await storage.getUserAccessiblePages(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(pages);
|
// Les jetons n'ont rien à faire dans le navigateur : on ne renvoie que
|
||||||
|
// l'information dont l'UI a besoin, dont la capacité à se renouveler seule.
|
||||||
|
res.json(pages.map(toClientPage));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching pages:", error);
|
console.error("Error fetching pages:", error);
|
||||||
res.status(500).json({ error: "Failed to fetch pages" });
|
res.status(500).json({ error: "Failed to fetch pages" });
|
||||||
@@ -1602,7 +1619,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
tokenExpiresAt
|
tokenExpiresAt
|
||||||
});
|
});
|
||||||
const page = await storage.createSocialPage(pageData);
|
const page = await storage.createSocialPage(pageData);
|
||||||
res.json(page);
|
res.json(toClientPage(page));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating page:", error);
|
console.error("Error creating page:", error);
|
||||||
res.status(500).json({ error: "Failed to create page" });
|
res.status(500).json({ error: "Failed to create page" });
|
||||||
@@ -1638,7 +1655,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedPage = await storage.updateSocialPage(pageId, pageData);
|
const updatedPage = await storage.updateSocialPage(pageId, pageData);
|
||||||
res.json(updatedPage);
|
res.json(toClientPage(updatedPage));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating page:", error);
|
console.error("Error updating page:", error);
|
||||||
res.status(500).json({ error: "Failed to update page" });
|
res.status(500).json({ error: "Failed to update page" });
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Connexion et renouvellement des pages Facebook / Instagram.
|
||||||
|
*
|
||||||
|
* Une page connectée ici embarque le jeton utilisateur longue durée qui a servi
|
||||||
|
* à l'obtenir : c'est ce qui permet au `TokenManager` de régénérer son jeton de
|
||||||
|
* page tout seul, au lieu d'exiger un copier-coller depuis le Graph Explorer
|
||||||
|
* tous les deux mois.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import type { User } from '@shared/schema';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
import { facebookOAuthService } from '../services/facebook_oauth';
|
||||||
|
import { TokenManager } from '../services/token_manager';
|
||||||
|
import { encrypt } from '../utils/encryption';
|
||||||
|
import { insertFacebookConfigSchema, updateFacebookConfigSchema } from '@shared/schema';
|
||||||
|
|
||||||
|
export const facebookRouter = Router();
|
||||||
|
|
||||||
|
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
|
||||||
|
const RETURN_PATH = '/pages';
|
||||||
|
|
||||||
|
function isAdmin(req: Request): boolean {
|
||||||
|
const user = req.user as User | undefined;
|
||||||
|
return user?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReturnUrl(status: 'success' | 'error', message: string): string {
|
||||||
|
const params = new URLSearchParams({ facebook: status, message });
|
||||||
|
return `${RETURN_PATH}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État de la configuration Facebook.
|
||||||
|
* L'App Secret n'est jamais renvoyé au client.
|
||||||
|
*/
|
||||||
|
facebookRouter.get('/config', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = await storage.getFacebookConfig();
|
||||||
|
const configured = await facebookOAuthService.isConfigured();
|
||||||
|
|
||||||
|
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
|
||||||
|
// admins voient les détails de l'application développeur.
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.json({ configured });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
configured,
|
||||||
|
appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''),
|
||||||
|
hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET),
|
||||||
|
redirectUri: facebookOAuthService.getRedirectUri(),
|
||||||
|
scopes: facebookOAuthService.getScopes().split(','),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur lecture configuration:', error);
|
||||||
|
res.status(500).json({ error: 'Impossible de lire la configuration Facebook' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Enregistre les identifiants de l'application Facebook (admin). */
|
||||||
|
facebookRouter.put('/config', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await storage.getFacebookConfig();
|
||||||
|
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
|
||||||
|
const schema = existing ? updateFacebookConfigSchema : insertFacebookConfigSchema;
|
||||||
|
const data = schema.parse(req.body);
|
||||||
|
|
||||||
|
const config = await storage.upsertFacebookConfig(data);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
appId: config.appId,
|
||||||
|
redirectUri: facebookOAuthService.getRedirectUri(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ [Facebook] Erreur enregistrement configuration:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
error: error?.errors?.[0]?.message || error?.message || 'Configuration Facebook invalide',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la connexion des pages Facebook.
|
||||||
|
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
|
||||||
|
*/
|
||||||
|
facebookRouter.get('/connect', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!(await facebookOAuthService.isConfigured())) {
|
||||||
|
return res.redirect(
|
||||||
|
buildReturnUrl('error', "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protection CSRF : l'état est vérifié au retour
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
(req.session as any).facebookOAuthState = state;
|
||||||
|
|
||||||
|
const authUrl = await facebookOAuthService.buildAuthorizationUrl(state);
|
||||||
|
|
||||||
|
// La session doit être écrite avant la redirection, sinon l'état est perdu
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ [Facebook] Impossible de sauvegarder la session OAuth:', err);
|
||||||
|
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
|
||||||
|
}
|
||||||
|
res.redirect(authUrl);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur démarrage OAuth:', error);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion Facebook')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retour d'autorisation Facebook : échange le code contre un jeton utilisateur
|
||||||
|
* longue durée, puis crée ou met à jour chaque page administrée.
|
||||||
|
*/
|
||||||
|
facebookRouter.get('/callback', async (req: Request, res: Response) => {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (oauthError) {
|
||||||
|
const description = (oauthErrorDescription as string) || (oauthError as string);
|
||||||
|
return res.redirect(buildReturnUrl('error', `Autorisation Facebook refusée : ${description}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedState = (req.session as any)?.facebookOAuthState;
|
||||||
|
delete (req.session as any)?.facebookOAuthState;
|
||||||
|
|
||||||
|
if (!state || !expectedState || state !== expectedState) {
|
||||||
|
return res.redirect(buildReturnUrl('error', 'Requête de connexion Facebook invalide (état non reconnu).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook."));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userToken = await facebookOAuthService.exchangeCodeForUserToken(code);
|
||||||
|
const targets = await facebookOAuthService.listPages(userToken.accessToken);
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return res.redirect(
|
||||||
|
buildReturnUrl(
|
||||||
|
'error',
|
||||||
|
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le jeton utilisateur est la clé du renouvellement automatique : il est
|
||||||
|
// conservé chiffré, sur chaque page issue de cette autorisation.
|
||||||
|
const encryptedUserToken = encrypt(userToken.accessToken);
|
||||||
|
const existingPages = await storage.getSocialPages(user.id);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const existing = existingPages.find(
|
||||||
|
(page) => page.platform === 'facebook' && page.pageId === target.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await storage.updateSocialPage(existing.id, {
|
||||||
|
pageName: target.name,
|
||||||
|
accessToken: target.accessToken,
|
||||||
|
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas.
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
userAccessToken: encryptedUserToken,
|
||||||
|
userTokenExpiresAt: userToken.expiresAt,
|
||||||
|
followersCount: target.followersCount,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
tokenError: null,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await storage.createSocialPage({
|
||||||
|
userId: user.id,
|
||||||
|
platform: 'facebook',
|
||||||
|
pageId: target.id,
|
||||||
|
pageName: target.name,
|
||||||
|
accessToken: target.accessToken,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
userAccessToken: encryptedUserToken,
|
||||||
|
userTokenExpiresAt: userToken.expiresAt,
|
||||||
|
followersCount: target.followersCount,
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
|
||||||
|
// Les non-admins ne voient que les pages qui leur sont explicitement
|
||||||
|
// attribuées : sans cette permission, ils ne verraient pas leur propre page.
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
try {
|
||||||
|
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
||||||
|
} catch (permissionError) {
|
||||||
|
console.error('⚠️ [Facebook] Impossible de créer la permission de page:', permissionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un compte Instagram professionnel publie avec le jeton de la page Facebook
|
||||||
|
// à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il
|
||||||
|
// resterait sur un jeton saisi à la main, condamné à expirer.
|
||||||
|
for (const page of existingPages) {
|
||||||
|
if (page.platform !== 'instagram') continue;
|
||||||
|
const linked = targets.find((target) => target.instagram?.id === page.pageId);
|
||||||
|
if (!linked) continue;
|
||||||
|
|
||||||
|
await storage.updateSocialPage(page.id, {
|
||||||
|
accessToken: linked.accessToken,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
userAccessToken: encryptedUserToken,
|
||||||
|
userTokenExpiresAt: userToken.expiresAt,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
tokenError: null,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
created > 0 ? `${created} page${created > 1 ? 's' : ''} connectée${created > 1 ? 's' : ''}` : null,
|
||||||
|
updated > 0 ? `${updated} page${updated > 1 ? 's' : ''} mise${updated > 1 ? 's' : ''} à jour` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
console.log(`✅ [Facebook] ${summary} pour ${user.username}`);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('success', `${summary}. Les jetons se renouvellent désormais automatiquement.`)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur callback OAuth:', error);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion Facebook impossible')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. */
|
||||||
|
facebookRouter.post('/pages/:id/refresh', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const page = await storage.getSocialPage(req.params.id);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return res.status(404).json({ error: 'Page non trouvée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin' && page.userId !== user.id) {
|
||||||
|
const accessible = await storage.getUserAccessiblePages(user.id);
|
||||||
|
if (!accessible.some((p) => p.id === page.id)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé à cette page' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await TokenManager.checkPage(page.id);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur renouvellement du jeton:', error);
|
||||||
|
res.status(502).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Impossible de contrôler le jeton',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Relance le contrôle de toutes les pages (admin). */
|
||||||
|
facebookRouter.post('/tokens/check', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await TokenManager.checkAndRefreshTokens();
|
||||||
|
res.json({
|
||||||
|
checked: results.length,
|
||||||
|
renewed: results.filter((r) => r.renewed).length,
|
||||||
|
failing: results.filter((r) => r.status === 'expired' || r.status === 'error').length,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur contrôle global des jetons:', error);
|
||||||
|
res.status(500).json({ error: 'Contrôle des jetons impossible' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Renouvellement des tokens Facebook.
|
||||||
|
*
|
||||||
|
* Le problème que ce service résout : un token de page saisi à la main finit
|
||||||
|
* toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du
|
||||||
|
* jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle
|
||||||
|
* d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au
|
||||||
|
* moment où une publication échoue.
|
||||||
|
*
|
||||||
|
* La parade est celle recommandée par Meta :
|
||||||
|
* 1. l'utilisateur autorise l'application (OAuth) ;
|
||||||
|
* 2. le code est échangé contre un token utilisateur court ;
|
||||||
|
* 3. ce token court est échangé contre un token utilisateur longue durée
|
||||||
|
* (~60 jours) — c'est lui que l'on conserve, chiffré ;
|
||||||
|
* 4. les tokens de page dérivés d'un token utilisateur longue durée
|
||||||
|
* n'expirent pas.
|
||||||
|
*
|
||||||
|
* Conserver le token utilisateur permet de régénérer un token de page sans
|
||||||
|
* intervention humaine, et de prolonger le token utilisateur lui-même avant
|
||||||
|
* son échéance : tant que l'utilisateur ne retire pas son autorisation, la
|
||||||
|
* chaîne se renouvelle toute seule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { storage } from '../storage';
|
||||||
|
|
||||||
|
const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0';
|
||||||
|
const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
|
||||||
|
|
||||||
|
/** Permissions nécessaires pour publier et lire les statistiques d'une page. */
|
||||||
|
const DEFAULT_SCOPES = [
|
||||||
|
'pages_show_list',
|
||||||
|
'pages_manage_posts',
|
||||||
|
'pages_read_engagement',
|
||||||
|
'pages_manage_engagement',
|
||||||
|
'read_insights',
|
||||||
|
'business_management',
|
||||||
|
'instagram_basic',
|
||||||
|
'instagram_content_publish',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export interface FacebookLongLivedToken {
|
||||||
|
accessToken: string;
|
||||||
|
/** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookPageTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accessToken: string;
|
||||||
|
followersCount: number;
|
||||||
|
/** Compte Instagram professionnel rattaché à la page, s'il existe. */
|
||||||
|
instagram: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */
|
||||||
|
export interface FacebookTokenInfo {
|
||||||
|
isValid: boolean;
|
||||||
|
/** Nul = n'expire pas (cas normal d'un token de page). */
|
||||||
|
expiresAt: Date | null;
|
||||||
|
scopes: string[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FacebookAuthError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FacebookAuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FacebookOAuthService {
|
||||||
|
/**
|
||||||
|
* Identifiants de l'application Facebook (globaux à l'instance).
|
||||||
|
* La base prime sur les variables d'environnement.
|
||||||
|
*/
|
||||||
|
private async getCredentials(): Promise<{ appId: string; appSecret: string }> {
|
||||||
|
const config = await storage.getFacebookConfig();
|
||||||
|
|
||||||
|
const appId = config?.appId || process.env.FACEBOOK_APP_ID;
|
||||||
|
const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET;
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw new FacebookAuthError(
|
||||||
|
"Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " +
|
||||||
|
'votre application Facebook dans les paramètres.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { appId, appSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async isConfigured(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.getCredentials();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRedirectUri(): string {
|
||||||
|
if (process.env.FACEBOOK_REDIRECT_URI) {
|
||||||
|
return process.env.FACEBOOK_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
|
||||||
|
return `${base}/api/facebook/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getScopes(): string {
|
||||||
|
return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */
|
||||||
|
async buildAuthorizationUrl(state: string): Promise<string> {
|
||||||
|
const { appId } = await this.getCredentials();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: appId,
|
||||||
|
redirect_uri: this.getRedirectUri(),
|
||||||
|
state,
|
||||||
|
scope: this.getScopes(),
|
||||||
|
response_type: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échange le code d'autorisation contre un token utilisateur **longue durée**.
|
||||||
|
* L'étape intermédiaire (token court) n'est jamais conservée : seul le token
|
||||||
|
* longue durée rend les tokens de page permanents.
|
||||||
|
*/
|
||||||
|
async exchangeCodeForUserToken(code: string): Promise<FacebookLongLivedToken> {
|
||||||
|
const { appId, appSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', {
|
||||||
|
client_id: appId,
|
||||||
|
client_secret: appSecret,
|
||||||
|
redirect_uri: this.getRedirectUri(),
|
||||||
|
code,
|
||||||
|
}, "échange du code d'autorisation");
|
||||||
|
|
||||||
|
if (!shortLived.access_token) {
|
||||||
|
throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.extendUserToken(shortLived.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prolonge un token utilisateur. Appelé au retour d'OAuth (token court →
|
||||||
|
* ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée
|
||||||
|
* encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle.
|
||||||
|
*/
|
||||||
|
async extendUserToken(userToken: string): Promise<FacebookLongLivedToken> {
|
||||||
|
const { appId, appSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>(
|
||||||
|
'/oauth/access_token',
|
||||||
|
{
|
||||||
|
grant_type: 'fb_exchange_token',
|
||||||
|
client_id: appId,
|
||||||
|
client_secret: appSecret,
|
||||||
|
fb_exchange_token: userToken,
|
||||||
|
},
|
||||||
|
'prolongation du jeton utilisateur'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!longLived.access_token) {
|
||||||
|
throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: longLived.access_token,
|
||||||
|
// expires_in absent ou nul = jeton sans expiration connue.
|
||||||
|
expiresAt: longLived.expires_in
|
||||||
|
? new Date(Date.now() + longLived.expires_in * 1000)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages administrées par le compte, avec leur token de page.
|
||||||
|
* Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur
|
||||||
|
* longue durée : c'est le cœur du renouvellement automatique.
|
||||||
|
*/
|
||||||
|
async listPages(userToken: string): Promise<FacebookPageTarget[]> {
|
||||||
|
const response = await this.graphGet<{
|
||||||
|
data?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
access_token: string;
|
||||||
|
fan_count?: number;
|
||||||
|
followers_count?: number;
|
||||||
|
instagram_business_account?: { id: string; name?: string; username?: string };
|
||||||
|
}[];
|
||||||
|
}>(
|
||||||
|
'/me/accounts',
|
||||||
|
{
|
||||||
|
fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}',
|
||||||
|
limit: '200',
|
||||||
|
access_token: userToken,
|
||||||
|
},
|
||||||
|
'récupération des pages'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (response.data ?? []).map((page) => ({
|
||||||
|
id: page.id,
|
||||||
|
name: page.name,
|
||||||
|
accessToken: page.access_token,
|
||||||
|
followersCount: page.followers_count ?? page.fan_count ?? 0,
|
||||||
|
instagram: page.instagram_business_account
|
||||||
|
? {
|
||||||
|
id: page.instagram_business_account.id,
|
||||||
|
name:
|
||||||
|
page.instagram_business_account.username ||
|
||||||
|
page.instagram_business_account.name ||
|
||||||
|
page.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas
|
||||||
|
* quand le jeton expire : /debug_token le dit, ce qui permet de renouveler
|
||||||
|
* *avant* la panne plutôt qu'après.
|
||||||
|
*/
|
||||||
|
async inspectToken(token: string): Promise<FacebookTokenInfo> {
|
||||||
|
const { appId, appSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
const response = await this.graphGet<{
|
||||||
|
data?: {
|
||||||
|
is_valid?: boolean;
|
||||||
|
expires_at?: number;
|
||||||
|
scopes?: string[];
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'/debug_token',
|
||||||
|
{
|
||||||
|
input_token: token,
|
||||||
|
// Jeton d'application : `app_id|app_secret`, accepté par /debug_token.
|
||||||
|
access_token: `${appId}|${appSecret}`,
|
||||||
|
},
|
||||||
|
'inspection du jeton'
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response.data ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: data.is_valid === true,
|
||||||
|
// `expires_at: 0` signifie « n'expire pas » chez Meta.
|
||||||
|
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
|
||||||
|
scopes: data.scopes ?? [],
|
||||||
|
error: data.error?.message ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async graphGet<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
context: string
|
||||||
|
): Promise<T> {
|
||||||
|
const url = new URL(`${GRAPH_URL}${endpoint}`);
|
||||||
|
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url.toString());
|
||||||
|
} catch (error) {
|
||||||
|
throw new FacebookAuthError(
|
||||||
|
`Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok || payload?.error) {
|
||||||
|
const message = payload?.error?.message || `HTTP ${response.status}`;
|
||||||
|
throw new FacebookAuthError(`Facebook (${context}) : ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const facebookOAuthService = new FacebookOAuthService();
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
// crypto removed, using shared encryption utility
|
// crypto removed, using shared encryption utility
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { socialPages, tokenStatusEnum } from '../../shared/schema';
|
import { socialPages } from '../../shared/schema';
|
||||||
import { eq, and, lte } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { GraphAPIClient } from '../utils/graph_client';
|
import { GraphAPIClient } from '../utils/graph_client';
|
||||||
|
import type { SocialPage } from '../../shared/schema';
|
||||||
|
|
||||||
import { encrypt, decrypt } from '../utils/encryption';
|
import { encrypt, decrypt } from '../utils/encryption';
|
||||||
|
import { facebookOAuthService, FacebookAuthError } from './facebook_oauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé
|
||||||
|
* sans attendre : il reste largement le temps de réessayer les jours suivants
|
||||||
|
* si Facebook est indisponible au moment du contrôle.
|
||||||
|
*/
|
||||||
|
const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */
|
||||||
|
const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface TokenCheckResult {
|
||||||
|
pageId: string;
|
||||||
|
pageName: string;
|
||||||
|
status: 'valid' | 'expiring' | 'expired' | 'error';
|
||||||
|
/** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */
|
||||||
|
renewed: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service responsible for managing Facebook Page Token lifecycle
|
* Service responsible for managing Facebook Page Token lifecycle
|
||||||
@@ -30,8 +51,9 @@ export class TokenManager {
|
|||||||
/**
|
/**
|
||||||
* Checks all active pages for expiring tokens and refreshes them if needed.
|
* Checks all active pages for expiring tokens and refreshes them if needed.
|
||||||
*/
|
*/
|
||||||
static async checkAndRefreshTokens() {
|
static async checkAndRefreshTokens(): Promise<TokenCheckResult[]> {
|
||||||
console.log('[TokenManager] Starting daily token check...');
|
console.log('[TokenManager] Starting daily token check...');
|
||||||
|
const results: TokenCheckResult[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch active pages
|
// Fetch active pages
|
||||||
@@ -41,15 +63,18 @@ export class TokenManager {
|
|||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
try {
|
try {
|
||||||
await this.processPageToken(page);
|
results.push(await this.processPageToken(page));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
|
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
|
||||||
await db.update(socialPages)
|
await this.persistStatus(page.id, 'error', message);
|
||||||
.set({
|
results.push({
|
||||||
tokenStatus: "error",
|
pageId: page.id,
|
||||||
lastTokenCheck: new Date()
|
pageName: page.pageName,
|
||||||
})
|
status: 'error',
|
||||||
.where(eq(socialPages.id, page.id));
|
renewed: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,46 +82,211 @@ export class TokenManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
|
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async processPageToken(page: any) {
|
/** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */
|
||||||
|
static async checkPage(pageId: string): Promise<TokenCheckResult> {
|
||||||
|
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId));
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Page introuvable');
|
||||||
|
}
|
||||||
|
return this.processPageToken(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async processPageToken(page: SocialPage): Promise<TokenCheckResult> {
|
||||||
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
|
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
|
||||||
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
|
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
|
||||||
if (page.platform === 'tiktok') {
|
if (page.platform === 'tiktok') {
|
||||||
return await this.processTiktokToken(page);
|
return await this.processTiktokToken(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessToken = page.accessToken;
|
|
||||||
|
|
||||||
// Attempt to decrypt. If fails, assume it's plain text (migration phase)
|
|
||||||
try {
|
|
||||||
if (accessToken.includes(':')) {
|
|
||||||
accessToken = this.decrypt(accessToken);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not encrypted or format error, proceed with original
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[TokenManager] Checking page: ${page.pageName}`);
|
console.log(`[TokenManager] Checking page: ${page.pageName}`);
|
||||||
|
|
||||||
|
const accessToken = this.readToken(page.accessToken);
|
||||||
|
|
||||||
|
// Sans application Facebook configurée, on ne peut ni interroger
|
||||||
|
// /debug_token ni régénérer quoi que ce soit : on se rabat sur le
|
||||||
|
// contrôle historique (le jeton répond-il encore ?).
|
||||||
|
if (!(await facebookOAuthService.isConfigured())) {
|
||||||
|
return this.legacyCheck(page, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await facebookOAuthService.inspectToken(accessToken);
|
||||||
|
} catch (error) {
|
||||||
|
// Facebook injoignable : on ne dégrade pas le statut sur un incident
|
||||||
|
// réseau, sinon toute l'UI passerait au rouge le temps d'une panne.
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`);
|
||||||
|
return this.legacyCheck(page, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = info.expiresAt;
|
||||||
|
const expiringSoon =
|
||||||
|
expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS;
|
||||||
|
|
||||||
|
if (info.isValid && !expiringSoon) {
|
||||||
|
// Le jeton de page tient : on en profite pour prolonger le jeton
|
||||||
|
// utilisateur s'il approche de son échéance, car c'est lui qui
|
||||||
|
// permettra le prochain renouvellement.
|
||||||
|
await this.extendUserTokenIfNeeded(page);
|
||||||
|
await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt });
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = info.isValid
|
||||||
|
? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}`
|
||||||
|
: info.error || 'Le jeton a été invalidé par Facebook';
|
||||||
|
console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`);
|
||||||
|
|
||||||
|
// Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le
|
||||||
|
// jeton utilisateur longue durée au moment de la connexion OAuth.
|
||||||
|
try {
|
||||||
|
const renewed = await this.renewFromUserToken(page);
|
||||||
|
if (renewed) {
|
||||||
|
console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`);
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`);
|
||||||
|
await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, {
|
||||||
|
tokenExpiresAt: expiresAt,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
pageId: page.id,
|
||||||
|
pageName: page.pageName,
|
||||||
|
status: info.isValid ? 'expiring' : 'expired',
|
||||||
|
renewed: false,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de jeton utilisateur en base : la page a été ajoutée à la main,
|
||||||
|
// seule une reconnexion peut la remettre d'aplomb.
|
||||||
|
const manual =
|
||||||
|
`${reason}. Reconnectez la page via « Connecter des pages Facebook » ` +
|
||||||
|
'pour activer le renouvellement automatique.';
|
||||||
|
const status = info.isValid ? 'expiring' : 'expired';
|
||||||
|
await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt });
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Régénère le jeton de page à partir du jeton utilisateur longue durée.
|
||||||
|
* Renvoie false quand la page n'a pas de jeton utilisateur enregistré.
|
||||||
|
*/
|
||||||
|
private static async renewFromUserToken(page: SocialPage): Promise<boolean> {
|
||||||
|
if (!page.userAccessToken) return false;
|
||||||
|
|
||||||
|
const storedUserToken = this.readToken(page.userAccessToken);
|
||||||
|
|
||||||
|
// On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour
|
||||||
|
// ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle.
|
||||||
|
// Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne.
|
||||||
|
let userToken = storedUserToken;
|
||||||
|
let userTokenExpiresAt = page.userTokenExpiresAt ?? null;
|
||||||
|
try {
|
||||||
|
const extended = await facebookOAuthService.extendUserToken(storedUserToken);
|
||||||
|
userToken = extended.accessToken;
|
||||||
|
userTokenExpiresAt = extended.expiresAt;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await facebookOAuthService.listPages(userToken);
|
||||||
|
// Une page Instagram est publiée avec le jeton de la page Facebook à
|
||||||
|
// laquelle son compte professionnel est rattaché.
|
||||||
|
const match = targets.find(
|
||||||
|
(target) => target.id === page.pageId || target.instagram?.id === page.pageId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new FacebookAuthError(
|
||||||
|
`La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` +
|
||||||
|
'Reconnectez-la avec un compte administrateur de cette page.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire
|
||||||
|
// pas : `expiresAt` reste donc généralement nul.
|
||||||
|
let pageTokenExpiresAt: Date | null = null;
|
||||||
|
try {
|
||||||
|
pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt;
|
||||||
|
} catch {
|
||||||
|
// L'inspection est un confort : le jeton fraîchement émis est bon.
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(socialPages)
|
||||||
|
.set({
|
||||||
|
accessToken: match.accessToken,
|
||||||
|
tokenExpiresAt: pageTokenExpiresAt,
|
||||||
|
userAccessToken: encrypt(userToken),
|
||||||
|
userTokenExpiresAt,
|
||||||
|
followersCount: match.followersCount || page.followersCount,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
tokenError: null,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(socialPages.id, page.id));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prolonge le jeton utilisateur quand son échéance approche, même si le
|
||||||
|
* jeton de page se porte bien : c'est ce qui évite de perdre la capacité de
|
||||||
|
* renouveler le jour où le jeton de page est révoqué.
|
||||||
|
*/
|
||||||
|
private static async extendUserTokenIfNeeded(page: SocialPage): Promise<void> {
|
||||||
|
if (!page.userAccessToken) return;
|
||||||
|
|
||||||
|
const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null;
|
||||||
|
if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken));
|
||||||
|
await db.update(socialPages)
|
||||||
|
.set({
|
||||||
|
userAccessToken: encrypt(extended.accessToken),
|
||||||
|
userTokenExpiresAt: extended.expiresAt,
|
||||||
|
})
|
||||||
|
.where(eq(socialPages.id, page.id));
|
||||||
|
console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôle historique, utilisé quand l'application Facebook n'est pas
|
||||||
|
* configurée : on sait seulement si le jeton répond encore.
|
||||||
|
*/
|
||||||
|
private static async legacyCheck(page: SocialPage, accessToken: string): Promise<TokenCheckResult> {
|
||||||
const isValid = await this.verifyToken(accessToken);
|
const isValid = await this.verifyToken(accessToken);
|
||||||
|
|
||||||
if (isValid) {
|
if (!isValid) {
|
||||||
await db.update(socialPages)
|
const error =
|
||||||
.set({
|
"Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
|
||||||
tokenStatus: "valid",
|
'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
|
||||||
lastTokenCheck: new Date()
|
await this.persistStatus(page.id, 'expired', error);
|
||||||
})
|
return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
|
||||||
.where(eq(socialPages.id, page.id));
|
|
||||||
} else {
|
|
||||||
await db.update(socialPages)
|
|
||||||
.set({
|
|
||||||
tokenStatus: "expired",
|
|
||||||
lastTokenCheck: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(socialPages.id, page.id));
|
|
||||||
console.warn(`[TokenManager] Token expired for ${page.pageName}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le jeton répond : reste l'échéance déclarée à l'ajout de la page.
|
||||||
|
const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null;
|
||||||
|
const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid';
|
||||||
|
await this.persistStatus(page.id, status, null);
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status, renewed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,7 +294,7 @@ export class TokenManager {
|
|||||||
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
|
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
|
||||||
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
|
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
|
||||||
*/
|
*/
|
||||||
private static async processTiktokToken(page: any) {
|
private static async processTiktokToken(page: SocialPage): Promise<TokenCheckResult> {
|
||||||
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
|
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
|
||||||
|
|
||||||
const refreshExpiresAt = page.refreshTokenExpiresAt
|
const refreshExpiresAt = page.refreshTokenExpiresAt
|
||||||
@@ -112,21 +302,47 @@ export class TokenManager {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let status: "valid" | "expiring" | "expired" = "valid";
|
let status: "valid" | "expiring" | "expired" = "valid";
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
|
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
|
||||||
status = "expired";
|
status = "expired";
|
||||||
|
error = "L'autorisation TikTok a expiré : reconnectez le compte.";
|
||||||
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
|
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
|
||||||
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
|
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
|
||||||
// Moins de 30 jours de validité restante
|
// Moins de 30 jours de validité restante
|
||||||
status = "expiring";
|
status = "expiring";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.persistStatus(page.id, status, error);
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async persistStatus(
|
||||||
|
pageId: string,
|
||||||
|
status: 'valid' | 'expiring' | 'expired' | 'error',
|
||||||
|
error: string | null,
|
||||||
|
extra: { tokenExpiresAt?: Date | null } = {}
|
||||||
|
): Promise<void> {
|
||||||
await db.update(socialPages)
|
await db.update(socialPages)
|
||||||
.set({
|
.set({
|
||||||
tokenStatus: status,
|
tokenStatus: status,
|
||||||
lastTokenCheck: new Date()
|
tokenError: error,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}),
|
||||||
})
|
})
|
||||||
.where(eq(socialPages.id, page.id));
|
.where(eq(socialPages.id, pageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés
|
||||||
|
* (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré.
|
||||||
|
*/
|
||||||
|
private static readToken(token: string): string {
|
||||||
|
try {
|
||||||
|
return this.decrypt(token);
|
||||||
|
} catch {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async verifyToken(token: string): Promise<boolean> {
|
private static async verifyToken(token: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
tiktokConfig,
|
tiktokConfig,
|
||||||
type TiktokConfig,
|
type TiktokConfig,
|
||||||
type InsertTiktokConfig,
|
type InsertTiktokConfig,
|
||||||
|
facebookConfig,
|
||||||
|
type FacebookConfig,
|
||||||
|
type InsertFacebookConfig,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray } from "drizzle-orm";
|
||||||
@@ -121,6 +124,8 @@ export interface IStorage {
|
|||||||
// TikTok Config
|
// TikTok Config
|
||||||
getTiktokConfig(): Promise<TiktokConfig | undefined>;
|
getTiktokConfig(): Promise<TiktokConfig | undefined>;
|
||||||
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
|
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
|
||||||
|
getFacebookConfig(): Promise<FacebookConfig | undefined>;
|
||||||
|
upsertFacebookConfig(config: Partial<InsertFacebookConfig>): Promise<FacebookConfig>;
|
||||||
|
|
||||||
// Cloudinary Config
|
// Cloudinary Config
|
||||||
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
||||||
@@ -612,6 +617,38 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return config || undefined;
|
return config || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Facebook Config (application développeur, globale à l'instance)
|
||||||
|
async getFacebookConfig(): Promise<FacebookConfig | undefined> {
|
||||||
|
const [config] = await db.select().from(facebookConfig).limit(1);
|
||||||
|
if (!config) return undefined;
|
||||||
|
return { ...config, appSecret: decrypt(config.appSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertFacebookConfig(data: Partial<InsertFacebookConfig>): Promise<FacebookConfig> {
|
||||||
|
const values: Partial<FacebookConfig> = { ...data };
|
||||||
|
if (data.appSecret) {
|
||||||
|
values.appSecret = encrypt(data.appSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select().from(facebookConfig).limit(1);
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db.update(facebookConfig)
|
||||||
|
.set({ ...values, updatedAt: new Date() })
|
||||||
|
.where(eq(facebookConfig.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
return { ...updated, appSecret: decrypt(updated.appSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.appId || !values.appSecret) {
|
||||||
|
throw new Error("L'App ID et l'App Secret Facebook sont requis");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db.insert(facebookConfig)
|
||||||
|
.values({ appId: values.appId, appSecret: values.appSecret })
|
||||||
|
.returning();
|
||||||
|
return { ...created, appSecret: decrypt(created.appSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
// TikTok Config
|
// TikTok Config
|
||||||
async getTiktokConfig(): Promise<TiktokConfig | undefined> {
|
async getTiktokConfig(): Promise<TiktokConfig | undefined> {
|
||||||
const [config] = await db.select().from(tiktokConfig).limit(1);
|
const [config] = await db.select().from(tiktokConfig).limit(1);
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ export const socialPages = pgTable("social_pages", {
|
|||||||
tokenStatus: tokenStatusEnum("token_status").default("valid"),
|
tokenStatus: tokenStatusEnum("token_status").default("valid"),
|
||||||
lastTokenCheck: timestamp("last_token_check"),
|
lastTokenCheck: timestamp("last_token_check"),
|
||||||
|
|
||||||
|
// Jeton utilisateur longue durée (~60 j) obtenu via OAuth Facebook, chiffré au
|
||||||
|
// repos. C'est lui qui permet de régénérer un token de page sans intervention
|
||||||
|
// humaine : sans lui, un token révoqué impose une reconnexion manuelle.
|
||||||
|
userAccessToken: text("user_access_token"),
|
||||||
|
userTokenExpiresAt: timestamp("user_token_expires_at"),
|
||||||
|
// Dernière erreur rencontrée lors du contrôle du token, affichée dans l'UI
|
||||||
|
// pour que l'utilisateur sache quoi faire (reconnecter, réautoriser…).
|
||||||
|
tokenError: text("token_error"),
|
||||||
|
|
||||||
// OAuth TikTok : l'access token n'est valable que 24h, il doit être renouvelé
|
// OAuth TikTok : l'access token n'est valable que 24h, il doit être renouvelé
|
||||||
// avec le refresh token (valable 1 an) avant chaque publication.
|
// avec le refresh token (valable 1 an) avant chaque publication.
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
@@ -93,6 +102,7 @@ export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
tokenStatus: true, // Managed by System
|
tokenStatus: true, // Managed by System
|
||||||
lastTokenCheck: true, // Managed by System
|
lastTokenCheck: true, // Managed by System
|
||||||
|
tokenError: true, // Managed by System
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
|
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
|
||||||
@@ -143,6 +153,20 @@ export const openrouterConfig = pgTable("openrouter_config", {
|
|||||||
|
|
||||||
// Application développeur TikTok (globale) : un seul client_key pour toute
|
// Application développeur TikTok (globale) : un seul client_key pour toute
|
||||||
// l'instance, chaque magasin autorise ensuite son propre compte via OAuth.
|
// l'instance, chaque magasin autorise ensuite son propre compte via OAuth.
|
||||||
|
/**
|
||||||
|
* Application développeur Facebook (globale à l'instance). Elle rend possible le
|
||||||
|
* renouvellement automatique des tokens de page : sans app id / app secret, on
|
||||||
|
* ne peut ni échanger un code OAuth, ni prolonger un jeton, ni interroger
|
||||||
|
* /debug_token.
|
||||||
|
*/
|
||||||
|
export const facebookConfig = pgTable("facebook_config", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
appId: text("app_id").notNull(),
|
||||||
|
appSecret: text("app_secret").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const tiktokConfig = pgTable("tiktok_config", {
|
export const tiktokConfig = pgTable("tiktok_config", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
clientKey: text("client_key").notNull(),
|
clientKey: text("client_key").notNull(),
|
||||||
@@ -432,6 +456,19 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
|
|||||||
apiSecret: true,
|
apiSecret: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertFacebookConfigSchema = createInsertSchema(facebookConfig).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
}).extend({
|
||||||
|
appId: z.string().trim().min(1, "L'App ID ne peut pas être vide"),
|
||||||
|
appSecret: z.string().trim().min(1, "L'App Secret ne peut pas être vide"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateFacebookConfigSchema = insertFacebookConfigSchema.partial({
|
||||||
|
appSecret: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const insertTiktokConfigSchema = createInsertSchema(tiktokConfig).omit({
|
export const insertTiktokConfigSchema = createInsertSchema(tiktokConfig).omit({
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -504,6 +541,19 @@ export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
|
|||||||
|
|
||||||
export type AppConfig = typeof appConfig.$inferSelect;
|
export type AppConfig = typeof appConfig.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page telle qu'elle est renvoyée au navigateur : les jetons restent au serveur,
|
||||||
|
* `autoRenew` dit simplement si la page sait se renouveler toute seule.
|
||||||
|
*/
|
||||||
|
export type ClientSocialPage = Omit<
|
||||||
|
SocialPage,
|
||||||
|
'accessToken' | 'userAccessToken' | 'refreshToken'
|
||||||
|
> & { autoRenew: boolean };
|
||||||
|
|
||||||
|
export type FacebookConfig = typeof facebookConfig.$inferSelect;
|
||||||
|
export type InsertFacebookConfig = z.infer<typeof insertFacebookConfigSchema>;
|
||||||
|
export type UpdateFacebookConfig = z.infer<typeof updateFacebookConfigSchema>;
|
||||||
|
|
||||||
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
|
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
|
||||||
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
|
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
|
||||||
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;
|
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;
|
||||||
|
|||||||
Reference in new issue
Block a user