mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 07:38: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
@@ -74,6 +74,11 @@ site sans traitement supplémentaire.
|
|||||||
anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL.
|
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
|
- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
|
||||||
programmatique réservé à certains partenaires, d'où le bandeau de reconnexion.
|
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
|
- 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
|
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
|
||||||
par URL.
|
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,
|
"when": 1788680191881,
|
||||||
"tag": "0013_contact_email",
|
"tag": "0013_contact_email",
|
||||||
"breakpoints": true
|
"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 { desc, eq } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
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 { activityNodes } from "@/lib/activity";
|
||||||
import { can, isStaff } from "@/lib/rbac";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -51,14 +57,47 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
const pendingCount = pendingPromos.length;
|
const pendingCount = pendingPromos.length;
|
||||||
|
|
||||||
// Jetons réseaux à renouveler : sans alerte, on ne découvre l'expiration
|
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
|
||||||
// qu'au moment où une publication échoue.
|
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
|
||||||
const expiringNetworks = can(session?.user.role, "manageSettings")
|
// révoqué, et cela ne se voit qu'à la première publication en échec.
|
||||||
? (await getSocialAccounts())
|
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
|
||||||
.filter((a) => a.accessToken && a.targetId)
|
if (can(session?.user.role, "manageSettings")) {
|
||||||
.map((a) => ({ network: a.network, status: expiryStatus(a.expiresAt) }))
|
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
|
||||||
.filter((a) => a.status === "soon" || a.status === "expired")
|
|
||||||
: [];
|
// 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) {
|
function timeAgo(date: Date) {
|
||||||
const diff = Date.now() - new Date(date).getTime();
|
const diff = Date.now() - new Date(date).getTime();
|
||||||
@@ -90,9 +129,11 @@ export default async function DashboardPage() {
|
|||||||
{expiringNetworks.map((n) => (
|
{expiringNetworks.map((n) => (
|
||||||
<div key={n.network}>
|
<div key={n.network}>
|
||||||
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
||||||
{n.status === "expired"
|
{n.status === "broken"
|
||||||
? "— le jeton a expiré, les publications échoueront."
|
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
|
||||||
: "— le jeton expire dans moins de 7 jours."}{" "}
|
: n.status === "expired"
|
||||||
|
? "— le jeton a expiré, les publications échoueront."
|
||||||
|
: "— le jeton expire dans moins de 7 jours."}{" "}
|
||||||
Reconnecter le compte →
|
Reconnecter le compte →
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getSession } from "@/lib/session";
|
|||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import {
|
import {
|
||||||
|
checkTokenHealth,
|
||||||
expiryStatus,
|
expiryStatus,
|
||||||
getSocialAccounts,
|
getSocialAccounts,
|
||||||
listStoredTargets,
|
listStoredTargets,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
SOCIAL_LABELS,
|
SOCIAL_LABELS,
|
||||||
SOCIAL_NETWORKS,
|
SOCIAL_NETWORKS,
|
||||||
type SocialTarget,
|
type SocialTarget,
|
||||||
|
type TokenHealth,
|
||||||
} from "@/lib/social-accounts";
|
} from "@/lib/social-accounts";
|
||||||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
const HELP: Record<
|
const HELP: Record<
|
||||||
(typeof SOCIAL_NETWORKS)[number],
|
(typeof SOCIAL_NETWORKS)[number],
|
||||||
{ portal: string; steps: string[]; caution?: string }
|
{ portal: string; steps: string[]; caution?: string; tools?: { label: string; href: string }[] }
|
||||||
> = {
|
> = {
|
||||||
facebook: {
|
facebook: {
|
||||||
portal: "https://developers.facebook.com/apps",
|
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 ».",
|
"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.",
|
"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: {
|
linkedin: {
|
||||||
portal: "https://www.linkedin.com/developers/apps",
|
portal: "https://www.linkedin.com/developers/apps",
|
||||||
@@ -40,6 +48,10 @@ const HELP: Record<
|
|||||||
],
|
],
|
||||||
caution:
|
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.",
|
"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));
|
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.
|
// Cibles à proposer quand le compte administre plusieurs pages.
|
||||||
const targets = new Map<string, SocialTarget[]>();
|
const targets = new Map<string, SocialTarget[]>();
|
||||||
let targetError: string | null = null;
|
let targetError: string | null = null;
|
||||||
@@ -140,6 +162,8 @@ export default async function ReseauxPage({
|
|||||||
const status = expiryStatus(account?.expiresAt);
|
const status = expiryStatus(account?.expiresAt);
|
||||||
const isConnected = !!account?.accessToken && !!account.targetId;
|
const isConnected = !!account?.accessToken && !!account.targetId;
|
||||||
const candidates = targets.get(network) ?? [];
|
const candidates = targets.get(network) ?? [];
|
||||||
|
const tokenHealth = health.get(network) ?? null;
|
||||||
|
const broken = tokenHealth?.ok === false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={network} style={panel}>
|
<section key={network} style={panel}>
|
||||||
@@ -162,7 +186,7 @@ export default async function ReseauxPage({
|
|||||||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||||||
{SOCIAL_LABELS[network]}
|
{SOCIAL_LABELS[network]}
|
||||||
</h2>
|
</h2>
|
||||||
<StatusChip connected={isConnected} status={status} />
|
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
@@ -172,10 +196,25 @@ export default async function ReseauxPage({
|
|||||||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||||||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||||||
<br />
|
<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.
|
Pour changer de page, relancez une connexion.
|
||||||
</div>
|
</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 && (
|
{(status === "soon" || status === "expired") && isConnected && (
|
||||||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||||||
{status === "expired"
|
{status === "expired"
|
||||||
@@ -276,7 +315,28 @@ export default async function ReseauxPage({
|
|||||||
{help.portal}
|
{help.portal}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
<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" }}>
|
<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 —"}
|
{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 }),
|
targetName: varchar("target_name", { length: 200 }),
|
||||||
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
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(),
|
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";
|
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 ----
|
// ---- OAuth ----
|
||||||
|
|
||||||
export class SocialAuthError extends Error {}
|
export class SocialAuthError extends Error {}
|
||||||
|
|||||||
Reference in new issue
Block a user