From 81b99ed5fb7691d798b5bd9fc2026ca6b81ffc34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 10:43:11 +0000 Subject: [PATCH] =?UTF-8?q?R=C3=A9seaux=20:=20contr=C3=B4le=20r=C3=A9el=20?= =?UTF-8?q?de=20validit=C3=A9=20des=20jetons=20et=20outils=20d'inspection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un jeton de page Facebook n'a pas de date d'expiration, donc `expiryStatus` renvoyait « never » et aucune alerte ne pouvait se déclencher. Mais il meurt quand même : mot de passe du compte changé, rôle d'administrateur perdu sur la page, autorisation retirée, révocation par Meta. On ne l'apprenait qu'au moment où une publication échouait. - `checkTokenHealth()` interroge la plateforme (`/me` côté Graph, les ACL d'organisation côté LinkedIn) et rend un verdict motivé. - L'écran Réseaux sociaux contrôle à chaque affichage : pastille « Jeton expiré », bandeau rouge et réponse brute de la plateforme quand il est mort, ligne verte de confirmation sinon. - Le verdict est persisté (`social_accounts.last_check_*`) : le tableau de bord alerte via `tokenHealthCached()` (fenêtre de 6 h) sans appeler la plateforme à chaque affichage, et croise ce signal avec la dernière tentative de publication refusée pour motif d'autorisation. - Bloc « Outils pour inspecter un jeton » dans l'aide de chaque réseau : débogueur de jeton, outil de jetons d'accès, explorateur d'API Graph, documentation des jetons de longue durée — en précisant que le bouton Connecter fait déjà l'échange court → longue durée → jeton de page, et qu'il n'y a aucun jeton à copier à la main. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb --- CLAUDE.md | 5 + drizzle/0014_lean_marvel_zombies.sql | 3 + drizzle/meta/0014_snapshot.json | 1395 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/backend/page.tsx | 67 +- src/app/backend/reseaux/page.tsx | 66 +- src/db/schema.ts | 5 + src/lib/social-accounts.ts | 86 ++ 8 files changed, 1618 insertions(+), 16 deletions(-) create mode 100644 drizzle/0014_lean_marvel_zombies.sql create mode 100644 drizzle/meta/0014_snapshot.json diff --git a/CLAUDE.md b/CLAUDE.md index fbe9a54..6fdc438 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,11 @@ site sans traitement supplémentaire. 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. +- « N'expire pas » ≠ « ne meurt jamais » : `checkTokenHealth()` interroge la + plateforme et persiste le verdict dans `social_accounts.last_check_*`. + L'écran Réseaux contrôle à chaque affichage ; le tableau de bord passe par + `tokenHealthCached()` (6 h) et croise le résultat avec la dernière tentative + de publication en échec. Une date d'expiration seule ne suffit pas à alerter. - 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/drizzle/0014_lean_marvel_zombies.sql b/drizzle/0014_lean_marvel_zombies.sql new file mode 100644 index 0000000..3ad68e6 --- /dev/null +++ b/drizzle/0014_lean_marvel_zombies.sql @@ -0,0 +1,3 @@ +ALTER TABLE "social_accounts" ADD COLUMN "last_check_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "social_accounts" ADD COLUMN "last_check_ok" boolean;--> statement-breakpoint +ALTER TABLE "social_accounts" ADD COLUMN "last_check_error" text; \ No newline at end of file diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..2d280ce --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1395 @@ +{ + "id": "d6df280b-1194-4a4a-9670-257fc2acd2f9", + "prevId": "c6e431df-9534-471d-a107-6d149fe00a00", + "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 + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_ok": { + "name": "last_check_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "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 + }, + "session_version": { + "name": "session_version", + "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": { + "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 d914b15..c33c767 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1788680191881, "tag": "0013_contact_email", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1788691242685, + "tag": "0014_lean_marvel_zombies", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/backend/page.tsx b/src/app/backend/page.tsx index dda9a41..98755b5 100644 --- a/src/app/backend/page.tsx +++ b/src/app/backend/page.tsx @@ -3,10 +3,16 @@ import { redirect } from "next/navigation"; import { desc, eq } from "drizzle-orm"; import { getSession } from "@/lib/session"; import { db } from "@/db"; -import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema"; +import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema"; import { activityNodes } from "@/lib/activity"; import { can, isStaff } from "@/lib/rbac"; -import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts"; +import { + expiryStatus, + getSocialAccounts, + SOCIAL_LABELS, + tokenHealthCached, + type SocialNetwork, +} from "@/lib/social-accounts"; export const dynamic = "force-dynamic"; @@ -51,14 +57,47 @@ 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") - : []; + // Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne + // suffit pas : un jeton de page Facebook n'expire jamais mais peut être + // révoqué, et cela ne se voit qu'à la première publication en échec. + const expiringNetworks: { network: SocialNetwork; status: string }[] = []; + if (can(session?.user.role, "manageSettings")) { + const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId); + + // Dernière tentative de publication par réseau : une erreur d'autorisation + // signale un jeton mort, sans avoir à interroger la plateforme ici. + const attempts = connected.length + ? await db + .select({ + network: socialPosts.network, + status: socialPosts.status, + error: socialPosts.error, + }) + .from(socialPosts) + .orderBy(desc(socialPosts.createdAt)) + .limit(40) + : []; + const lastAttempt = new Map(); + for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a); + + for (const account of connected) { + const last = lastAttempt.get(account.network); + const authFailure = + last?.status === "failed" && + /oauth|token|401|expired|invalid|permission/i.test(last.error ?? ""); + // Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir + // avant la première publication en échec, sans appeler la plateforme à + // chaque affichage du tableau de bord. + const probe = authFailure ? null : await tokenHealthCached(account.network); + const expiry = expiryStatus(account.expiresAt); + + if (authFailure || probe?.ok === false) { + expiringNetworks.push({ network: account.network, status: "broken" }); + } else if (expiry === "soon" || expiry === "expired") { + expiringNetworks.push({ network: account.network, status: expiry }); + } + } + } function timeAgo(date: Date) { const diff = Date.now() - new Date(date).getTime(); @@ -90,9 +129,11 @@ export default async function DashboardPage() { {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."}{" "} + {n.status === "broken" + ? "— la dernière publication a été refusée : le jeton ne fonctionne plus." + : 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/reseaux/page.tsx b/src/app/backend/reseaux/page.tsx index 27e28aa..b85aa34 100644 --- a/src/app/backend/reseaux/page.tsx +++ b/src/app/backend/reseaux/page.tsx @@ -6,6 +6,7 @@ import { getSession } from "@/lib/session"; import { can } from "@/lib/rbac"; import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; import { + checkTokenHealth, expiryStatus, getSocialAccounts, listStoredTargets, @@ -14,6 +15,7 @@ import { SOCIAL_LABELS, SOCIAL_NETWORKS, type SocialTarget, + type TokenHealth, } from "@/lib/social-accounts"; import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions"; @@ -21,7 +23,7 @@ export const dynamic = "force-dynamic"; const HELP: Record< (typeof SOCIAL_NETWORKS)[number], - { portal: string; steps: string[]; caution?: string } + { portal: string; steps: string[]; caution?: string; tools?: { label: string; href: string }[] } > = { facebook: { portal: "https://developers.facebook.com/apps", @@ -30,6 +32,12 @@ const HELP: Record< "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.", ], + tools: [ + { label: "Débogueur de jeton (vérifier un jeton et sa date d'expiration)", href: "https://developers.facebook.com/tools/debug/accesstoken/" }, + { label: "Outil de jetons d'accès (voir les jetons de vos pages)", href: "https://developers.facebook.com/tools/accesstoken/" }, + { label: "Explorateur d'API Graph (tester un appel à la main)", href: "https://developers.facebook.com/tools/explorer/" }, + { label: "Documentation : jetons de longue durée", href: "https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" }, + ], }, linkedin: { portal: "https://www.linkedin.com/developers/apps", @@ -40,6 +48,10 @@ const HELP: Record< ], 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.", + tools: [ + { label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" }, + { label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" }, + ], }, }; @@ -70,6 +82,16 @@ export default async function ReseauxPage({ redirectUris.set(network, await redirectUri(network)); } + // État réel du jeton, demandé à la plateforme à chaque affichage : c'est le + // seul moyen de savoir qu'un jeton « sans expiration » a été révoqué. + const health = new Map(); + for (const network of SOCIAL_NETWORKS) { + const account = byNetwork.get(network); + if (account?.accessToken && account.targetId) { + health.set(network, await checkTokenHealth(network)); + } + } + // Cibles à proposer quand le compte administre plusieurs pages. const targets = new Map(); let targetError: string | null = null; @@ -140,6 +162,8 @@ export default async function ReseauxPage({ const status = expiryStatus(account?.expiresAt); const isConnected = !!account?.accessToken && !!account.targetId; const candidates = targets.get(network) ?? []; + const tokenHealth = health.get(network) ?? null; + const broken = tokenHealth?.ok === false; return (
@@ -162,7 +186,7 @@ export default async function ReseauxPage({

{SOCIAL_LABELS[network]}

- + {isConnected && ( @@ -172,10 +196,25 @@ export default async function ReseauxPage({ {account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`} {account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
+ {tokenHealth?.ok && ( + + ✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "} + + )} Pour changer de page, relancez une connexion. )} + {broken && ( + + Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront + tant que vous n'aurez pas cliqué sur Reconnecter. +
+ Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)} +
+
+ )} + {(status === "soon" || status === "expired") && isConnected && ( {status === "expired" @@ -276,7 +315,28 @@ export default async function ReseauxPage({ {help.portal} -
+ {help.tools && ( +
+
Outils pour inspecter un jeton
+ +
+ Ces outils servent à contrôler ou dépanner : le bouton + Connecter fait déjà l'échange complet — jeton court, + puis jeton de longue durée, puis jeton de page — vous n'avez aucun jeton à + copier à la main. +
+
+ )} + +
URL de redirection à déclarer
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"} diff --git a/src/db/schema.ts b/src/db/schema.ts index b04d79e..326d292 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -155,6 +155,11 @@ export const socialAccounts = pgTable("social_accounts", { targetName: varchar("target_name", { length: 200 }), connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }), connectedAt: timestamp("connected_at", { withTimezone: true }), + // Dernier contrôle de validité auprès de la plateforme : permet au tableau de + // bord d'alerter sans refaire un appel réseau à chaque affichage. + lastCheckAt: timestamp("last_check_at", { withTimezone: true }), + lastCheckOk: boolean("last_check_ok"), + lastCheckError: text("last_check_error"), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/lib/social-accounts.ts b/src/lib/social-accounts.ts index fc02828..8ea86ba 100644 --- a/src/lib/social-accounts.ts +++ b/src/lib/social-accounts.ts @@ -141,6 +141,92 @@ export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus { return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok"; } +/** + * Vérifie que le jeton fonctionne encore, en interrogeant la plateforme. + * + * « N'expire pas » ne veut pas dire « ne meurt jamais » : un jeton de page + * Facebook est invalidé si le mot de passe du compte connecté change, si ce + * compte perd son rôle d'administrateur de la page, si l'autorisation est + * retirée, ou si Meta le révoque. Sans cette vérification, on ne l'apprend + * qu'au moment où une publication échoue. + */ +export type TokenHealth = + | { ok: true; detail: string } + | { ok: false; reason: string }; + +export async function checkTokenHealth(network: SocialNetwork): Promise { + const health = await probeToken(network); + if (health) await rememberCheck(network, health); + return health; +} + +/** Contrôle mis en cache : évite un appel réseau à chaque affichage. */ +export async function tokenHealthCached( + network: SocialNetwork, + maxAgeMs = 6 * 3_600_000 +): Promise { + const account = await getSocialAccount(network); + if (!account?.accessToken || !account.targetId) return null; + + const fresh = + account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs; + if (fresh && account.lastCheckOk !== null) { + return account.lastCheckOk + ? { ok: true, detail: "Vérifié récemment" } + : { ok: false, reason: account.lastCheckError ?? "Jeton refusé" }; + } + return checkTokenHealth(network); +} + +async function rememberCheck(network: SocialNetwork, health: TokenHealth) { + await db + .update(socialAccounts) + .set({ + lastCheckAt: new Date(), + lastCheckOk: health.ok, + lastCheckError: health.ok ? null : health.reason.slice(0, 2000), + }) + .where(eq(socialAccounts.network, network)); +} + +async function probeToken(network: SocialNetwork): Promise { + const credentials = await resolveCredentials(network); + if (!credentials) return null; + + try { + if (network === "facebook") { + // Un jeton de page répond `/me` avec la page elle-même. + const res = await fetch( + `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me?${new URLSearchParams({ + fields: "id,name", + access_token: credentials.accessToken, + })}` + ); + if (!res.ok) return { ok: false, reason: await readError(res) }; + const page = (await res.json()) as { id?: string; name?: string }; + if (page.id && credentials.targetId && page.id !== credentials.targetId) { + return { + ok: false, + reason: `Le jeton ne pointe plus vers la bonne page (${page.name ?? page.id}).`, + }; + } + return { ok: true, detail: page.name ?? "Page accessible" }; + } + + const res = await fetch( + "https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED&count=1", + { headers: linkedinHeaders(credentials.accessToken) } + ); + if (!res.ok) return { ok: false, reason: await readError(res) }; + return { ok: true, detail: "Accès à la page confirmé" }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : "Plateforme injoignable", + }; + } +} + // ---- OAuth ---- export class SocialAuthError extends Error {}