Réseaux : contrôle réel de validité des jetons et outils d'inspection

Un jeton de page Facebook n'a pas de date d'expiration, donc `expiryStatus`
renvoyait « never » et aucune alerte ne pouvait se déclencher. Mais il meurt
quand même : mot de passe du compte changé, rôle d'administrateur perdu sur la
page, autorisation retirée, révocation par Meta. On ne l'apprenait qu'au moment
où une publication échouait.

- `checkTokenHealth()` interroge la plateforme (`/me` côté Graph, les ACL
  d'organisation côté LinkedIn) et rend un verdict motivé.
- L'écran Réseaux sociaux contrôle à chaque affichage : pastille « Jeton
  expiré », bandeau rouge et réponse brute de la plateforme quand il est mort,
  ligne verte de confirmation sinon.
- Le verdict est persisté (`social_accounts.last_check_*`) : le tableau de bord
  alerte via `tokenHealthCached()` (fenêtre de 6 h) sans appeler la plateforme à
  chaque affichage, et croise ce signal avec la dernière tentative de
  publication refusée pour motif d'autorisation.
- Bloc « Outils pour inspecter un jeton » dans l'aide de chaque réseau :
  débogueur de jeton, outil de jetons d'accès, explorateur d'API Graph,
  documentation des jetons de longue durée — en précisant que le bouton
  Connecter fait déjà l'échange court → longue durée → jeton de page, et qu'il
  n'y a aucun jeton à copier à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-09-06 10:43:11 +00:00
1 parent 17511a73c3
commit 81b99ed5fb
8 files changed
+1618 -16

No files matched your search

+54 -13
View File
@@ -3,10 +3,16 @@ import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
import { activityNodes } from "@/lib/activity";
import { can, isStaff } from "@/lib/rbac";
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
import {
expiryStatus,
getSocialAccounts,
SOCIAL_LABELS,
tokenHealthCached,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
@@ -51,14 +57,47 @@ export default async function DashboardPage() {
const pendingCount = pendingPromos.length;
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
// qu'au moment où une publication échoue.
const expiringNetworks = can(session?.user.role, "manageSettings")
? (await getSocialAccounts())
.filter((a) => a.accessToken && a.targetId)
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
.filter((a) => a.status === "soon" || a.status === "expired")
: [];
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
// révoqué, et cela ne se voit qu'à la première publication en échec.
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
if (can(session?.user.role, "manageSettings")) {
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
// Dernière tentative de publication par réseau : une erreur d'autorisation
// signale un jeton mort, sans avoir à interroger la plateforme ici.
const attempts = connected.length
? await db
.select({
network: socialPosts.network,
status: socialPosts.status,
error: socialPosts.error,
})
.from(socialPosts)
.orderBy(desc(socialPosts.createdAt))
.limit(40)
: [];
const lastAttempt = new Map<string, (typeof attempts)[number]>();
for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a);
for (const account of connected) {
const last = lastAttempt.get(account.network);
const authFailure =
last?.status === "failed" &&
/oauth|token|401|expired|invalid|permission/i.test(last.error ?? "");
// Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir
// avant la première publication en échec, sans appeler la plateforme à
// chaque affichage du tableau de bord.
const probe = authFailure ? null : await tokenHealthCached(account.network);
const expiry = expiryStatus(account.expiresAt);
if (authFailure || probe?.ok === false) {
expiringNetworks.push({ network: account.network, status: "broken" });
} else if (expiry === "soon" || expiry === "expired") {
expiringNetworks.push({ network: account.network, status: expiry });
}
}
}
function timeAgo(date: Date) {
const diff = Date.now() - new Date(date).getTime();
@@ -90,9 +129,11 @@ export default async function DashboardPage() {
{expiringNetworks.map((n) => (
<div key={n.network}>
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
{n.status === "expired"
? "— le jeton a expiré, les publications échoueront."
: "— le jeton expire dans moins de 7 jours."}{" "}
{n.status === "broken"
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
: n.status === "expired"
? "— le jeton a expiré, les publications échoueront."
: "— le jeton expire dans moins de 7 jours."}{" "}
Reconnecter le compte →
</div>
))}