Connexion Facebook / LinkedIn en OAuth depuis le backoffice

Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.

- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
  secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
  `SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
  httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
  transite par une URL — les cibles sont relistées côté serveur au moment de la
  sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
  pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
  obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
  tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
  variables d'environnement ensuite. Les installations existantes continuent de
  fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
  valeur conservée.

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-07-24 23:00:08 +00:00
1 parent 8bc1b17fc5
commit 19d1104ab0
19 files changed
+2599 -82

No files matched your search

+17 -21
View File
@@ -35,30 +35,26 @@ PORT=3000
NEXT_PUBLIC_SITE_URL=
# ---- Publication des promotions sur les réseaux sociaux ----
# Facultatif : si ces variables sont vides, le bouton correspondant n'apparaît
# pas dans le backoffice (le reste du site fonctionne normalement).
# Ce sont des SECRETS : ils ne doivent jamais être saisis dans le backoffice.
# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle
# les identifiants de l'application, on clique Connecter, et les jetons sont
# récupérés et stockés chiffrés automatiquement. Rien à mettre ici dans le cas
# normal.
#
# Facebook — page de l'association
# 1. Créer une app sur developers.facebook.com (type « Business »).
# 2. Ajouter le produit « Facebook Login » et les permissions
# pages_manage_posts + pages_read_engagement.
# 3. Générer un jeton de PAGE longue durée (Graph API Explorer puis
# /oauth/access_token?grant_type=fb_exchange_token) et le coller ci-dessous.
# Clé de chiffrement des jetons en base. Si elle est vide, AUTH_SECRET est
# utilisé. La définir explicitement permet de faire tourner AUTH_SECRET sans
# perdre les connexions réseaux.
# openssl rand -base64 32
SOCIAL_TOKEN_KEY=
# Versions d'API (facultatif)
FACEBOOK_GRAPH_VERSION=v21.0
LINKEDIN_API_VERSION=202506
# --- Repli historique ---
# Ces variables restent lues si aucun compte n'est connecté depuis le
# backoffice. Utile pour un jeton posé à la main ; sinon, laissez vide.
FACEBOOK_PAGE_ID=
FACEBOOK_PAGE_ACCESS_TOKEN=
# Version de la Graph API (facultatif)
FACEBOOK_GRAPH_VERSION=v21.0
#
# LinkedIn — page organisation
# 1. Créer une app sur linkedin.com/developers, la rattacher à la page
# « Association Plein R — Bassin de Pompey ».
# 2. Demander le produit « Community Management API ».
# 3. Générer un jeton avec les scopes w_organization_social + r_organization_social.
# L'URN se lit dans l'URL d'administration de la page (numéro d'organisation).
LINKEDIN_ORGANIZATION_URN=
# Alternative à l'URN : uniquement le numéro d'organisation
LINKEDIN_ORGANIZATION_ID=
LINKEDIN_ACCESS_TOKEN=
# Version de l'API LinkedIn (facultatif, format AAAAMM)
LINKEDIN_API_VERSION=202506
+11 -2
View File
@@ -52,8 +52,17 @@ site sans traitement supplémentaire.
## Réseaux sociaux
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais
en base ni dans le backoffice. Réseau non configuré = case masquée.
LinkedIn (Posts API). `src/lib/social-accounts.ts` gère la configuration : OAuth,
jetons, cibles. Réseau non configuré = case masquée.
- Les identifiants et jetons vivent en base (`social_accounts`), **chiffrés** via
`src/lib/crypto.ts` (AES-256-GCM, clé `SOCIAL_TOKEN_KEY` ou `AUTH_SECRET`), posés
depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli.
Aucun secret ne doit jamais repartir vers le navigateur.
- `isNetworkConfigured()` / `configuredNetworks()` sont **asynchrones** (accès base).
- Routes OAuth : `src/app/api/social/[network]/{connect,callback}`. Le `state`
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.
- 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.
+39 -9
View File
@@ -102,12 +102,40 @@ Une promotion n'est jamais publiée deux fois : un réseau ayant déjà une
publication réussie est systématiquement ignoré, y compris sur un cycle
suspension → remise en ligne.
Les jetons d'accès sont des **secrets** : ils se configurent uniquement par
variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`,
`LINKEDIN_ORGANIZATION_URN` ou `LINKEDIN_ORGANIZATION_ID`,
`LINKEDIN_ACCESS_TOKEN`), jamais depuis le backoffice. Voir
[`.env.example`](./.env.example) pour la marche à suivre côté Meta et LinkedIn.
Si un réseau n'est pas configuré, son bouton n'apparaît simplement pas.
### Connecter les comptes
Tout se passe dans **Backend › Réseaux sociaux** (administrateurs) : on colle les
identifiants de l'application, on clique **Connecter**, on choisit la page. Les
jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé
`SOCIAL_TOKEN_KEY` ou à défaut `AUTH_SECRET`) ; ils ne ressortent jamais vers le
navigateur. Un réseau non connecté voit simplement sa case disparaître du
formulaire de promotion.
L'écran affiche l'URL de redirection à déclarer sur le portail développeur —
c'est l'erreur de configuration la plus fréquente.
**Facebook.** Créez une application « Business » sur
[developers.facebook.com](https://developers.facebook.com/apps), ajoutez le
produit Connexion Facebook, déclarez l'URL de redirection. Gardez l'application
en **mode développement** avec le compte de l'association comme administrateur :
publier sur votre propre page ne demande alors aucune revue Meta. Le jeton de
page obtenu **n'expire pas** — une connexion suffit, définitivement.
**LinkedIn.** Créez une application sur
[linkedin.com/developers](https://www.linkedin.com/developers/apps) rattachée à
la page de l'association, puis demandez le produit **Community Management API**.
Deux limites à connaître avant de vous lancer :
- l'accès est soumis à une revue (page vérifiée, nom légal, adresse, politique
de confidentialité) ; ce n'est pas garanti ni immédiat ;
- les jetons LinkedIn durent **60 jours** et le rafraîchissement programmatique
est réservé à certains partenaires. En pratique il faut donc recliquer sur
**Reconnecter** environ tous les deux mois. Le backoffice affiche la date
d'expiration et un bandeau d'alerte 7 jours avant.
Les variables d'environnement (`FACEBOOK_PAGE_ACCESS_TOKEN`, etc.) restent lues
en **repli** si aucun compte n'est connecté, pour ne pas casser une installation
antérieure.
Les **liens publics** vers les deux pages (affichés sur l'accueil et dans le pied
de page) se règlent, eux, dans **Backend › Paramètres**.
@@ -143,9 +171,11 @@ Voir [`.env.example`](./.env.example). Les principales :
- `AUTH_URL` — URL publique de l'application
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
- `NEXT_PUBLIC_SITE_URL` — URL publique reprise dans les posts réseaux sociaux
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN` — publication Facebook (optionnel)
- `LINKEDIN_ORGANIZATION_URN` / `LINKEDIN_ACCESS_TOKEN` — publication LinkedIn (optionnel)
- `NEXT_PUBLIC_SITE_URL` — URL publique : sert au lien des posts **et** à l'adresse
de retour OAuth. Obligatoire pour connecter un réseau social.
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
## Note sur le logo
+3 -1
View File
@@ -49,7 +49,9 @@ services:
AUTH_TRUST_HOST: "true"
# URL publique, reprise dans le texte des posts Facebook / LinkedIn.
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
# Publication des promotions sur les réseaux (facultatif — voir .env.example).
# Chiffrement des jetons réseaux stockés en base (à défaut : AUTH_SECRET).
SOCIAL_TOKEN_KEY: ${SOCIAL_TOKEN_KEY:-}
# Repli historique : la configuration normale se fait dans le backoffice.
FACEBOOK_PAGE_ID: ${FACEBOOK_PAGE_ID:-}
FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-}
FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-}
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE "social_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"network" "social_network" NOT NULL,
"app_id" varchar(200) NOT NULL,
"app_secret" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"target_id" varchar(200),
"target_name" varchar(200),
"connected_by_id" integer,
"connected_at" timestamp with time zone,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "social_accounts_network_unique" UNIQUE("network")
);
--> statement-breakpoint
ALTER TABLE "social_accounts" ADD CONSTRAINT "social_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -57,6 +57,13 @@
"when": 1784931989372,
"tag": "0007_foamy_vindicator",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1784933339220,
"tag": "0008_neat_makkari",
"breakpoints": true
}
]
}
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { can } from "@/lib/rbac";
import {
exchangeCode,
getDecryptedAppSecret,
getSocialAccount,
saveConnection,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/reseaux";
function back(request: Request, params: Record<string, string>) {
const url = new URL(SETTINGS, request.url);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = NextResponse.redirect(url);
// Le state a fait son office, quel que soit le résultat.
for (const network of SOCIAL_NETWORKS) response.cookies.delete(`plr_oauth_${network}`);
return response;
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await auth();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { network: raw } = await params;
const network = raw as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) {
return back(request, { error: "Réseau inconnu." });
}
const url = new URL(request.url);
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (error) {
return back(request, { error: `Autorisation refusée : ${error}` });
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = request.headers
.get("cookie")
?.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`plr_oauth_${network}=`))
?.split("=")[1];
if (!code || !state || !expected || state !== expected) {
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
}
const account = await getSocialAccount(network);
const appSecret = await getDecryptedAppSecret(network);
if (!account?.appId || !appSecret) {
return back(request, { error: "Identifiants d'application introuvables." });
}
try {
const result = await exchangeCode(network, account.appId, appSecret, code);
const userId = Number(session?.user.id);
// Une seule page administrée : on la sélectionne d'office. Sinon on laisse
// choisir, en conservant le jeton utilisateur le temps de la sélection.
if (result.targets.length === 1) {
await saveConnection(network, result, result.targets[0], Number.isFinite(userId) ? userId : null);
return back(request, { connected: network });
}
// Plusieurs pages : on garde le jeton utilisateur, l'écran relistera les
// cibles côté serveur. Rien de sensible ne transite par l'URL.
await saveConnection(network, result, null, Number.isFinite(userId) ? userId : null);
return back(request, { choose: network });
} catch (caught) {
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
return back(request, { error: message.slice(0, 400) });
}
}
@@ -0,0 +1,62 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { can } from "@/lib/rbac";
import {
authorizeUrl,
getDecryptedAppSecret,
getSocialAccount,
siteUrl,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/reseaux";
function back(request: Request, error: string) {
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await auth();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { network: raw } = await params;
const network = raw as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) {
return back(request, "Réseau inconnu.");
}
if (!siteUrl()) {
return back(
request,
"L'URL publique du site n'est pas configurée (NEXT_PUBLIC_SITE_URL) : impossible de construire l'adresse de retour."
);
}
const account = await getSocialAccount(network);
const appSecret = await getDecryptedAppSecret(network);
if (!account?.appId || !appSecret) {
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
}
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée.
const state = randomBytes(24).toString("hex");
const response = NextResponse.redirect(authorizeUrl(network, account.appId, state));
response.cookies.set(`plr_oauth_${network}`, state, {
httpOnly: true,
sameSite: "lax",
secure: new URL(siteUrl()).protocol === "https:",
path: "/",
maxAge: 600,
});
return response;
}
+5
View File
@@ -171,6 +171,11 @@ export function BackendShell({
{dotDiamond}Catégories
</NavLink>
)}
{can(user.role, "manageSettings") && (
<NavLink href="/backend/reseaux" active={pathname === "/backend/reseaux"}>
{dot}Réseaux sociaux
</NavLink>
)}
{can(user.role, "manageSettings") && (
<NavLink href="/backend/parametres" active={pathname === "/backend/parametres"}>
{dot}Paramètres
+54 -1
View File
@@ -31,6 +31,11 @@ import {
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social";
import {
disconnectAccount,
saveAppCredentials,
selectTarget,
} from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth";
@@ -164,7 +169,7 @@ async function publishPromoShares(
for (const network of networks) {
if (alreadyPosted.has(network)) continue;
if (!isNetworkConfigured(network)) {
if (!(await isNetworkConfigured(network))) {
await db.insert(socialPosts).values({
promotionId: promoId,
network,
@@ -1128,6 +1133,54 @@ export async function setContactStatus(formData: FormData) {
revalidatePath("/backend");
}
// ---- Réseaux sociaux : identifiants d'application et connexion ----
export async function saveSocialApp(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const network = String(formData.get("network")) as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) return;
const appId = asString(formData, "appId");
if (!appId) throw new Error("L'identifiant de l'application est requis.");
// Champ secret laissé vide = on garde celui déjà enregistré.
const appSecret = asString(formData, "appSecret") || null;
await saveAppCredentials(network, appId, appSecret);
revalidatePath("/backend/reseaux");
}
export async function selectSocialTarget(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const network = String(formData.get("network")) as SocialNetwork;
const targetId = asString(formData, "targetId");
if (!SOCIAL_NETWORKS.includes(network) || !targetId) return;
await selectTarget(network, targetId);
await logActivity(`Page ${SOCIAL_LABELS[network]} sélectionnée pour la publication`, "#2C6FB3");
revalidatePath("/backend/reseaux");
revalidatePath("/backend/promotions");
}
export async function disconnectSocial(formData: FormData) {
const { role, name } = await requireRole();
if (!can(role, "manageSettings")) throw new Error("Accès refusé");
const network = String(formData.get("network")) as SocialNetwork;
if (!SOCIAL_NETWORKS.includes(network)) return;
await disconnectAccount(network);
await logActivity(
`Compte ${SOCIAL_LABELS[network]} déconnecté par <strong>${name}</strong>`,
"#d8472b"
);
revalidatePath("/backend/reseaux");
revalidatePath("/backend/promotions");
revalidatePath("/backend/espace");
}
// ---- Sign out ----
export async function doSignOut() {
await signOut({ redirectTo: "/" });
+1 -1
View File
@@ -125,7 +125,7 @@ export default async function EspacePage() {
.from(categories)
.orderBy(asc(categories.sort));
const categoryLabels = catRows.map((c) => c.label);
const networks = configuredNetworks();
const networks = await configuredNetworks();
function fmtDate(d: Date) {
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
+39 -1
View File
@@ -4,7 +4,8 @@ import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
import { isStaff } from "@/lib/rbac";
import { can, isStaff } from "@/lib/rbac";
import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
@@ -49,6 +50,15 @@ 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")
: [];
function timeAgo(date: Date) {
const diff = Date.now() - new Date(date).getTime();
const h = Math.floor(diff / 3_600_000);
@@ -60,6 +70,34 @@ export default async function DashboardPage() {
return (
<div>
{expiringNetworks.length > 0 && (
<Link
href="/backend/reseaux"
style={{
display: "block",
textDecoration: "none",
background: "#fbeede",
border: "1px solid #ecd8b8",
color: "#9a6638",
borderRadius: 12,
padding: "13px 16px",
marginBottom: 20,
fontSize: 13.5,
lineHeight: 1.6,
}}
>
{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."}{" "}
Reconnecter le compte →
</div>
))}
</Link>
)}
<div className="grid grid-4" style={{ marginBottom: 26 }}>
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function PromotionsPage() {
redirect("/backend");
}
const canShare = can(session?.user.role, "publishSocial");
const networks = canShare ? configuredNetworks() : [];
const networks = canShare ? await configuredNetworks() : [];
const rows = await db
.select({
+308
View File
@@ -0,0 +1,308 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { auth } from "@/auth";
import { can } from "@/lib/rbac";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import {
expiryStatus,
getSocialAccounts,
listStoredTargets,
redirectUri,
siteUrl,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialTarget,
} from "@/lib/social-accounts";
import { disconnectSocial, saveSocialApp, selectSocialTarget } from "../actions";
export const dynamic = "force-dynamic";
const HELP: Record<
(typeof SOCIAL_NETWORKS)[number],
{ portal: string; steps: string[]; caution?: 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.",
],
},
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 Reconnecter environ tous les deux mois.",
},
};
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 auth();
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 = siteUrl();
// Cibles à proposer quand le compte administre plusieurs pages.
const targets = new Map<string, SocialTarget[]>();
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 (
<div style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
{error && <Banner tone="error">{error}</Banner>}
{targetError && <Banner tone="error">{targetError}</Banner>}
{connected && (
<Banner tone="ok">
Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté.
</Banner>
)}
{choose && (
<Banner tone="warn">
Connexion réussie : choisissez la page à utiliser pour les publications.
</Banner>
)}
{!base && (
<Banner tone="warn">
L&apos;URL publique du site n&apos;est pas renseignée (variable
<code> NEXT_PUBLIC_SITE_URL</code>). Elle est indispensable pour construire
l&apos;adresse de retour OAuth : renseignez-la avant de connecter un réseau.
</Banner>
)}
{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) ?? [];
return (
<section key={network} style={panel}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 6 }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 12,
background: SOCIAL_BRAND[network],
color: "#fff",
flexShrink: 0,
}}
>
<SocialIcon network={network} size={20} />
</span>
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
{SOCIAL_LABELS[network]}
</h2>
<StatusChip connected={isConnected} status={status} />
</div>
{isConnected && (
<div style={{ fontSize: 13.5, color: "#6c6150", marginBottom: 14, lineHeight: 1.6 }}>
Publie sur <strong style={{ color: "#26201a" }}>{account?.targetName}</strong>.
{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)}.`}
<br />
Pour changer de page, relancez une connexion.
</div>
)}
{(status === "soon" || status === "expired") && isConnected && (
<Banner tone={status === "expired" ? "error" : "warn"}>
{status === "expired"
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
</Banner>
)}
{candidates.length > 0 && (
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: 15, marginBottom: 16 }}>
<div className="field-label" style={{ marginBottom: 9 }}>
Page à utiliser pour les publications
</div>
<div style={{ display: "grid", gap: 8 }}>
{candidates.map((target) => (
<form key={target.id} action={selectSocialTarget} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input type="hidden" name="network" value={network} />
<input type="hidden" name="targetId" value={target.id} />
<span style={{ flex: 1, fontSize: 13.5, color: "#3c3322", fontWeight: 600 }}>
{target.name}
</span>
<button type="submit" style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}>
Choisir
</button>
</form>
))}
</div>
</div>
)}
<form action={saveSocialApp} style={{ marginBottom: 14 }}>
<input type="hidden" name="network" value={network} />
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">
{network === "facebook" ? "Identifiant de l'application" : "Client ID"}
</label>
<input name="appId" defaultValue={account?.appId ?? ""} className="field" required />
</div>
<div>
<label className="field-label">
{network === "facebook" ? "Clé secrète" : "Client Secret"}
</label>
<input
name="appSecret"
type="password"
className="field"
autoComplete="new-password"
placeholder={account ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
/>
</div>
</div>
<button type="submit" style={{ ...submitButton, marginTop: 14 }}>
Enregistrer les identifiants
</button>
</form>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<a
href={`/api/social/${network}/connect`}
className="font-display"
style={{
textDecoration: "none",
border: "none",
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
color: "#fff",
fontWeight: 700,
fontSize: 14,
padding: "12px 20px",
borderRadius: 10,
pointerEvents: account?.appId ? undefined : "none",
}}
>
{isConnected ? "Reconnecter" : "Connecter"}
</a>
{isConnected && (
<form action={disconnectSocial}>
<input type="hidden" name="network" value={network} />
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 14, padding: "12px 18px", borderRadius: 10, cursor: "pointer" }}>
Déconnecter
</button>
</form>
)}
</div>
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 13.5, fontWeight: 700, color: "#9a6638" }}>
Comment obtenir ces identifiants ?
</summary>
<ol style={{ margin: "12px 0 0", paddingLeft: 20, fontSize: 13.5, color: "#6c6150", lineHeight: 1.7 }}>
{help.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<div style={{ marginTop: 12, fontSize: 13, color: "#6c6150" }}>
Portail :{" "}
<a href={help.portal} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
{help.portal}
</a>
</div>
<div style={{ marginTop: 10 }}>
<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 ? redirectUri(network) : "— renseignez d'abord NEXT_PUBLIC_SITE_URL —"}
</code>
</div>
{help.caution && (
<div style={{ marginTop: 12, background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 10, padding: "11px 13px", fontSize: 13, lineHeight: 1.6 }}>
{help.caution}
</div>
)}
</details>
</section>
);
})}
</div>
);
}
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 (
<span style={{ background: bg, color, borderRadius: 999, padding: "6px 13px", fontSize: 12, fontWeight: 800 }}>
{label}
</span>
);
}
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 (
<div style={{ background: palette.bg, border: `1px solid ${palette.border}`, color: palette.color, borderRadius: 12, padding: "12px 16px", fontSize: 13.5, lineHeight: 1.6 }}>
{children}
</div>
);
}
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",
};
+20
View File
@@ -131,6 +131,25 @@ export const socialPosts = pgTable(
})
);
// ---- Comptes réseaux sociaux connectés (OAuth) ----
// Une ligne par réseau. Les secrets sont chiffrés (src/lib/crypto.ts) et ne
// ressortent jamais vers le navigateur.
export const socialAccounts = pgTable("social_accounts", {
id: serial("id").primaryKey(),
network: socialNetworkEnum("network").notNull().unique(),
appId: varchar("app_id", { length: 200 }).notNull(),
appSecret: text("app_secret").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
// Nul = n'expire pas (jeton de page Facebook).
expiresAt: timestamp("expires_at", { withTimezone: true }),
targetId: varchar("target_id", { length: 200 }),
targetName: varchar("target_name", { length: 200 }),
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
connectedAt: timestamp("connected_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
// ---- Membership requests (demandes d'adhésion) ----
export const membershipRequests = pgTable("membership_requests", {
id: serial("id").primaryKey(),
@@ -316,6 +335,7 @@ export type Member = typeof members.$inferSelect;
export type User = typeof users.$inferSelect;
export type Promotion = typeof promotions.$inferSelect;
export type SocialPost = typeof socialPosts.$inferSelect;
export type SocialAccount = typeof socialAccounts.$inferSelect;
export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number];
export type MembershipRequest = typeof membershipRequests.$inferSelect;
export type ContactMessage = typeof contactMessages.$inferSelect;
+62
View File
@@ -0,0 +1,62 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
/**
* Chiffrement des secrets stockés en base (jetons OAuth, secrets d'application).
*
* AES-256-GCM : le tag d'authentification garantit qu'une valeur altérée en base
* est rejetée au lieu d'être déchiffrée en silence.
*
* La clé dérive de `SOCIAL_TOKEN_KEY`, avec repli sur `AUTH_SECRET` pour ne rien
* imposer aux déploiements existants. Conséquence : changer `AUTH_SECRET` sans
* avoir posé `SOCIAL_TOKEN_KEY` rend les secrets illisibles — il suffit alors de
* reconnecter les comptes, aucune donnée métier n'est perdue.
*/
const PREFIX = "v1";
const SALT = "pleinr.social.v1";
function secretMaterial(): string {
const key = (process.env.SOCIAL_TOKEN_KEY ?? "").trim() || (process.env.AUTH_SECRET ?? "").trim();
if (!key) {
throw new Error(
"Chiffrement indisponible : définissez SOCIAL_TOKEN_KEY (ou AUTH_SECRET) sur le serveur."
);
}
return key;
}
function key(): Buffer {
return scryptSync(secretMaterial(), SALT, 32);
}
export function encryptSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [PREFIX, iv.toString("base64"), tag.toString("base64"), encrypted.toString("base64")].join(":");
}
export function decryptSecret(stored: string): string {
const parts = stored.split(":");
if (parts.length !== 4 || parts[0] !== PREFIX) {
throw new Error("Secret chiffré illisible (format inattendu).");
}
const [, iv, tag, payload] = parts;
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64"));
decipher.setAuthTag(Buffer.from(tag, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(payload, "base64")),
decipher.final(),
]).toString("utf8");
}
/** Déchiffre sans lever : une clé changée ne doit pas casser l'affichage. */
export function tryDecryptSecret(stored: string | null): string | null {
if (!stored) return null;
try {
return decryptSecret(stored);
} catch {
return null;
}
}
+469
View File
@@ -0,0 +1,469 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
import { decryptSecret, encryptSecret } from "./crypto";
/**
* Configuration des comptes Facebook / LinkedIn.
*
* Les identifiants d'application et les jetons vivent en base (chiffrés), posés
* depuis Backend › Réseaux sociaux. Les variables d'environnement restent
* acceptées en **repli** pour ne pas casser les déploiements antérieurs.
*/
export type { SocialNetwork };
export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"];
export const SOCIAL_LABELS: Record<SocialNetwork, string> = {
facebook: "Facebook",
linkedin: "LinkedIn",
};
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0";
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506";
/** Un jeton LinkedIn qui expire dans moins de 7 jours est signalé. */
export const EXPIRY_WARNING_DAYS = 7;
function env(key: string): string {
return (process.env[key] ?? "").trim();
}
export function siteUrl(): string {
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
}
export function redirectUri(network: SocialNetwork): string {
return `${siteUrl()}/api/social/${network}/callback`;
}
// ---- Lecture ----
export async function getSocialAccount(network: SocialNetwork): Promise<SocialAccount | null> {
const [row] = await db.select().from(socialAccounts).where(eq(socialAccounts.network, network));
return row ?? null;
}
export async function getSocialAccounts(): Promise<SocialAccount[]> {
return db.select().from(socialAccounts);
}
export type SocialCredentials = {
source: "db" | "env";
accessToken: string;
targetId: string;
expiresAt: Date | null;
};
/** Identifiants utilisables pour publier : la base d'abord, l'environnement ensuite. */
export async function resolveCredentials(
network: SocialNetwork
): Promise<SocialCredentials | null> {
const account = await getSocialAccount(network);
if (account?.accessToken && account.targetId) {
try {
return {
source: "db",
accessToken: decryptSecret(account.accessToken),
targetId: account.targetId,
expiresAt: account.expiresAt,
};
} catch {
// Clé de chiffrement changée : on retombe sur l'environnement s'il existe.
}
}
if (network === "facebook") {
const token = env("FACEBOOK_PAGE_ACCESS_TOKEN");
const pageId = env("FACEBOOK_PAGE_ID");
if (token && pageId) return { source: "env", accessToken: token, targetId: pageId, expiresAt: null };
return null;
}
const token = env("LINKEDIN_ACCESS_TOKEN");
const urn = env("LINKEDIN_ORGANIZATION_URN") ||
(env("LINKEDIN_ORGANIZATION_ID") ? `urn:li:organization:${env("LINKEDIN_ORGANIZATION_ID")}` : "");
if (token && urn) return { source: "env", accessToken: token, targetId: urn, expiresAt: null };
return null;
}
export async function isNetworkConfigured(network: SocialNetwork): Promise<boolean> {
return (await resolveCredentials(network)) !== null;
}
export async function configuredNetworks(): Promise<SocialNetwork[]> {
const found = await Promise.all(
SOCIAL_NETWORKS.map(async (n) => ((await isNetworkConfigured(n)) ? n : null))
);
return found.filter((n): n is SocialNetwork => n !== null);
}
/** État d'expiration, pour le bandeau d'alerte du backoffice. */
export type ExpiryStatus = "never" | "ok" | "soon" | "expired";
export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
if (!expiresAt) return "never";
const remainingMs = new Date(expiresAt).getTime() - Date.now();
if (remainingMs <= 0) return "expired";
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
}
// ---- OAuth ----
export class SocialAuthError extends Error {}
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
const SCOPES: Record<SocialNetwork, string> = {
facebook: "pages_show_list,pages_manage_posts,pages_read_engagement",
linkedin: "w_organization_social r_organization_social rw_organization_admin",
};
export function authorizeUrl(network: SocialNetwork, appId: string, state: string): string {
if (network === "facebook") {
const params = new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri("facebook"),
state,
scope: SCOPES.facebook,
response_type: "code",
});
return `https://www.facebook.com/${FACEBOOK_GRAPH_VERSION}/dialog/oauth?${params}`;
}
const params = new URLSearchParams({
response_type: "code",
client_id: appId,
redirect_uri: redirectUri("linkedin"),
state,
scope: SCOPES.linkedin,
});
return `https://www.linkedin.com/oauth/v2/authorization?${params}`;
}
/** Cible publiable : page Facebook ou organisation LinkedIn. */
export type SocialTarget = { id: string; name: string; token?: string };
export type ExchangeResult = {
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
targets: SocialTarget[];
};
export async function exchangeCode(
network: SocialNetwork,
appId: string,
appSecret: string,
code: string
): Promise<ExchangeResult> {
return network === "facebook"
? exchangeFacebook(appId, appSecret, code)
: exchangeLinkedIn(appId, appSecret, code);
}
async function exchangeFacebook(
appId: string,
appSecret: string,
code: string
): Promise<ExchangeResult> {
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}`;
const shortRes = await fetch(
`${base}/oauth/access_token?${new URLSearchParams({
client_id: appId,
client_secret: appSecret,
redirect_uri: redirectUri("facebook"),
code,
})}`
);
if (!shortRes.ok) throw new SocialAuthError(`Facebook (code) : ${await readError(shortRes)}`);
const short = (await shortRes.json()) as { access_token?: string };
if (!short.access_token) throw new SocialAuthError("Facebook : jeton court absent de la réponse.");
// Jeton utilisateur longue durée (~60 j) : c'est lui qui rend les jetons de
// page permanents.
const longRes = await fetch(
`${base}/oauth/access_token?${new URLSearchParams({
grant_type: "fb_exchange_token",
client_id: appId,
client_secret: appSecret,
fb_exchange_token: short.access_token,
})}`
);
if (!longRes.ok) throw new SocialAuthError(`Facebook (jeton longue durée) : ${await readError(longRes)}`);
const long = (await longRes.json()) as { access_token?: string };
const userToken = long.access_token ?? short.access_token;
const pagesRes = await fetch(
`${base}/me/accounts?${new URLSearchParams({
fields: "id,name,access_token",
access_token: userToken,
})}`
);
if (!pagesRes.ok) throw new SocialAuthError(`Facebook (pages) : ${await readError(pagesRes)}`);
const pages = (await pagesRes.json()) as {
data?: { id: string; name: string; access_token: string }[];
};
const targets = (pages.data ?? []).map((p) => ({ id: p.id, name: p.name, token: p.access_token }));
if (targets.length === 0) {
throw new SocialAuthError(
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page Plein R."
);
}
// Le jeton de page ne dépend pas de l'expiration du jeton utilisateur.
return { accessToken: userToken, refreshToken: null, expiresAt: null, targets };
}
async function exchangeLinkedIn(
clientId: string,
clientSecret: string,
code: string
): Promise<ExchangeResult> {
const tokenRes = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri("linkedin"),
}),
});
if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`);
const token = (await tokenRes.json()) as {
access_token?: string;
expires_in?: number;
refresh_token?: string;
};
if (!token.access_token) throw new SocialAuthError("LinkedIn : jeton absent de la réponse.");
const targets = await listLinkedInOrganizations(token.access_token);
if (targets.length === 0) {
throw new SocialAuthError(
"Aucune page LinkedIn administrée par ce compte. Connectez-vous avec un administrateur de la page de l'association."
);
}
return {
accessToken: token.access_token,
refreshToken: token.refresh_token ?? null,
expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000) : null,
targets,
};
}
function linkedinHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
"LinkedIn-Version": LINKEDIN_VERSION,
"X-Restli-Protocol-Version": "2.0.0",
};
}
async function listLinkedInOrganizations(token: string): Promise<SocialTarget[]> {
const res = await fetch(
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED",
{ headers: linkedinHeaders(token) }
);
if (!res.ok) throw new SocialAuthError(`LinkedIn (organisations) : ${await readError(res)}`);
const json = (await res.json()) as {
elements?: { organization?: string; organizationTarget?: string }[];
};
// Le finder renvoie tantôt `organization`, tantôt `organizationTarget`.
const urns = Array.from(
new Set((json.elements ?? []).map((e) => e.organization ?? e.organizationTarget).filter(Boolean))
) as string[];
return Promise.all(
urns.map(async (urn) => ({ id: urn, name: (await linkedInOrgName(token, urn)) ?? urn }))
);
}
async function linkedInOrgName(token: string, urn: string): Promise<string | null> {
const id = urn.split(":").pop();
if (!id) return null;
try {
const res = await fetch(`https://api.linkedin.com/rest/organizations/${id}`, {
headers: linkedinHeaders(token),
});
if (!res.ok) return null;
const json = (await res.json()) as { localizedName?: string };
return json.localizedName ?? null;
} catch {
return null; // Le nom est un confort : l'URN suffit à publier.
}
}
/**
* Reliste les cibles publiables avec le jeton déjà enregistré. Sert à l'écran de
* sélection quand le compte administre plusieurs pages : les jetons de page ne
* transitent ainsi jamais par une URL.
*/
export async function listStoredTargets(network: SocialNetwork): Promise<SocialTarget[]> {
const account = await getSocialAccount(network);
if (!account?.accessToken) return [];
const token = decryptSecret(account.accessToken);
if (network === "linkedin") return listLinkedInOrganizations(token);
const res = await fetch(
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me/accounts?${new URLSearchParams({
fields: "id,name,access_token",
access_token: token,
})}`
);
if (!res.ok) throw new SocialAuthError(`Facebook (pages) : ${await readError(res)}`);
const json = (await res.json()) as { data?: { id: string; name: string; access_token: string }[] };
return (json.data ?? []).map((p) => ({ id: p.id, name: p.name, token: p.access_token }));
}
// ---- Écriture ----
export async function saveAppCredentials(
network: SocialNetwork,
appId: string,
appSecret: string | null
) {
const existing = await getSocialAccount(network);
if (!existing) {
if (!appSecret) throw new Error("Le secret de l'application est requis à la première saisie.");
await db.insert(socialAccounts).values({
network,
appId,
appSecret: encryptSecret(appSecret),
updatedAt: new Date(),
});
return;
}
await db
.update(socialAccounts)
.set({
appId,
// Champ laissé vide = on conserve le secret déjà enregistré.
...(appSecret ? { appSecret: encryptSecret(appSecret) } : {}),
updatedAt: new Date(),
})
.where(eq(socialAccounts.network, network));
}
export async function saveConnection(
network: SocialNetwork,
result: ExchangeResult,
target: SocialTarget | null,
userId: number | null
) {
await db
.update(socialAccounts)
.set({
// Facebook : c'est le jeton de la page qui sert à publier, pas celui de
// l'utilisateur — et lui n'expire pas.
accessToken: encryptSecret(target?.token ?? result.accessToken),
refreshToken: result.refreshToken ? encryptSecret(result.refreshToken) : null,
expiresAt: target?.token ? null : result.expiresAt,
targetId: target?.id ?? null,
targetName: target?.name ?? null,
connectedById: userId,
connectedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(socialAccounts.network, network));
}
/**
* Fixe la page / organisation à utiliser, une fois la connexion faite. Côté
* Facebook, on bascule ici du jeton utilisateur vers le jeton de page — celui
* qui n'expire pas.
*/
export async function selectTarget(network: SocialNetwork, targetId: string) {
const targets = await listStoredTargets(network);
const target = targets.find((t) => t.id === targetId);
if (!target) throw new Error("Page introuvable : relancez la connexion.");
await db
.update(socialAccounts)
.set({
targetId: target.id,
targetName: target.name,
...(target.token
? { accessToken: encryptSecret(target.token), expiresAt: null }
: {}),
updatedAt: new Date(),
})
.where(eq(socialAccounts.network, network));
}
export async function disconnectAccount(network: SocialNetwork) {
await db
.update(socialAccounts)
.set({
accessToken: null,
refreshToken: null,
expiresAt: null,
targetId: null,
targetName: null,
connectedById: null,
connectedAt: null,
updatedAt: new Date(),
})
.where(eq(socialAccounts.network, network));
}
export async function getDecryptedAppSecret(network: SocialNetwork): Promise<string | null> {
const account = await getSocialAccount(network);
if (!account) return null;
try {
return decryptSecret(account.appSecret);
} catch {
return null;
}
}
/**
* Rafraîchit un jeton LinkedIn proche de l'expiration. N'est possible que si
* LinkedIn a accordé les « programmatic refresh tokens » à l'application ;
* sinon on s'appuie sur le bandeau de reconnexion du backoffice.
*/
export async function refreshLinkedInIfNeeded(): Promise<void> {
const account = await getSocialAccount("linkedin");
if (!account?.refreshToken || !account.accessToken) return;
if (expiryStatus(account.expiresAt) === "ok") return;
try {
const res = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: decryptSecret(account.refreshToken),
client_id: account.appId,
client_secret: decryptSecret(account.appSecret),
}),
});
if (!res.ok) return;
const json = (await res.json()) as {
access_token?: string;
expires_in?: number;
refresh_token?: string;
};
if (!json.access_token) return;
await db
.update(socialAccounts)
.set({
accessToken: encryptSecret(json.access_token),
refreshToken: json.refresh_token ? encryptSecret(json.refresh_token) : account.refreshToken,
expiresAt: json.expires_in ? new Date(Date.now() + json.expires_in * 1000) : null,
updatedAt: new Date(),
})
.where(eq(socialAccounts.network, "linkedin"));
} catch {
// Échec silencieux : la publication signalera l'erreur si le jeton est mort.
}
}
+29 -45
View File
@@ -1,51 +1,31 @@
/**
* Publication des promotions sur les pages Facebook / LinkedIn de l'association.
*
* Les jetons d'accès sont des SECRETS : ils vivent dans les variables
* d'environnement du conteneur, jamais en base ni dans le backoffice. Voir
* `.env.example` pour la marche à suivre côté Meta / LinkedIn.
* Les identifiants et jetons viennent de `social-accounts.ts` : base de données
* en premier (posés depuis Backend › Réseaux sociaux), variables
* d'environnement en repli.
*
* Si un réseau n'est pas configuré, le backoffice masque simplement son bouton :
* Si un réseau n'est pas configuré, le backoffice masque simplement sa case :
* le reste du site fonctionne normalement.
*/
export type SocialNetwork = "facebook" | "linkedin";
import {
refreshLinkedInIfNeeded,
resolveCredentials,
siteUrl,
type SocialNetwork,
} from "./social-accounts";
export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"];
export {
configuredNetworks,
isNetworkConfigured,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "./social-accounts";
export const SOCIAL_LABELS: Record<SocialNetwork, string> = {
facebook: "Facebook",
linkedin: "LinkedIn",
};
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION ?? "v21.0";
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION ?? "202506";
function env(key: string): string {
return (process.env[key] ?? "").trim();
}
export function isNetworkConfigured(network: SocialNetwork): boolean {
if (network === "facebook") {
return !!env("FACEBOOK_PAGE_ID") && !!env("FACEBOOK_PAGE_ACCESS_TOKEN");
}
return !!linkedinOrganizationUrn() && !!env("LINKEDIN_ACCESS_TOKEN");
}
export function configuredNetworks(): SocialNetwork[] {
return SOCIAL_NETWORKS.filter(isNetworkConfigured);
}
function linkedinOrganizationUrn(): string {
const urn = env("LINKEDIN_ORGANIZATION_URN");
if (urn) return urn;
const id = env("LINKEDIN_ORGANIZATION_ID");
return id ? `urn:li:organization:${id}` : "";
}
function siteUrl(): string {
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
}
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0";
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506";
// ---- Contenu du post ----
@@ -133,9 +113,9 @@ async function readError(res: Response): Promise<string> {
// ---- Facebook (Graph API, page de l'association) ----
async function publishToFacebook(promo: PromoForSharing, message: string): Promise<PublishResult> {
const pageId = env("FACEBOOK_PAGE_ID");
const token = env("FACEBOOK_PAGE_ACCESS_TOKEN");
if (!pageId || !token) throw new SocialPublishError("Facebook n'est pas configuré.");
const credentials = await resolveCredentials("facebook");
if (!credentials) throw new SocialPublishError("Facebook n'est pas connecté.");
const { targetId: pageId, accessToken: token } = credentials;
const image = await loadPromoImage(promo.imageUrl);
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`;
@@ -218,9 +198,13 @@ async function uploadLinkedInImage(
}
async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise<PublishResult> {
const token = env("LINKEDIN_ACCESS_TOKEN");
const owner = linkedinOrganizationUrn();
if (!token || !owner) throw new SocialPublishError("LinkedIn n'est pas configuré.");
// Rattrape un jeton proche de l'expiration quand LinkedIn a accordé les
// jetons de rafraîchissement programmatiques à l'application.
await refreshLinkedInIfNeeded();
const credentials = await resolveCredentials("linkedin");
if (!credentials) throw new SocialPublishError("LinkedIn n'est pas connecté.");
const { accessToken: token, targetId: owner } = credentials;
const image = await loadPromoImage(promo.imageUrl);
const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;