mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +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
@@ -74,6 +74,11 @@ site sans traitement supplémentaire.
|
||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
||||
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
|
||||
- « N'expire pas » ≠ « ne meurt jamais » : `checkTokenHealth()` interroge la
|
||||
plateforme et persiste le verdict dans `social_accounts.last_check_*`.
|
||||
L'écran Réseaux contrôle à chaque affichage ; le tableau de bord passe par
|
||||
`tokenHealthCached()` (6 h) et croise le résultat avec la dernière tentative
|
||||
de publication en échec. Une date d'expiration seule ne suffit pas à alerter.
|
||||
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
|
||||
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||
par URL.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "social_accounts" ADD COLUMN "last_check_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "social_accounts" ADD COLUMN "last_check_ok" boolean;--> statement-breakpoint
|
||||
ALTER TABLE "social_accounts" ADD COLUMN "last_check_error" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,13 @@
|
||||
"when": 1788680191881,
|
||||
"tag": "0013_contact_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1788691242685,
|
||||
"tag": "0014_lean_marvel_zombies",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+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 —"}
|
||||
|
||||
@@ -155,6 +155,11 @@ export const socialAccounts = pgTable("social_accounts", {
|
||||
targetName: varchar("target_name", { length: 200 }),
|
||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
||||
// Dernier contrôle de validité auprès de la plateforme : permet au tableau de
|
||||
// bord d'alerter sans refaire un appel réseau à chaque affichage.
|
||||
lastCheckAt: timestamp("last_check_at", { withTimezone: true }),
|
||||
lastCheckOk: boolean("last_check_ok"),
|
||||
lastCheckError: text("last_check_error"),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
@@ -141,6 +141,92 @@ export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
|
||||
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le jeton fonctionne encore, en interrogeant la plateforme.
|
||||
*
|
||||
* « N'expire pas » ne veut pas dire « ne meurt jamais » : un jeton de page
|
||||
* Facebook est invalidé si le mot de passe du compte connecté change, si ce
|
||||
* compte perd son rôle d'administrateur de la page, si l'autorisation est
|
||||
* retirée, ou si Meta le révoque. Sans cette vérification, on ne l'apprend
|
||||
* qu'au moment où une publication échoue.
|
||||
*/
|
||||
export type TokenHealth =
|
||||
| { ok: true; detail: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export async function checkTokenHealth(network: SocialNetwork): Promise<TokenHealth | null> {
|
||||
const health = await probeToken(network);
|
||||
if (health) await rememberCheck(network, health);
|
||||
return health;
|
||||
}
|
||||
|
||||
/** Contrôle mis en cache : évite un appel réseau à chaque affichage. */
|
||||
export async function tokenHealthCached(
|
||||
network: SocialNetwork,
|
||||
maxAgeMs = 6 * 3_600_000
|
||||
): Promise<TokenHealth | null> {
|
||||
const account = await getSocialAccount(network);
|
||||
if (!account?.accessToken || !account.targetId) return null;
|
||||
|
||||
const fresh =
|
||||
account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs;
|
||||
if (fresh && account.lastCheckOk !== null) {
|
||||
return account.lastCheckOk
|
||||
? { ok: true, detail: "Vérifié récemment" }
|
||||
: { ok: false, reason: account.lastCheckError ?? "Jeton refusé" };
|
||||
}
|
||||
return checkTokenHealth(network);
|
||||
}
|
||||
|
||||
async function rememberCheck(network: SocialNetwork, health: TokenHealth) {
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
lastCheckAt: new Date(),
|
||||
lastCheckOk: health.ok,
|
||||
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
async function probeToken(network: SocialNetwork): Promise<TokenHealth | null> {
|
||||
const credentials = await resolveCredentials(network);
|
||||
if (!credentials) return null;
|
||||
|
||||
try {
|
||||
if (network === "facebook") {
|
||||
// Un jeton de page répond `/me` avec la page elle-même.
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me?${new URLSearchParams({
|
||||
fields: "id,name",
|
||||
access_token: credentials.accessToken,
|
||||
})}`
|
||||
);
|
||||
if (!res.ok) return { ok: false, reason: await readError(res) };
|
||||
const page = (await res.json()) as { id?: string; name?: string };
|
||||
if (page.id && credentials.targetId && page.id !== credentials.targetId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Le jeton ne pointe plus vers la bonne page (${page.name ?? page.id}).`,
|
||||
};
|
||||
}
|
||||
return { ok: true, detail: page.name ?? "Page accessible" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=1",
|
||||
{ headers: linkedinHeaders(credentials.accessToken) }
|
||||
);
|
||||
if (!res.ok) return { ok: false, reason: await readError(res) };
|
||||
return { ok: true, detail: "Accès à la page confirmé" };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : "Plateforme injoignable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OAuth ----
|
||||
|
||||
export class SocialAuthError extends Error {}
|
||||
|
||||
Reference in new issue
Block a user