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

+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: "/" });