Deux comportements demandés :
1. Une promotion n'apparaît sur le site que pendant sa période de validité.
Les lectures publiques passent par `VISIBLE_PROMO` (`src/lib/queries.ts`) :
statut `live` **et** fenêtre `starts_on` / `ends_on`, journée calculée en
Europe/Paris pour qu'un conteneur en UTC ne retire pas une offre deux
heures trop tôt. Le statut n'est pas touché : hors période, le backoffice
explique pourquoi l'offre n'est pas visible (`visibilityNote`) et elle
revient d'elle-même si les dates changent.
2. La publication peut être programmée. `promotions.publish_at` : l'adhérent
propose une date, le modérateur la garde, la déplace ou la vide dans le
formulaire « Valider ». Avec une échéance future la promotion passe au
statut `scheduled` — rien n'est visible, rien n'est diffusé — et
`releaseDuePromotions()` fait la bascule à l'heure dite, site et réseaux
ensemble. Un bouton « Publier maintenant » court-circuite l'attente.
Mécanique :
- `src/lib/promo-schedule.ts` (pur, testé) : conversion `datetime-local` ⇄
heure de l'association, changements d'heure compris, et refus d'une saisie
illisible plutôt qu'une publication immédiate involontaire.
- `src/lib/promo-publish.ts` reprend `publishPromoShares()` : deux appelants
désormais, une action serveur et la boucle de libération, qui n'a pas de
requête et ne peut donc pas appeler `revalidatePath()`. Idem pour
`logActivity()` dans `src/lib/activity-log.ts`.
- La bascule `scheduled → live` est un `UPDATE … RETURNING` filtré sur le
statut : atomique, donc pas de double publication même à plusieurs
instances, en plus de la garde existante sur `social_posts`.
- Déclencheur : `src/instrumentation.ts`, boucle d'une minute avec rattrapage
au démarrage (`PROMO_SCHEDULER=off` la désactive). Le travail vit dans
`instrumentation-node.ts`, écarté du bundle edge par `next.config.mjs` :
Next compile aussi l'instrumentation pour le middleware, où `pg` ne peut
pas être embarqué.
Migration 0016 : `promo_status += 'scheduled'`, `promotions.publish_at`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Suite de l'audit : les cinq points laissés en suspens sont traités.
Mot de passe
- `changeOwnPassword` exige désormais le mot de passe actuel. Un poste laissé
ouvert ne suffit plus à s'approprier un compte. Les erreurs reviennent sur
l'écran avec un message au lieu d'une page d'erreur brute.
Sessions révocables
- Colonne `users.session_version`, portée dans le jeton et comparée à la base.
- `getSession()` (src/lib/session.ts) remplace `auth()` sur les 20 pages et
actions : rôle, rattachement adhérent et existence du compte sont relus à
chaque requête. Supprimer un compte ou réinitialiser un mot de passe coupe
immédiatement les sessions ouvertes, sans attendre l'expiration du jeton.
Dépendances — de 8 vulnérabilités (2 critiques) à zéro
- next 15.5.19 → 15.5.21, next-auth beta.25 → beta.32, drizzle-orm 0.38 → 0.45.
- postcss et sharp forcés par `overrides` sur leurs versions corrigées, Next ne
les ayant pas encore reprises ; drizzle-kit et esbuild montés côté outillage.
- `npm audit fix --force` a été écarté : il proposait de RÉTROGRADER Next en
9.3.3 et eslint-config-next en 12, ce qui aurait cassé l'application.
- `eslint-config-next` traînait dans node_modules sans être déclaré : retiré.
CSP complète
- Politique à nonce posée par le middleware, nonce régénéré à chaque requête,
`script-src` sans 'unsafe-inline'. `style-src` garde 'unsafe-inline' : tout le
design repose sur des attributs style, et une injection de style n'a pas la
portée d'une injection de script.
- Conséquence assumée : rendu dynamique pour toutes les pages, un HTML
pré-généré ne pouvant pas porter de nonce.
Tests
- `npm test` (runner natif node:test via tsx), 18 tests sur le filtre XSS, la
limitation des tentatives de connexion et le chiffrement des jetons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Audit de l'application. Cinq corrections, la première critique.
XSS stocké non authentifié (critique)
- Le journal d'activité était rendu avec `dangerouslySetInnerHTML` alors qu'il
agrège des saisies de tiers : titre de promotion d'un adhérent, et surtout nom
laissé dans le formulaire de contact PUBLIC. N'importe quel visiteur pouvait
donc faire exécuter du script dans le navigateur d'un administrateur, et
prendre la main sur son compte.
- Défense aux deux bouts : `sanitizeActivityMessage` ne laisse passer que
<strong> à l'écriture, et `activityNodes` rend le message en éléments React —
un message déjà stocké en base ne peut plus rien injecter.
SSRF
- `loadPromoImage` allait chercher toute URL http(s) placée dans le champ image
d'une promotion, et en poussait le contenu vers Facebook ou LinkedIn : de quoi
atteindre un service interne ou les métadonnées de l'hébergeur. Seules les
data-URI sont désormais acceptées, ce qui couvre tous les usages réels.
- Les images sont en plus validées à l'écriture (type et taille), côté promotion
comme côté profil adhérent.
Authentification
- Aucun frein sur la connexion : blocage progressif après 8 échecs par compte.
- La comparaison bcrypt est menée même sans compte correspondant, sinon le temps
de réponse révélait quelles adresses existent.
Aléa
- Les mots de passe temporaires venaient de `Math.random()`, prédictible :
passage à `randomInt` de node:crypto, et longueur portée à 12.
En-têtes
- nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy et
frame-ancestors. Une CSP complète demandera des nonces sur les scripts Next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Le menu empilait douze liens sous un seul intitulé, avec des carrés, ronds et
losanges qui ne portaient aucun sens. Sur téléphone, la barre latérale se
dépliait en ligne et devenait un pavé de boutons inutilisable.
Navigation
- Six rubriques au lieu d'une pile : Pilotage, Vie du réseau, Rencontres,
Communication, Configuration, Côté adhérent. Le quotidien est ainsi séparé de
ce qu'on ne touche qu'occasionnellement.
- Une icône par entrée (`BackendIcons.tsx`) à la place des formes génériques.
- Le menu devient une table de données filtrée par capacité, au lieu de douze
blocs JSX répétant les mêmes appels à `can()`.
Mobile
- Sous 1024px la barre latérale devient un tiroir : voile de fond, fermeture par
Échap, par clic hors zone et à la navigation, défilement de la page bloqué,
actions Voir le site / Déconnexion rapatriées dans le tiroir.
- `min-height: 0` sur la zone de navigation : sans lui l'enfant flex refusait de
rétrécir et la carte utilisateur recouvrait les derniers liens, interceptant
les clics.
- En-tête public repensé : logo toujours présent sur téléphone (seul retour à
l'accueil), accès adhérent sur la même ligne, navigation répartie en dessous
au lieu de se replier en escalier.
- Liste des adhérents : le tableau à cinq colonnes devient une fiche par
adhérent, sur deux lignes constantes, sans défilement horizontal.
- Compteurs du tableau de bord en deux colonnes plutôt qu'empilés.
Vérifié : aucun débordement horizontal sur les 23 pages en 320, 390 et 768 px ;
tiroir et cibles tactiles testés au doigt sur toutes les interactions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.
- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
`SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
transite par une URL — les cibles sont relistées côté serveur au moment de la
sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
variables d'environnement ensuite. Les installations existantes continuent de
fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
valeur conservée.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Comptes de connexion adherent
- addMember cree desormais un compte users (role member, lie a la fiche)
avec un mot de passe temporaire genere
- Le mdp temporaire s'affiche sur la fiche admin (/backend/adherents/[id])
jusqu'a la 1ere connexion ; bouton "Reinitialiser le mot de passe"
- 1ere connexion : changement de mot de passe force (must_change_password)
via /backend/changer-mot-de-passe, redirection geree dans authorized()
- users += must_change_password, temp_password (migration 0003)
Boite de reception admin (consultation)
- Page /backend/demandes : demandes d'adhesion + messages de contact
avec statuts (approuvee/rejetee, lu/archive)
- Lien sidebar + badge (nouveaux), carte tableau de bord cliquable
Espace adherent
- L'adherent peut editer sa propre fiche (updateOwnProfile) : infos,
description, horaires, tags, image d'entete + logo
- Statut/categorie/mise a l'honneur restent reserves au staff
Promotions : moderation deja en place, inchangee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>