import { headers } from "next/headers"; import { originFromHeaders } from "@/lib/site-url"; import { redirect } from "next/navigation"; import type { CSSProperties } from "react"; import { getSession } from "@/lib/session"; import { can } from "@/lib/rbac"; import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; import { checkTokenHealth, expiryStatus, getSocialAccounts, listStoredTargets, redirectUri, siteUrl, SOCIAL_LABELS, SOCIAL_NETWORKS, type SocialTarget, type TokenHealth, } from "@/lib/social-accounts"; import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions"; export const dynamic = "force-dynamic"; const HELP: Record< (typeof SOCIAL_NETWORKS)[number], { portal: string; steps: string[]; caution?: string; tools?: { label: string; href: string }[]; /** Les deux liens utiles au moment de renouveler un jeton. */ renew: { note: string; links: { label: string; hint: string; href: string }[] }; } > = { facebook: { portal: "https://developers.facebook.com/apps", steps: [ "Créez une application de type « Business » et relevez l'identifiant et la clé secrète (Paramètres › Général).", "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.", ], renew: { note: "Le bouton refait tout l'échange : jeton court, puis jeton de longue durée, puis jeton de page. " + "Vous n'avez aucun jeton à copier à la main — les deux liens ci-dessous ne servent qu'à vérifier.", links: [ { label: "Débogueur de jeton", hint: "coller un jeton pour voir sa validité et sa date d'expiration", href: "https://developers.facebook.com/tools/debug/accesstoken/", }, { label: "Outil de jetons d'accès", hint: "lister les jetons de vos pages et en générer un de longue durée", href: "https://developers.facebook.com/tools/accesstoken/", }, ], }, 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", steps: [ "Créez une application rattachée à la page LinkedIn de l'association et relevez le Client ID et le Client Secret (onglet Auth).", "Collez l'URL de redirection ci-dessous dans « Authorized redirect URLs ».", "Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.", ], 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 « Renouveler le jeton » environ tous les deux mois.", renew: { note: "Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " + "repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.", links: [ { label: "Jetons de votre application", hint: "voir la durée de validité restante des jetons émis", href: "https://www.linkedin.com/developers/tools/oauth", }, { label: "Documentation : durée de vie des jetons", hint: "règles de rafraîchissement et conditions d'accès", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens", }, ], }, 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" }, ], }, }; function fmtDate(d: Date) { return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }); } export default async function ReseauxPage({ searchParams, }: { searchParams: Promise<{ error?: string; connected?: string; choose?: string }>; }) { const session = await getSession(); if (!can(session?.user.role, "manageSettings")) redirect("/backend"); const { error, connected, choose } = await searchParams; const accounts = await getSocialAccounts(); const byNetwork = new Map(accounts.map((a) => [a.network, a])); const base = await siteUrl(); // Adresse par laquelle l'administrateur consulte cette page : proposée par // défaut pour lui éviter de la recopier à la main. const detected = originFromHeaders(await headers()); // Calculées ici : le rendu des cartes n'est pas asynchrone. const redirectUris = new Map(); for (const network of SOCIAL_NETWORKS) { 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(); 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(); let targetError: string | null = null; for (const network of SOCIAL_NETWORKS) { const account = byNetwork.get(network); if (account?.accessToken && !account.targetId) { try { targets.set(network, await listStoredTargets(network)); } catch (caught) { targetError = caught instanceof Error ? caught.message : "Pages illisibles."; } } } return (
{error && {error}} {targetError && {targetError}} {connected && ( Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté. )} {choose && ( Connexion réussie : choisissez la page à utiliser pour les publications. )}

URL publique du site

Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire l'adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans les publications.

{!base && detected && (
Aucune adresse enregistrée : celle de votre navigation, {detected}, est utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour en https sur un nom de domaine : vérifiez-la puis enregistrez.
)} {!base && !detected && (
Renseignez cette adresse avant de connecter un réseau social.
)}
{SOCIAL_NETWORKS.map((network) => { const account = byNetwork.get(network); const help = HELP[network]; 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; // Le bloc de renouvellement passe en ambre quand il y a urgence : // jeton mort, expiré, ou sur le point de l'être. const needsRenewal = broken || status === "expired" || status === "soon"; return (

{SOCIAL_LABELS[network]}

{isConnected && (
Publie sur {account?.targetName}. {status === "never" && " Ce jeton n'expire pas."} {account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`} {account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
{tokenHealth?.ok && ( ✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "} )} Pour changer de page ou renouveler l'accès, utilisez le bouton ci-dessous.
)} {broken && ( Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront tant que vous n'aurez pas cliqué sur « Renouveler le jeton ».
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
)} {(status === "soon" || status === "expired") && isConnected && ( {status === "expired" ? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.` : `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`} )} {candidates.length > 0 && (
Page à utiliser pour les publications
{candidates.map((target) => (
{target.name}
))}
)}
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles sans avoir à déplier l'aide de configuration. */} {isConnected && (
Renouveler le jeton
{help.renew.note}
Renouveler le jeton {SOCIAL_LABELS[network]}
{help.renew.links.map((link) => ( {link.label} ↗ {link.hint} ))}
)}
{/* Une fois connecté, l'action vit dans le bloc de renouvellement ci-dessus : deux boutons identiques côte à côte n'aidaient personne. */} {!isConnected && ( Connecter )} {isConnected && (
)}
Comment obtenir ces identifiants ?
    {help.steps.map((step) => (
  1. {step}
  2. ))}
Portail :{" "} {help.portal}
{help.tools && (
Outils pour inspecter un jeton
Ces outils servent à contrôler ou dépanner : le bouton Connecter 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.
)}
URL de redirection à déclarer
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
{help.caution && (
{help.caution}
)}
); })}
); } function StatusChip({ connected, status }: { connected: boolean; status: string }) { const [label, bg, color] = !connected ? ["Non connecté", "#f1efe7", "#a99c82"] : status === "expired" ? ["Jeton expiré", "#fbe9e6", "#d8472b"] : status === "soon" ? ["Expire bientôt", "#fbeede", "#9a6638"] : ["Connecté", "#e6f4ec", "#1f8a5b"]; return ( {label} ); } function Banner({ tone, children }: { tone: "ok" | "warn" | "error"; children: React.ReactNode }) { const palette = { ok: { bg: "#e6f4ec", border: "#c7e6d5", color: "#1f8a5b" }, warn: { bg: "#fbeede", border: "#ecd8b8", color: "#9a6638" }, error: { bg: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" }, }[tone]; return (
{children}
); } const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, }; const title: CSSProperties = { fontSize: 20, color: "#26201a", }; const submitButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 14, padding: "11px 20px", borderRadius: 10, cursor: "pointer", };