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

@@ -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",
};