From 19d1104ab0bf565e0706f94ee4e5cf0fe56935a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 23:00:08 +0000 Subject: [PATCH] Connexion Facebook / LinkedIn en OAuth depuis le backoffice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb --- .env.example | 38 +- CLAUDE.md | 13 +- README.md | 48 +- docker-compose.yml | 4 +- drizzle/0008_neat_makkari.sql | 17 + drizzle/meta/0008_snapshot.json | 1370 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../api/social/[network]/callback/route.ts | 85 + src/app/api/social/[network]/connect/route.ts | 62 + src/app/backend/BackendShell.tsx | 5 + src/app/backend/actions.ts | 55 +- src/app/backend/espace/page.tsx | 2 +- src/app/backend/page.tsx | 40 +- src/app/backend/promotions/page.tsx | 2 +- src/app/backend/reseaux/page.tsx | 308 ++++ src/db/schema.ts | 20 + src/lib/crypto.ts | 62 + src/lib/social-accounts.ts | 469 ++++++ src/lib/social.ts | 74 +- 19 files changed, 2599 insertions(+), 82 deletions(-) create mode 100644 drizzle/0008_neat_makkari.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/app/api/social/[network]/callback/route.ts create mode 100644 src/app/api/social/[network]/connect/route.ts create mode 100644 src/app/backend/reseaux/page.tsx create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/social-accounts.ts diff --git a/.env.example b/.env.example index 49a9150..0ef7414 100644 --- a/.env.example +++ b/.env.example @@ -35,30 +35,26 @@ PORT=3000 NEXT_PUBLIC_SITE_URL= # ---- Publication des promotions sur les réseaux sociaux ---- -# Facultatif : si ces variables sont vides, le bouton correspondant n'apparaît -# pas dans le backoffice (le reste du site fonctionne normalement). -# Ce sont des SECRETS : ils ne doivent jamais être saisis dans le backoffice. +# La configuration se fait désormais dans Backend › Réseaux sociaux : on y colle +# les identifiants de l'application, on clique Connecter, et les jetons sont +# récupérés et stockés chiffrés automatiquement. Rien à mettre ici dans le cas +# normal. # -# Facebook — page de l'association -# 1. Créer une app sur developers.facebook.com (type « Business »). -# 2. Ajouter le produit « Facebook Login » et les permissions -# pages_manage_posts + pages_read_engagement. -# 3. Générer un jeton de PAGE longue durée (Graph API Explorer puis -# /oauth/access_token?grant_type=fb_exchange_token) et le coller ci-dessous. +# Clé de chiffrement des jetons en base. Si elle est vide, AUTH_SECRET est +# utilisé. La définir explicitement permet de faire tourner AUTH_SECRET sans +# perdre les connexions réseaux. +# openssl rand -base64 32 +SOCIAL_TOKEN_KEY= + +# Versions d'API (facultatif) +FACEBOOK_GRAPH_VERSION=v21.0 +LINKEDIN_API_VERSION=202506 + +# --- Repli historique --- +# Ces variables restent lues si aucun compte n'est connecté depuis le +# backoffice. Utile pour un jeton posé à la main ; sinon, laissez vide. FACEBOOK_PAGE_ID= FACEBOOK_PAGE_ACCESS_TOKEN= -# Version de la Graph API (facultatif) -FACEBOOK_GRAPH_VERSION=v21.0 -# -# LinkedIn — page organisation -# 1. Créer une app sur linkedin.com/developers, la rattacher à la page -# « Association Plein R — Bassin de Pompey ». -# 2. Demander le produit « Community Management API ». -# 3. Générer un jeton avec les scopes w_organization_social + r_organization_social. -# L'URN se lit dans l'URL d'administration de la page (numéro d'organisation). LINKEDIN_ORGANIZATION_URN= -# Alternative à l'URN : uniquement le numéro d'organisation LINKEDIN_ORGANIZATION_ID= LINKEDIN_ACCESS_TOKEN= -# Version de l'API LinkedIn (facultatif, format AAAAMM) -LINKEDIN_API_VERSION=202506 diff --git a/CLAUDE.md b/CLAUDE.md index 45408cd..4cdf92f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,8 +52,17 @@ site sans traitement supplémentaire. ## Réseaux sociaux - `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou - LinkedIn (Posts API). Jetons **uniquement** en variables d'environnement, jamais - en base ni dans le backoffice. Réseau non configuré = case masquée. + LinkedIn (Posts API). `src/lib/social-accounts.ts` gère la configuration : OAuth, + jetons, cibles. Réseau non configuré = case masquée. +- Les identifiants et jetons vivent en base (`social_accounts`), **chiffrés** via + `src/lib/crypto.ts` (AES-256-GCM, clé `SOCIAL_TOKEN_KEY` ou `AUTH_SECRET`), posés + depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli. + Aucun secret ne doit jamais repartir vers le navigateur. +- `isNetworkConfigured()` / `configuredNetworks()` sont **asynchrones** (accès base). +- Routes OAuth : `src/app/api/social/[network]/{connect,callback}`. Le `state` + anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL. +- Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement + programmatique réservé à certains partenaires, d'où le bandeau de reconnexion. - Les images de promo sont stockées en data-URI : l'upload se fait donc en binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas par URL. diff --git a/README.md b/README.md index 0c93376..068b633 100644 --- a/README.md +++ b/README.md @@ -102,12 +102,40 @@ Une promotion n'est jamais publiée deux fois : un réseau ayant déjà une publication réussie est systématiquement ignoré, y compris sur un cycle suspension → remise en ligne. -Les jetons d'accès sont des **secrets** : ils se configurent uniquement par -variables d'environnement (`FACEBOOK_PAGE_ID`, `FACEBOOK_PAGE_ACCESS_TOKEN`, -`LINKEDIN_ORGANIZATION_URN` ou `LINKEDIN_ORGANIZATION_ID`, -`LINKEDIN_ACCESS_TOKEN`), jamais depuis le backoffice. Voir -[`.env.example`](./.env.example) pour la marche à suivre côté Meta et LinkedIn. -Si un réseau n'est pas configuré, son bouton n'apparaît simplement pas. +### Connecter les comptes + +Tout se passe dans **Backend › Réseaux sociaux** (administrateurs) : on colle les +identifiants de l'application, on clique **Connecter**, on choisit la page. Les +jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé +`SOCIAL_TOKEN_KEY` ou à défaut `AUTH_SECRET`) ; ils ne ressortent jamais vers le +navigateur. Un réseau non connecté voit simplement sa case disparaître du +formulaire de promotion. + +L'écran affiche l'URL de redirection à déclarer sur le portail développeur — +c'est l'erreur de configuration la plus fréquente. + +**Facebook.** Créez une application « Business » sur +[developers.facebook.com](https://developers.facebook.com/apps), ajoutez le +produit Connexion Facebook, déclarez l'URL de redirection. Gardez l'application +en **mode développement** avec le compte de l'association comme administrateur : +publier sur votre propre page ne demande alors aucune revue Meta. Le jeton de +page obtenu **n'expire pas** — une connexion suffit, définitivement. + +**LinkedIn.** Créez une application sur +[linkedin.com/developers](https://www.linkedin.com/developers/apps) rattachée à +la page de l'association, puis demandez le produit **Community Management API**. +Deux limites à connaître avant de vous lancer : + +- l'accès est soumis à une revue (page vérifiée, nom légal, adresse, politique + de confidentialité) ; ce n'est pas garanti ni immédiat ; +- les jetons LinkedIn durent **60 jours** et le rafraîchissement programmatique + est réservé à certains partenaires. En pratique il faut donc recliquer sur + **Reconnecter** environ tous les deux mois. Le backoffice affiche la date + d'expiration et un bandeau d'alerte 7 jours avant. + +Les variables d'environnement (`FACEBOOK_PAGE_ACCESS_TOKEN`, etc.) restent lues +en **repli** si aucun compte n'est connecté, pour ne pas casser une installation +antérieure. Les **liens publics** vers les deux pages (affichés sur l'accueil et dans le pied de page) se règlent, eux, dans **Backend › Paramètres**. @@ -143,9 +171,11 @@ Voir [`.env.example`](./.env.example). Les principales : - `AUTH_URL` — URL publique de l'application - `SEED_ON_START` — `true` pour seeder au démarrage du conteneur - `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin -- `NEXT_PUBLIC_SITE_URL` — URL publique reprise dans les posts réseaux sociaux -- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN` — publication Facebook (optionnel) -- `LINKEDIN_ORGANIZATION_URN` / `LINKEDIN_ACCESS_TOKEN` — publication LinkedIn (optionnel) +- `NEXT_PUBLIC_SITE_URL` — URL publique : sert au lien des posts **et** à l'adresse + de retour OAuth. Obligatoire pour connecter un réseau social. +- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`) +- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` / + `LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice ## Note sur le logo diff --git a/docker-compose.yml b/docker-compose.yml index d4f40e5..8dfe724 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,9 @@ services: AUTH_TRUST_HOST: "true" # URL publique, reprise dans le texte des posts Facebook / LinkedIn. NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-} - # Publication des promotions sur les réseaux (facultatif — voir .env.example). + # Chiffrement des jetons réseaux stockés en base (à défaut : AUTH_SECRET). + SOCIAL_TOKEN_KEY: ${SOCIAL_TOKEN_KEY:-} + # Repli historique : la configuration normale se fait dans le backoffice. FACEBOOK_PAGE_ID: ${FACEBOOK_PAGE_ID:-} FACEBOOK_PAGE_ACCESS_TOKEN: ${FACEBOOK_PAGE_ACCESS_TOKEN:-} FACEBOOK_GRAPH_VERSION: ${FACEBOOK_GRAPH_VERSION:-} diff --git a/drizzle/0008_neat_makkari.sql b/drizzle/0008_neat_makkari.sql new file mode 100644 index 0000000..f736765 --- /dev/null +++ b/drizzle/0008_neat_makkari.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..30c51e6 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1370 @@ +{ + "id": "e3897c03-edfd-4bf4-9f9d-f34c01581ba2", + "prevId": "42826c19-7d81-478b-9543-aa1873ac15f4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "tableTo": "users", + "columnsFrom": [ + "suspended_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "nullsNotDistinct": false, + "columns": [ + "network" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "tableTo": "promotions", + "columnsFrom": [ + "promotion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "tableTo": "users", + "columnsFrom": [ + "posted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "temp_password": { + "name": "temp_password", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected", + "suspended" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d848272..6796a01 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1784931989372, "tag": "0007_foamy_vindicator", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1784933339220, + "tag": "0008_neat_makkari", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/social/[network]/callback/route.ts b/src/app/api/social/[network]/callback/route.ts new file mode 100644 index 0000000..a5a008b --- /dev/null +++ b/src/app/api/social/[network]/callback/route.ts @@ -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) { + 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) }); + } +} diff --git a/src/app/api/social/[network]/connect/route.ts b/src/app/api/social/[network]/connect/route.ts new file mode 100644 index 0000000..a691ce6 --- /dev/null +++ b/src/app/api/social/[network]/connect/route.ts @@ -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; +} diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index 2d18712..15d95ec 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -171,6 +171,11 @@ export function BackendShell({ {dotDiamond}Catégories )} + {can(user.role, "manageSettings") && ( + + {dot}Réseaux sociaux + + )} {can(user.role, "manageSettings") && ( {dot}Paramètres diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 81d7db3..e756bd2 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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 ${name}`, + "#d8472b" + ); + revalidatePath("/backend/reseaux"); + revalidatePath("/backend/promotions"); + revalidatePath("/backend/espace"); +} + // ---- Sign out ---- export async function doSignOut() { await signOut({ redirectTo: "/" }); diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index 5c5331f..8ddbe2b 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -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" }); diff --git a/src/app/backend/page.tsx b/src/app/backend/page.tsx index bb80dbb..f999801 100644 --- a/src/app/backend/page.tsx +++ b/src/app/backend/page.tsx @@ -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 (
+ {expiringNetworks.length > 0 && ( + + {expiringNetworks.map((n) => ( +
+ {SOCIAL_LABELS[n.network]}{" "} + {n.status === "expired" + ? "— le jeton a expiré, les publications échoueront." + : "— le jeton expire dans moins de 7 jours."}{" "} + Reconnecter le compte → +
+ ))} + + )} +
diff --git a/src/app/backend/promotions/page.tsx b/src/app/backend/promotions/page.tsx index 59a6871..89591bf 100644 --- a/src/app/backend/promotions/page.tsx +++ b/src/app/backend/promotions/page.tsx @@ -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({ diff --git a/src/app/backend/reseaux/page.tsx b/src/app/backend/reseaux/page.tsx new file mode 100644 index 0000000..285fa81 --- /dev/null +++ b/src/app/backend/reseaux/page.tsx @@ -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(); + let targetError: string | null = null; + for (const network of SOCIAL_NETWORKS) { + const account = byNetwork.get(network); + if (account?.accessToken && !account.targetId) { + try { + targets.set(network, await listStoredTargets(network)); + } catch (caught) { + targetError = caught instanceof Error ? caught.message : "Pages illisibles."; + } + } + } + + return ( +
+ {error && {error}} + {targetError && {targetError}} + {connected && ( + + Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté. + + )} + {choose && ( + + Connexion réussie : choisissez la page à utiliser pour les publications. + + )} + + {!base && ( + + L'URL publique du site n'est pas renseignée (variable + NEXT_PUBLIC_SITE_URL). Elle est indispensable pour construire + l'adresse de retour OAuth : renseignez-la avant de connecter un réseau. + + )} + + {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 ( +
+
+ + + +

+ {SOCIAL_LABELS[network]} +

+ +
+ + {isConnected && ( +
+ Publie sur {account?.targetName}. + {status === "never" && " Ce jeton n'expire pas."} + {account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`} + {account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`} +
+ Pour changer de page, relancez une connexion. +
+ )} + + {(status === "soon" || status === "expired") && isConnected && ( + + {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.`} + + )} + + {candidates.length > 0 && ( +
+
+ Page à utiliser pour les publications +
+
+ {candidates.map((target) => ( +
+ + + + {target.name} + + +
+ ))} +
+
+ )} + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + {isConnected ? "Reconnecter" : "Connecter"} + + {isConnected && ( +
+ + +
+ )} +
+ +
+ + Comment obtenir ces identifiants ? + +
    + {help.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ Portail :{" "} + + {help.portal} + +
+
+
URL de redirection à déclarer
+ + {base ? redirectUri(network) : "— renseignez d'abord NEXT_PUBLIC_SITE_URL —"} + +
+ {help.caution && ( +
+ {help.caution} +
+ )} +
+
+ ); + })} +
+ ); +} + +function StatusChip({ connected, status }: { connected: boolean; status: string }) { + const [label, bg, color] = !connected + ? ["Non connecté", "#f1efe7", "#a99c82"] + : status === "expired" + ? ["Jeton expiré", "#fbe9e6", "#d8472b"] + : status === "soon" + ? ["Expire bientôt", "#fbeede", "#9a6638"] + : ["Connecté", "#e6f4ec", "#1f8a5b"]; + return ( + + {label} + + ); +} + +function Banner({ tone, children }: { tone: "ok" | "warn" | "error"; children: React.ReactNode }) { + const palette = { + ok: { bg: "#e6f4ec", border: "#c7e6d5", color: "#1f8a5b" }, + warn: { bg: "#fbeede", border: "#ecd8b8", color: "#9a6638" }, + error: { bg: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" }, + }[tone]; + return ( +
+ {children} +
+ ); +} + +const panel: CSSProperties = { + background: "#fff", + border: "1px solid #e6dcc6", + borderRadius: 16, + padding: 22, +}; + +const title: CSSProperties = { + fontSize: 20, + color: "#26201a", +}; + +const submitButton: CSSProperties = { + border: "none", + background: "#13324F", + color: "#fff", + fontWeight: 800, + fontSize: 14, + padding: "11px 20px", + borderRadius: 10, + cursor: "pointer", +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index a28370a..dc61cf8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -131,6 +131,25 @@ export const socialPosts = pgTable( }) ); +// ---- Comptes réseaux sociaux connectés (OAuth) ---- +// Une ligne par réseau. Les secrets sont chiffrés (src/lib/crypto.ts) et ne +// ressortent jamais vers le navigateur. +export const socialAccounts = pgTable("social_accounts", { + id: serial("id").primaryKey(), + network: socialNetworkEnum("network").notNull().unique(), + appId: varchar("app_id", { length: 200 }).notNull(), + appSecret: text("app_secret").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + // Nul = n'expire pas (jeton de page Facebook). + expiresAt: timestamp("expires_at", { withTimezone: true }), + targetId: varchar("target_id", { length: 200 }), + targetName: varchar("target_name", { length: 200 }), + connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }), + connectedAt: timestamp("connected_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + // ---- Membership requests (demandes d'adhésion) ---- export const membershipRequests = pgTable("membership_requests", { id: serial("id").primaryKey(), @@ -316,6 +335,7 @@ export type Member = typeof members.$inferSelect; export type User = typeof users.$inferSelect; export type Promotion = typeof promotions.$inferSelect; export type SocialPost = typeof socialPosts.$inferSelect; +export type SocialAccount = typeof socialAccounts.$inferSelect; export type SocialNetwork = (typeof socialNetworkEnum.enumValues)[number]; export type MembershipRequest = typeof membershipRequests.$inferSelect; export type ContactMessage = typeof contactMessages.$inferSelect; diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..c853757 --- /dev/null +++ b/src/lib/crypto.ts @@ -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; + } +} diff --git a/src/lib/social-accounts.ts b/src/lib/social-accounts.ts new file mode 100644 index 0000000..39e8fc3 --- /dev/null +++ b/src/lib/social-accounts.ts @@ -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 = { + 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 { + const [row] = await db.select().from(socialAccounts).where(eq(socialAccounts.network, network)); + return row ?? null; +} + +export async function getSocialAccounts(): Promise { + 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 { + 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 { + return (await resolveCredentials(network)) !== null; +} + +export async function configuredNetworks(): Promise { + 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 { + const body = await res.text().catch(() => ""); + return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`; +} + +const SCOPES: Record = { + 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 { + return network === "facebook" + ? exchangeFacebook(appId, appSecret, code) + : exchangeLinkedIn(appId, appSecret, code); +} + +async function exchangeFacebook( + appId: string, + appSecret: string, + code: string +): Promise { + 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 { + 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 { + return { + Authorization: `Bearer ${token}`, + "LinkedIn-Version": LINKEDIN_VERSION, + "X-Restli-Protocol-Version": "2.0.0", + }; +} + +async function listLinkedInOrganizations(token: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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. + } +} diff --git a/src/lib/social.ts b/src/lib/social.ts index a3b3215..9ec2e78 100644 --- a/src/lib/social.ts +++ b/src/lib/social.ts @@ -1,51 +1,31 @@ /** * Publication des promotions sur les pages Facebook / LinkedIn de l'association. * - * Les jetons d'accès sont des SECRETS : ils vivent dans les variables - * d'environnement du conteneur, jamais en base ni dans le backoffice. Voir - * `.env.example` pour la marche à suivre côté Meta / LinkedIn. + * Les identifiants et jetons viennent de `social-accounts.ts` : base de données + * en premier (posés depuis Backend › Réseaux sociaux), variables + * d'environnement en repli. * - * Si un réseau n'est pas configuré, le backoffice masque simplement son bouton : + * Si un réseau n'est pas configuré, le backoffice masque simplement sa case : * le reste du site fonctionne normalement. */ -export type SocialNetwork = "facebook" | "linkedin"; +import { + refreshLinkedInIfNeeded, + resolveCredentials, + siteUrl, + type SocialNetwork, +} from "./social-accounts"; -export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"]; +export { + configuredNetworks, + isNetworkConfigured, + SOCIAL_LABELS, + SOCIAL_NETWORKS, + type SocialNetwork, +} from "./social-accounts"; -export const SOCIAL_LABELS: Record = { - facebook: "Facebook", - linkedin: "LinkedIn", -}; - -const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION ?? "v21.0"; -const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION ?? "202506"; - -function env(key: string): string { - return (process.env[key] ?? "").trim(); -} - -export function isNetworkConfigured(network: SocialNetwork): boolean { - if (network === "facebook") { - return !!env("FACEBOOK_PAGE_ID") && !!env("FACEBOOK_PAGE_ACCESS_TOKEN"); - } - return !!linkedinOrganizationUrn() && !!env("LINKEDIN_ACCESS_TOKEN"); -} - -export function configuredNetworks(): SocialNetwork[] { - return SOCIAL_NETWORKS.filter(isNetworkConfigured); -} - -function linkedinOrganizationUrn(): string { - const urn = env("LINKEDIN_ORGANIZATION_URN"); - if (urn) return urn; - const id = env("LINKEDIN_ORGANIZATION_ID"); - return id ? `urn:li:organization:${id}` : ""; -} - -function siteUrl(): string { - return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, ""); -} +const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0"; +const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506"; // ---- Contenu du post ---- @@ -133,9 +113,9 @@ async function readError(res: Response): Promise { // ---- Facebook (Graph API, page de l'association) ---- async function publishToFacebook(promo: PromoForSharing, message: string): Promise { - const pageId = env("FACEBOOK_PAGE_ID"); - const token = env("FACEBOOK_PAGE_ACCESS_TOKEN"); - if (!pageId || !token) throw new SocialPublishError("Facebook n'est pas configuré."); + const credentials = await resolveCredentials("facebook"); + if (!credentials) throw new SocialPublishError("Facebook n'est pas connecté."); + const { targetId: pageId, accessToken: token } = credentials; const image = await loadPromoImage(promo.imageUrl); const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`; @@ -218,9 +198,13 @@ async function uploadLinkedInImage( } async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise { - const token = env("LINKEDIN_ACCESS_TOKEN"); - const owner = linkedinOrganizationUrn(); - if (!token || !owner) throw new SocialPublishError("LinkedIn n'est pas configuré."); + // Rattrape un jeton proche de l'expiration quand LinkedIn a accordé les + // jetons de rafraîchissement programmatiques à l'application. + await refreshLinkedInIfNeeded(); + + const credentials = await resolveCredentials("linkedin"); + if (!credentials) throw new SocialPublishError("LinkedIn n'est pas connecté."); + const { accessToken: token, targetId: owner } = credentials; const image = await loadPromoImage(promo.imageUrl); const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;