mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
19 files changed
+2599
-82
No files matched your search
+17
-21
@@ -35,30 +35,26 @@ PORT=3000
|
|||||||
NEXT_PUBLIC_SITE_URL=
|
NEXT_PUBLIC_SITE_URL=
|
||||||
|
|
||||||
# ---- Publication des promotions sur les réseaux sociaux ----
|
# ---- Publication des promotions sur les réseaux sociaux ----
|
||||||
# Facultatif : si ces variables sont vides, le bouton correspondant n'apparaît
|
# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle
|
||||||
# pas dans le backoffice (le reste du site fonctionne normalement).
|
# les identifiants de l'application, on clique Connecter, et les jetons sont
|
||||||
# Ce sont des SECRETS : ils ne doivent jamais être saisis dans le backoffice.
|
# récupérés et stockés chiffrés automatiquement. Rien à mettre ici dans le cas
|
||||||
|
# normal.
|
||||||
#
|
#
|
||||||
# Facebook — page de l'association
|
# Clé de chiffrement des jetons en base. Si elle est vide, AUTH_SECRET est
|
||||||
# 1. Créer une app sur developers.facebook.com (type « Business »).
|
# utilisé. La définir explicitement permet de faire tourner AUTH_SECRET sans
|
||||||
# 2. Ajouter le produit « Facebook Login » et les permissions
|
# perdre les connexions réseaux.
|
||||||
# pages_manage_posts + pages_read_engagement.
|
# openssl rand -base64 32
|
||||||
# 3. Générer un jeton de PAGE longue durée (Graph API Explorer puis
|
SOCIAL_TOKEN_KEY=
|
||||||
# /oauth/access_token?grant_type=fb_exchange_token) et le coller ci-dessous.
|
|
||||||
|
# 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_ID=
|
||||||
FACEBOOK_PAGE_ACCESS_TOKEN=
|
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=
|
LINKEDIN_ORGANIZATION_URN=
|
||||||
# Alternative à l'URN : uniquement le numéro d'organisation
|
|
||||||
LINKEDIN_ORGANIZATION_ID=
|
LINKEDIN_ORGANIZATION_ID=
|
||||||
LINKEDIN_ACCESS_TOKEN=
|
LINKEDIN_ACCESS_TOKEN=
|
||||||
# Version de l'API LinkedIn (facultatif, format AAAAMM)
|
|
||||||
LINKEDIN_API_VERSION=202506
|
|
||||||
@@ -52,8 +52,17 @@ site sans traitement supplémentaire.
|
|||||||
## Réseaux sociaux
|
## Réseaux sociaux
|
||||||
|
|
||||||
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
|
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
|
||||||
LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais
|
LinkedIn (Posts API). `src/lib/social-accounts.ts` gère la configuration : OAuth,
|
||||||
en base ni dans le backoffice. Réseau non configuré = case masquée.
|
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
|
- 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.
|
||||||
|
|||||||
@@ -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
|
publication réussie est systématiquement ignoré, y compris sur un cycle
|
||||||
suspension → remise en ligne.
|
suspension → remise en ligne.
|
||||||
|
|
||||||
Les jetons d'accès sont des **secrets** : ils se configurent uniquement par
|
### Connecter les comptes
|
||||||
variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`,
|
|
||||||
`LINKEDIN_ORGANIZATION_URN` ou `LINKEDIN_ORGANIZATION_ID`,
|
Tout se passe dans **Backend › Réseaux sociaux** (administrateurs) : on colle les
|
||||||
`LINKEDIN_ACCESS_TOKEN`), jamais depuis le backoffice. Voir
|
identifiants de l'application, on clique **Connecter**, on choisit la page. Les
|
||||||
[`.env.example`](./.env.example) pour la marche à suivre côté Meta et LinkedIn.
|
jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé
|
||||||
Si un réseau n'est pas configuré, son bouton n'apparaît simplement pas.
|
`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
|
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**.
|
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
|
- `AUTH_URL` — URL publique de l'application
|
||||||
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
|
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
|
||||||
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
|
||||||
- `NEXT_PUBLIC_SITE_URL` — URL publique reprise dans les posts réseaux sociaux
|
- `NEXT_PUBLIC_SITE_URL` — URL publique : sert au lien des posts **et** à l'adresse
|
||||||
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN` — publication Facebook (optionnel)
|
de retour OAuth. Obligatoire pour connecter un réseau social.
|
||||||
- `LINKEDIN_ORGANIZATION_URN` / `LINKEDIN_ACCESS_TOKEN` — publication LinkedIn (optionnel)
|
- `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
|
## Note sur le logo
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -49,7 +49,9 @@ services:
|
|||||||
AUTH_TRUST_HOST: "true"
|
AUTH_TRUST_HOST: "true"
|
||||||
# URL publique, reprise dans le texte des posts Facebook / LinkedIn.
|
# URL publique, reprise dans le texte des posts Facebook / LinkedIn.
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
|
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_ID: ${FACEBOOK_PAGE_ID:-}
|
||||||
FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-}
|
FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-}
|
||||||
FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-}
|
FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-}
|
||||||
|
|||||||
@@ -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
@@ -57,6 +57,13 @@
|
|||||||
"when": 1784931989372,
|
"when": 1784931989372,
|
||||||
"tag": "0007_foamy_vindicator",
|
"tag": "0007_foamy_vindicator",
|
||||||
"breakpoints": true
|
"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;
|
||||||
|
}
|
||||||
@@ -171,6 +171,11 @@ export function BackendShell({
|
|||||||
{dotDiamond}Catégories
|
{dotDiamond}Catégories
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
{can(user.role, "manageSettings") && (
|
||||||
|
<NavLink href="/backend/reseaux" active={pathname === "/backend/reseaux"}>
|
||||||
|
{dot}Réseaux sociaux
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{can(user.role, "manageSettings") && (
|
{can(user.role, "manageSettings") && (
|
||||||
<NavLink href="/backend/parametres" active={pathname === "/backend/parametres"}>
|
<NavLink href="/backend/parametres" active={pathname === "/backend/parametres"}>
|
||||||
{dot}Paramètres
|
{dot}Paramètres
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
SOCIAL_NETWORKS,
|
SOCIAL_NETWORKS,
|
||||||
type SocialNetwork,
|
type SocialNetwork,
|
||||||
} from "@/lib/social";
|
} from "@/lib/social";
|
||||||
|
import {
|
||||||
|
disconnectAccount,
|
||||||
|
saveAppCredentials,
|
||||||
|
selectTarget,
|
||||||
|
} from "@/lib/social-accounts";
|
||||||
import { normalizeWebsite } from "@/lib/member-profile";
|
import { normalizeWebsite } from "@/lib/member-profile";
|
||||||
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
|
||||||
import type { AppRole } from "@/types/next-auth";
|
import type { AppRole } from "@/types/next-auth";
|
||||||
@@ -164,7 +169,7 @@ async function publishPromoShares(
|
|||||||
for (const network of networks) {
|
for (const network of networks) {
|
||||||
if (alreadyPosted.has(network)) continue;
|
if (alreadyPosted.has(network)) continue;
|
||||||
|
|
||||||
if (!isNetworkConfigured(network)) {
|
if (!(await isNetworkConfigured(network))) {
|
||||||
await db.insert(socialPosts).values({
|
await db.insert(socialPosts).values({
|
||||||
promotionId: promoId,
|
promotionId: promoId,
|
||||||
network,
|
network,
|
||||||
@@ -1128,6 +1133,54 @@ export async function setContactStatus(formData: FormData) {
|
|||||||
revalidatePath("/backend");
|
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 ----
|
// ---- Sign out ----
|
||||||
export async function doSignOut() {
|
export async function doSignOut() {
|
||||||
await signOut({ redirectTo: "/" });
|
await signOut({ redirectTo: "/" });
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default async function EspacePage() {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.orderBy(asc(categories.sort));
|
.orderBy(asc(categories.sort));
|
||||||
const categoryLabels = catRows.map((c) => c.label);
|
const categoryLabels = catRows.map((c) => c.label);
|
||||||
const networks = configuredNetworks();
|
const networks = await configuredNetworks();
|
||||||
|
|
||||||
function fmtDate(d: Date) {
|
function fmtDate(d: Date) {
|
||||||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { desc, eq } from "drizzle-orm";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -49,6 +50,15 @@ 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
|
||||||
|
// 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) {
|
function timeAgo(date: Date) {
|
||||||
const diff = Date.now() - new Date(date).getTime();
|
const diff = Date.now() - new Date(date).getTime();
|
||||||
const h = Math.floor(diff / 3_600_000);
|
const h = Math.floor(diff / 3_600_000);
|
||||||
@@ -60,6 +70,34 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<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="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" />
|
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default async function PromotionsPage() {
|
|||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
const canShare = can(session?.user.role, "publishSocial");
|
const canShare = can(session?.user.role, "publishSocial");
|
||||||
const networks = canShare ? configuredNetworks() : [];
|
const networks = canShare ? await configuredNetworks() : [];
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -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'URL publique du site n'est pas renseignée (variable
|
||||||
|
<code> NEXT_PUBLIC_SITE_URL</code>). Elle est indispensable pour construire
|
||||||
|
l'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",
|
||||||
|
};
|
||||||
@@ -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) ----
|
// ---- Membership requests (demandes d'adhésion) ----
|
||||||
export const membershipRequests = pgTable("membership_requests", {
|
export const membershipRequests = pgTable("membership_requests", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
@@ -316,6 +335,7 @@ export type Member = typeof members.$inferSelect;
|
|||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type Promotion = typeof promotions.$inferSelect;
|
export type Promotion = typeof promotions.$inferSelect;
|
||||||
export type SocialPost = typeof socialPosts.$inferSelect;
|
export type SocialPost = typeof socialPosts.$inferSelect;
|
||||||
|
export type SocialAccount = typeof socialAccounts.$inferSelect;
|
||||||
export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number];
|
export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number];
|
||||||
export type MembershipRequest = typeof membershipRequests.$inferSelect;
|
export type MembershipRequest = typeof membershipRequests.$inferSelect;
|
||||||
export type ContactMessage = typeof contactMessages.$inferSelect;
|
export type ContactMessage = typeof contactMessages.$inferSelect;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,51 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Publication des promotions sur les pages Facebook / LinkedIn de l'association.
|
* Publication des promotions sur les pages Facebook / LinkedIn de l'association.
|
||||||
*
|
*
|
||||||
* Les jetons d'accès sont des SECRETS : ils vivent dans les variables
|
* Les identifiants et jetons viennent de `social-accounts.ts` : base de données
|
||||||
* d'environnement du conteneur, jamais en base ni dans le backoffice. Voir
|
* en premier (posés depuis Backend › Réseaux sociaux), variables
|
||||||
* `.env.example` pour la marche à suivre côté Meta / LinkedIn.
|
* 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.
|
* 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> = {
|
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0";
|
||||||
facebook: "Facebook",
|
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506";
|
||||||
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(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Contenu du post ----
|
// ---- Contenu du post ----
|
||||||
|
|
||||||
@@ -133,9 +113,9 @@ async function readError(res: Response): Promise<string> {
|
|||||||
// ---- Facebook (Graph API, page de l'association) ----
|
// ---- Facebook (Graph API, page de l'association) ----
|
||||||
|
|
||||||
async function publishToFacebook(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
async function publishToFacebook(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
||||||
const pageId = env("FACEBOOK_PAGE_ID");
|
const credentials = await resolveCredentials("facebook");
|
||||||
const token = env("FACEBOOK_PAGE_ACCESS_TOKEN");
|
if (!credentials) throw new SocialPublishError("Facebook n'est pas connecté.");
|
||||||
if (!pageId || !token) throw new SocialPublishError("Facebook n'est pas configuré.");
|
const { targetId: pageId, accessToken: token } = credentials;
|
||||||
|
|
||||||
const image = await loadPromoImage(promo.imageUrl);
|
const image = await loadPromoImage(promo.imageUrl);
|
||||||
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`;
|
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> {
|
async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
||||||
const token = env("LINKEDIN_ACCESS_TOKEN");
|
// Rattrape un jeton proche de l'expiration quand LinkedIn a accordé les
|
||||||
const owner = linkedinOrganizationUrn();
|
// jetons de rafraîchissement programmatiques à l'application.
|
||||||
if (!token || !owner) throw new SocialPublishError("LinkedIn n'est pas configuré.");
|
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 image = await loadPromoImage(promo.imageUrl);
|
||||||
const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;
|
const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;
|
||||||
|
|||||||
Reference in new issue
Block a user