mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
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:
8 files changed
+1618
-16
No files matched your search
+54
-13
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||
import {
|
||||
checkTokenHealth,
|
||||
expiryStatus,
|
||||
getSocialAccounts,
|
||||
listStoredTargets,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialTarget,
|
||||
type TokenHealth,
|
||||
} from "@/lib/social-accounts";
|
||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||
|
||||
@@ -21,7 +23,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const HELP: Record<
|
||||
(typeof SOCIAL_NETWORKS)[number],
|
||||
{ portal: string; steps: string[]; caution?: string }
|
||||
{ portal: string; steps: string[]; caution?: string; tools?: { label: string; href: string }[] }
|
||||
> = {
|
||||
facebook: {
|
||||
portal: "https://developers.facebook.com/apps",
|
||||
@@ -30,6 +32,12 @@ const HELP: Record<
|
||||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
||||
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
|
||||
],
|
||||
tools: [
|
||||
{ label: "Débogueur de jeton (vérifier un jeton et sa date d'expiration)", href: "https://developers.facebook.com/tools/debug/accesstoken/" },
|
||||
{ label: "Outil de jetons d'accès (voir les jetons de vos pages)", href: "https://developers.facebook.com/tools/accesstoken/" },
|
||||
{ label: "Explorateur d'API Graph (tester un appel à la main)", href: "https://developers.facebook.com/tools/explorer/" },
|
||||
{ label: "Documentation : jetons de longue durée", href: "https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" },
|
||||
],
|
||||
},
|
||||
linkedin: {
|
||||
portal: "https://www.linkedin.com/developers/apps",
|
||||
@@ -40,6 +48,10 @@ const HELP: Record<
|
||||
],
|
||||
caution:
|
||||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
|
||||
tools: [
|
||||
{ label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" },
|
||||
{ label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,6 +82,16 @@ export default async function ReseauxPage({
|
||||
redirectUris.set(network, await redirectUri(network));
|
||||
}
|
||||
|
||||
// État réel du jeton, demandé à la plateforme à chaque affichage : c'est le
|
||||
// seul moyen de savoir qu'un jeton « sans expiration » a été révoqué.
|
||||
const health = new Map<string, TokenHealth | null>();
|
||||
for (const network of SOCIAL_NETWORKS) {
|
||||
const account = byNetwork.get(network);
|
||||
if (account?.accessToken && account.targetId) {
|
||||
health.set(network, await checkTokenHealth(network));
|
||||
}
|
||||
}
|
||||
|
||||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||
const targets = new Map<string, SocialTarget[]>();
|
||||
let targetError: string | null = null;
|
||||
@@ -140,6 +162,8 @@ export default async function ReseauxPage({
|
||||
const status = expiryStatus(account?.expiresAt);
|
||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||
const candidates = targets.get(network) ?? [];
|
||||
const tokenHealth = health.get(network) ?? null;
|
||||
const broken = tokenHealth?.ok === false;
|
||||
|
||||
return (
|
||||
<section key={network} style={panel}>
|
||||
@@ -162,7 +186,7 @@ export default async function ReseauxPage({
|
||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||
{SOCIAL_LABELS[network]}
|
||||
</h2>
|
||||
<StatusChip connected={isConnected} status={status} />
|
||||
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
@@ -172,10 +196,25 @@ export default async function ReseauxPage({
|
||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||
<br />
|
||||
{tokenHealth?.ok && (
|
||||
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
|
||||
✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
|
||||
</span>
|
||||
)}
|
||||
Pour changer de page, relancez une connexion.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{broken && (
|
||||
<Banner tone="error">
|
||||
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
|
||||
tant que vous n'aurez pas cliqué sur Reconnecter.
|
||||
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
|
||||
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{(status === "soon" || status === "expired") && isConnected && (
|
||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||
{status === "expired"
|
||||
@@ -276,7 +315,28 @@ export default async function ReseauxPage({
|
||||
{help.portal}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{help.tools && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">Outils pour inspecter un jeton</div>
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||||
{help.tools.map((tool) => (
|
||||
<li key={tool.href}>
|
||||
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
|
||||
{tool.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
|
||||
Ces outils servent à contrôler ou dépanner : le bouton
|
||||
<strong> Connecter</strong> fait déjà l'échange complet — jeton court,
|
||||
puis jeton de longue durée, puis jeton de page — vous n'avez aucun jeton à
|
||||
copier à la main.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div className="field-label">URL de redirection à déclarer</div>
|
||||
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
|
||||
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||||
|
||||
Reference in new issue
Block a user