diff --git a/.env.example b/.env.example
index 82ee91c..0751ede 100644
--- a/.env.example
+++ b/.env.example
@@ -30,9 +30,27 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here
MINIO_ENDPOINT=http://minio:9000
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_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
# INSTAGRAM_APP_ID=
diff --git a/client/src/components/facebook-token-status.tsx b/client/src/components/facebook-token-status.tsx
new file mode 100644
index 0000000..8e4adeb
--- /dev/null
+++ b/client/src/components/facebook-token-status.tsx
@@ -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 = {
+ 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 (
+
+
+ Connecter des pages Facebook
+
+ );
+}
+
+/** 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 (
+ refreshMutation.mutate()}
+ disabled={refreshMutation.isPending}
+ data-testid={`button-refresh-token-${page.id}`}
+ >
+
+ {refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {status === 'valid' ? (
+
+ ) : (
+
+ )}
+
{style.label}
+
+
+
+
+ {expiresAt ? (
+
+ {isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '}
+ {format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })}
+
+ ) : page.autoRenew ? (
+ Sans expiration
+ ) : (
+ Échéance inconnue
+ )}
+
+
+ {page.autoRenew && (
+
+
+ Renouvellement automatique actif
+
+ )}
+
+ {page.tokenError && (
+
+ {page.tokenError}
+
+ )}
+
+ {page.lastTokenCheck && (
+
+ Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })}
+
+ )}
+
+ );
+}
+
+/** 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 (
+
+
+
+
+ {failing.length === 1
+ ? 'Une page nécessite votre attention'
+ : `${failing.length} pages nécessitent votre attention`}
+
+ {failing.map((page) => (
+
+ {page.pageName} — {STATUS_STYLES[statusOf(page)].label.toLowerCase()}
+ {page.tokenError ? ` : ${page.tokenError}` : '.'}
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/pages/mobile/pages.tsx b/client/src/pages/mobile/pages.tsx
index 9bcc59e..f284a4c 100644
--- a/client/src/pages/mobile/pages.tsx
+++ b/client/src/pages/mobile/pages.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
-import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
+import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
@@ -12,69 +12,13 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
-import type { SocialPage } from "@shared/schema";
-import { format } from "date-fns";
-import { fr } from "date-fns/locale";
-
-function getTokenExpirationStatus(expiresAt: string | null | undefined) {
- if (!expiresAt) {
- return {
- status: 'unknown' as const,
- color: 'text-gray-500',
- bgColor: 'bg-gray-100',
- daysLeft: null,
- message: 'Date inconnue'
- };
- }
-
- const now = new Date();
- const expiration = new Date(expiresAt);
- const diffTime = expiration.getTime() - now.getTime();
- const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
-
- if (daysLeft < 0) {
- return {
- status: 'expired' as const,
- color: 'text-red-600',
- bgColor: 'bg-red-100',
- daysLeft,
- message: 'Expiré'
- };
- } else if (daysLeft <= 7) {
- return {
- status: 'urgent' as const,
- color: 'text-red-600',
- bgColor: 'bg-red-100',
- daysLeft,
- message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
- };
- } else if (daysLeft <= 15) {
- return {
- status: 'warning' as const,
- color: 'text-orange-600',
- bgColor: 'bg-orange-100',
- daysLeft,
- message: `${daysLeft} jours restants`
- };
- } else {
- return {
- status: 'good' as const,
- color: 'text-green-600',
- bgColor: 'bg-green-100',
- daysLeft,
- message: `${daysLeft} jours restants`
- };
- }
-}
-
-/**
- * Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la
- * validité de l'autorisation, portée par le refresh token (1 an).
- */
-function getConnectionExpiry(page: SocialPage): string | null {
- const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
- return date ? new Date(date).toISOString() : null;
-}
+import type { ClientSocialPage } from "@shared/schema";
+import {
+ ConnectFacebookButton,
+ RefreshTokenButton,
+ TokenAlertBanner,
+ TokenHealthPanel,
+} from "@/components/facebook-token-status";
/**
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
@@ -115,17 +59,21 @@ function ConnectTiktokButton() {
export default function PagesManagementMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
- const [editingPage, setEditingPage] = useState(null);
+ const [editingPage, setEditingPage] = useState(null);
const { toast } = useToast();
- // Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
+ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
+ // redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
- const result = params.get('tiktok');
- if (!result) return;
+ const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
+ if (!network) return;
+
+ const result = params.get(network);
+ const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
- title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
+ title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
@@ -134,7 +82,7 @@ export default function PagesManagementMobile() {
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
- const { data: pages = [], isLoading } = useQuery({
+ const { data: pages = [], isLoading } = useQuery({
queryKey: ['/api/pages'],
});
@@ -185,6 +133,9 @@ export default function PagesManagementMobile() {
+
+
+
!open && setEditingPage(null)} />
@@ -267,32 +218,14 @@ export default function PagesManagementMobile() {
ID: {page.pageId}
- {/* Token expiration status */}
- {(() => {
- const expiresAt = getConnectionExpiry(page);
- const expirationStatus = getTokenExpirationStatus(expiresAt);
- return (
-
-
-
-
- {page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
-
-
- {expirationStatus.message}
-
- {expiresAt && (
-
- {format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
-
- )}
-
- {(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
-
- )}
-
- );
- })()}
+ {/* État du jeton, tel que constaté par le dernier contrôle serveur */}
+
+
+ {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 { toast } = useToast();
diff --git a/client/src/pages/pages.tsx b/client/src/pages/pages.tsx
index 3c6fdce..d63d8e5 100644
--- a/client/src/pages/pages.tsx
+++ b/client/src/pages/pages.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
-import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
+import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
@@ -12,84 +12,32 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
-import type { SocialPage } from "@shared/schema";
-import { format } from "date-fns";
-import { fr } from "date-fns/locale";
-
-function getTokenExpirationStatus(expiresAt: string | null | undefined) {
- if (!expiresAt) {
- return {
- status: 'unknown' as const,
- color: 'text-gray-500',
- bgColor: 'bg-gray-100',
- daysLeft: null,
- message: 'Date inconnue'
- };
- }
-
- const now = new Date();
- const expiration = new Date(expiresAt);
- const diffTime = expiration.getTime() - now.getTime();
- const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
-
- if (daysLeft < 0) {
- return {
- status: 'expired' as const,
- color: 'text-red-600',
- bgColor: 'bg-red-100',
- daysLeft,
- message: 'Expiré'
- };
- } else if (daysLeft <= 7) {
- return {
- status: 'urgent' as const,
- color: 'text-red-600',
- bgColor: 'bg-red-100',
- daysLeft,
- message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
- };
- } else if (daysLeft <= 15) {
- return {
- status: 'warning' as const,
- color: 'text-orange-600',
- bgColor: 'bg-orange-100',
- daysLeft,
- message: `${daysLeft} jours restants`
- };
- } else {
- return {
- status: 'good' as const,
- color: 'text-green-600',
- bgColor: 'bg-green-100',
- daysLeft,
- message: `${daysLeft} jours restants`
- };
- }
-}
-
-/**
- * Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la
- * validité de l'autorisation, portée par le refresh token (1 an).
- */
-function getConnectionExpiry(page: SocialPage): string | null {
- const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
- return date ? new Date(date).toISOString() : null;
-}
+import type { ClientSocialPage } from "@shared/schema";
+import {
+ ConnectFacebookButton,
+ RefreshTokenButton,
+ TokenAlertBanner,
+ TokenHealthPanel,
+} from "@/components/facebook-token-status";
export default function PagesManagement() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
- const [editingPage, setEditingPage] = useState(null);
+ const [editingPage, setEditingPage] = useState(null);
const { toast } = useToast();
- // Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
+ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
+ // redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
- const result = params.get('tiktok');
- if (!result) return;
+ const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
+ if (!network) return;
+
+ const result = params.get(network);
+ const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
- title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
+ title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
@@ -98,7 +46,7 @@ export default function PagesManagement() {
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
- const { data: pages = [], isLoading } = useQuery({
+ const { data: pages = [], isLoading } = useQuery({
queryKey: ['/api/pages'],
});
@@ -149,24 +97,29 @@ export default function PagesManagement() {
!open && setEditingPage(null)} />
+
+
{/* Permissions required notice */}
-
Permissions Facebook requises pour la publication vidéo
+
Connectez vos pages plutôt que de coller un jeton
- Le token de page doit inclure : pages_manage_posts{" "}
- pages_read_engagement{" "}
- publish_video
+ « Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
+ ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
+ main expire au bout de 60 jours et devra être remplacé manuellement.
- Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via /me/accounts pour obtenir le Page Token.
+ Le jeton doit couvrir : pages_manage_posts{" "}
+ pages_read_engagement{" "}
+ publish_video
@@ -246,34 +199,11 @@ export default function PagesManagement() {
ID: {page.pageId}
- {/* Token expiration status */}
- {(() => {
- const expiresAt = getConnectionExpiry(page);
- const expirationStatus = getTokenExpirationStatus(expiresAt);
- return (
-
-
-
-
- {page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
-
-
- {expirationStatus.message}
-
- {expiresAt && (
-
- {format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
-
- )}
-
- {(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
-
- )}
-
- );
- })()}
+ {/* État du jeton, tel que constaté par le dernier contrôle serveur */}
+
+ {page.platform !== 'tiktok' &&
}
{page.platform === 'tiktok' ? (
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
void }) {
+function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState('');
const { toast } = useToast();
diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx
index ca24202..fbbcf25 100644
--- a/client/src/pages/settings.tsx
+++ b/client/src/pages/settings.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
-import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react";
+import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
@@ -32,6 +32,8 @@ export default function Settings() {
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
+ const [facebookAppId, setFacebookAppId] = useState("");
+ const [facebookAppSecret, setFacebookAppSecret] = useState("");
const [tiktokClientKey, setTiktokClientKey] = useState("");
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
const { toast } = useToast();
@@ -68,6 +70,16 @@ export default function Settings() {
queryKey: ['/api/tiktok/config'],
});
+ const { data: facebookConfig } = useQuery<{
+ configured: boolean;
+ appId: string;
+ hasAppSecret: boolean;
+ redirectUri: string;
+ scopes: string[];
+ }>({
+ queryKey: ['/api/facebook/config'],
+ });
+
const { data: externalApiConfig } = useQuery({
queryKey: ['/api/settings/external-api'],
enabled: isAdmin,
@@ -111,6 +123,13 @@ export default function Settings() {
}
}, [tiktokConfig]);
+ useEffect(() => {
+ if (facebookConfig) {
+ // L'App Secret n'est jamais renvoyé par l'API : il reste vide
+ setFacebookAppId(facebookConfig.appId || "");
+ }
+ }, [facebookConfig]);
+
useEffect(() => {
if (geminiConfig) {
// Don't pre-fill - user must enter key manually
@@ -283,6 +302,32 @@ export default function Settings() {
},
});
+ const saveFacebookMutation = useMutation({
+ mutationFn: () => {
+ const payload: any = { appId: facebookAppId.trim() };
+ // Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
+ if (facebookAppSecret.trim() !== "") {
+ payload.appSecret = facebookAppSecret.trim();
+ }
+ return apiRequest('PUT', '/api/facebook/config', payload);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['/api/facebook/config'] });
+ setFacebookAppSecret("");
+ toast({
+ title: "Configuration Facebook enregistrée",
+ description: "Les jetons de page se renouvellent désormais automatiquement",
+ });
+ },
+ onError: async (error: any) => {
+ toast({
+ title: "Erreur",
+ description: error?.message || "Impossible d'enregistrer la configuration Facebook",
+ variant: "destructive",
+ });
+ },
+ });
+
const saveTiktokMutation = useMutation({
mutationFn: () => {
const payload: any = { clientKey: tiktokClientKey.trim() };
@@ -802,6 +847,80 @@ export default function Settings() {
)}
+ {isAdmin && (
+
+
+
+
+ Facebook
+
+
+ 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.
+
+
+
+ {facebookConfig?.configured && (
+
+
+ Application Facebook configurée
+
+ )}
+
+
+ App ID
+ setFacebookAppId(e.target.value)}
+ placeholder="1234567890123456"
+ />
+
+
+
+
+ {facebookConfig?.hasAppSecret ? "Nouvel App Secret (laisser vide pour conserver l'actuel)" : "App Secret"}
+
+ setFacebookAppSecret(e.target.value)}
+ placeholder="••••••••••••••••"
+ />
+
+
+
+
URI de redirection à déclarer chez Facebook
+
+ {facebookConfig?.redirectUri || `${window.location.origin}/api/facebook/callback`}
+
+
+ À ajouter dans « Connexion Facebook › Paramètres › URI de redirection OAuth valides ».
+
+
+
+ {facebookConfig?.scopes?.length ? (
+
+
Permissions demandées
+
+ {facebookConfig.scopes.join(', ')}
+
+
+ ) : null}
+
+ saveFacebookMutation.mutate()}
+ disabled={saveFacebookMutation.isPending || !facebookAppId.trim()}
+ className="w-full"
+ >
+ {saveFacebookMutation.isPending ? "Enregistrement..." : "Enregistrer"}
+
+
+
+ )}
+
{isAdmin && (
diff --git a/server/cron.ts b/server/cron.ts
index 3919b3b..03e41be 100644
--- a/server/cron.ts
+++ b/server/cron.ts
@@ -15,12 +15,17 @@ export function startTokenCron() {
});
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
- // Run every day at midnight (00:00) — Token health check
- cron.schedule('0 0 * * *', async () => {
- console.log('[Cron] Running daily token check...');
- await TokenManager.checkAndRefreshTokens();
+ // Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons.
+ // Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour
+ // réduit d'autant la fenêtre pendant laquelle une publication échouerait.
+ 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
cron.schedule('0 8,20 * * *', async () => {
diff --git a/server/migrate.ts b/server/migrate.ts
index 69ae34e..e005eed 100644
--- a/server/migrate.ts
+++ b/server/migrate.ts
@@ -187,6 +187,25 @@ export async function migrate() {
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
await client.query(`
ALTER TABLE "scheduled_posts"
diff --git a/server/routes.ts b/server/routes.ts
index 40780a0..9c7f566 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -18,6 +18,7 @@ import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
+import { facebookRouter } from "./routes/facebook";
import { legalRouter } from "./routes/legal";
import { insertAudioTrackSchema } from "@shared/schema";
import * as musicMetadata from "music-metadata";
@@ -475,6 +476,7 @@ export async function registerRoutes(app: Express): Promise {
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
app.use("/api/tiktok", requireAuth, tiktokRouter);
+ app.use("/api/facebook", requireAuth, facebookRouter);
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
@@ -1553,6 +1555,19 @@ export async function registerRoutes(app: Express): Promise {
});
// 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) => {
try {
const user = req.user as User;
@@ -1572,7 +1587,9 @@ export async function registerRoutes(app: Express): Promise {
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) {
console.error("Error fetching pages:", error);
res.status(500).json({ error: "Failed to fetch pages" });
@@ -1602,7 +1619,7 @@ export async function registerRoutes(app: Express): Promise {
tokenExpiresAt
});
const page = await storage.createSocialPage(pageData);
- res.json(page);
+ res.json(toClientPage(page));
} catch (error) {
console.error("Error creating page:", error);
res.status(500).json({ error: "Failed to create page" });
@@ -1638,7 +1655,7 @@ export async function registerRoutes(app: Express): Promise {
}
const updatedPage = await storage.updateSocialPage(pageId, pageData);
- res.json(updatedPage);
+ res.json(toClientPage(updatedPage));
} catch (error) {
console.error("Error updating page:", error);
res.status(500).json({ error: "Failed to update page" });
diff --git a/server/routes/facebook.ts b/server/routes/facebook.ts
new file mode 100644
index 0000000..952dcb1
--- /dev/null
+++ b/server/routes/facebook.ts
@@ -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' });
+ }
+});
diff --git a/server/services/facebook_oauth.ts b/server/services/facebook_oauth.ts
new file mode 100644
index 0000000..a5b52da
--- /dev/null
+++ b/server/services/facebook_oauth.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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(
+ endpoint: string,
+ params: Record,
+ context: string
+ ): Promise {
+ 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();
diff --git a/server/services/token_manager.ts b/server/services/token_manager.ts
index c823fb4..82dd90a 100644
--- a/server/services/token_manager.ts
+++ b/server/services/token_manager.ts
@@ -1,10 +1,31 @@
// crypto removed, using shared encryption utility
import { db } from '../db';
-import { socialPages, tokenStatusEnum } from '../../shared/schema';
-import { eq, and, lte } from 'drizzle-orm';
+import { socialPages } from '../../shared/schema';
+import { eq } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client';
+import type { SocialPage } from '../../shared/schema';
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
@@ -30,8 +51,9 @@ export class TokenManager {
/**
* Checks all active pages for expiring tokens and refreshes them if needed.
*/
- static async checkAndRefreshTokens() {
+ static async checkAndRefreshTokens(): Promise {
console.log('[TokenManager] Starting daily token check...');
+ const results: TokenCheckResult[] = [];
try {
// Fetch active pages
@@ -41,15 +63,18 @@ export class TokenManager {
for (const page of pages) {
try {
- await this.processPageToken(page);
+ results.push(await this.processPageToken(page));
} catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
- await db.update(socialPages)
- .set({
- tokenStatus: "error",
- lastTokenCheck: new Date()
- })
- .where(eq(socialPages.id, page.id));
+ await this.persistStatus(page.id, 'error', message);
+ results.push({
+ pageId: page.id,
+ pageName: page.pageName,
+ status: 'error',
+ renewed: false,
+ error: message,
+ });
}
}
@@ -57,46 +82,211 @@ export class TokenManager {
} catch (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 {
+ 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 {
// 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.
if (page.platform === 'tiktok') {
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}`);
+ 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 {
+ 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 {
+ 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 {
const isValid = await this.verifyToken(accessToken);
- if (isValid) {
- await db.update(socialPages)
- .set({
- tokenStatus: "valid",
- lastTokenCheck: new Date()
- })
- .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}`);
+ if (!isValid) {
+ const error =
+ "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
+ 'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
+ await this.persistStatus(page.id, 'expired', error);
+ return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
}
+
+ // 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
* 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 {
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
const refreshExpiresAt = page.refreshTokenExpiresAt
@@ -112,21 +302,47 @@ export class TokenManager {
: 0;
let status: "valid" | "expiring" | "expired" = "valid";
+ let error: string | null = null;
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
status = "expired";
+ error = "L'autorisation TikTok a expiré : reconnectez le compte.";
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
// Moins de 30 jours de validité restante
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 {
await db.update(socialPages)
.set({
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 {
diff --git a/server/storage.ts b/server/storage.ts
index 34e3fe0..6c5f40e 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -39,6 +39,9 @@ import {
tiktokConfig,
type TiktokConfig,
type InsertTiktokConfig,
+ facebookConfig,
+ type FacebookConfig,
+ type InsertFacebookConfig,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray } from "drizzle-orm";
@@ -121,6 +124,8 @@ export interface IStorage {
// TikTok Config
getTiktokConfig(): Promise;
upsertTiktokConfig(config: Partial): Promise;
+ getFacebookConfig(): Promise;
+ upsertFacebookConfig(config: Partial): Promise;
// Cloudinary Config
getCloudinaryConfig(): Promise;
@@ -612,6 +617,38 @@ export class DatabaseStorage implements IStorage {
return config || undefined;
}
+ // Facebook Config (application développeur, globale à l'instance)
+ async getFacebookConfig(): Promise {
+ const [config] = await db.select().from(facebookConfig).limit(1);
+ if (!config) return undefined;
+ return { ...config, appSecret: decrypt(config.appSecret) };
+ }
+
+ async upsertFacebookConfig(data: Partial): Promise {
+ const values: Partial = { ...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
async getTiktokConfig(): Promise {
const [config] = await db.select().from(tiktokConfig).limit(1);
diff --git a/shared/schema.ts b/shared/schema.ts
index d4d48c3..ce17633 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -38,6 +38,15 @@ export const socialPages = pgTable("social_pages", {
tokenStatus: tokenStatusEnum("token_status").default("valid"),
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é
// avec le refresh token (valable 1 an) avant chaque publication.
refreshToken: text("refresh_token"),
@@ -93,6 +102,7 @@ export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
createdAt: true,
tokenStatus: true, // Managed by System
lastTokenCheck: true, // Managed by System
+ tokenError: true, // Managed by System
});
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
// 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", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
clientKey: text("client_key").notNull(),
@@ -432,6 +456,19 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
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({
id: true,
createdAt: true,
@@ -504,6 +541,19 @@ export type InsertAudioTrack = z.infer;
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;
+export type UpdateFacebookConfig = z.infer;
+
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
export type InsertTiktokConfig = z.infer;
export type UpdateTiktokConfig = z.infer;