Le port et la case « connexion chiffrée » étaient deux champs indépendants
qu'il fallait accorder de tête. Passer en 587 sans décocher fait tenter une
poignée de main TLS sur un port qui répond en clair : la connexion meurt sur
un « wrong version number » que rien ne traduisait.
- `src/lib/smtp-config.ts` (pur, testé) : `impliedSecure()`, `tlsMismatch()`
et `describeSmtpError()`, qui traduit les échecs courants — mauvaise case,
port filtré, identifiants refusés, certificat, expéditeur rejeté — en
consigne actionnable, l'explication avant la trace puisque l'écran tronque.
- `SmtpAccountForm` (client) réaligne la case sur le port saisi, propose les
deux ports courants en pastilles et affiche le désaccord s'il subsiste. Le
mot de passe enregistré ne descend pas en prop, seul un booléen le fait.
- Transport : `requireTLS` hors TLS implicite — sans lui STARTTLS reste
opportuniste et l'identifiant partirait en clair chez un serveur qui ne
l'annonce pas — SNI explicite, TLS 1.2 minimum, et délais ramenés à
quelques secondes pour qu'un port bloqué en sortie rende son verdict au
lieu de figer l'action serveur.
- `verifySmtp` referme la socket même en échec et dit sur quel port et dans
quel mode la connexion a abouti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RCUSintYfXnBtwFKQezFSt
Un adhérent voyait deux fois le même menu : la barre latérale ne portait chez
lui que la section « Côté adhérent » — Informations, Mon profil, Mes
promotions — et EspaceHeader rend déjà ces trois entrées en onglets sous le
bandeau. Un quart de l'écran pour une redite.
BackendShell branche donc sur isStaff : hors staff, il ne reste qu'un bandeau
de marque, aligné sur la colonne de contenu, et la page prend toute la
largeur. Le staff garde la barre, qui est chez lui la navigation principale et
le seul chemin vers son propre espace.
Le piège était sur téléphone : sous 1024px les actions du compte vivaient dans
le tiroir, pas dans l'entête. Sans tiroir, la déconnexion serait devenue
inatteignable — .backend-header--plain rétablit leur affichage à toutes les
largeurs, et un test le verrouille avec l'absence d'aside et de bouton de
tiroir dans la variante.
Vérifié au navigateur en 1440 et 390 px, adhérent et administrateur : aucune
barre ni bouton de tiroir côté adhérent, actions présentes aux deux largeurs,
aucun débordement horizontal ; les dix-sept entrées de l'administrateur et son
tiroir sont intacts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.
Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.
- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.
Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
Deux réglages dans Backend › Paramètres :
- `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé.
`src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la
saisie ne peut donc pas s'échapper du script en ligne. Les deux `<script>`
portent le nonce de la CSP, et seules les pages publiques sont mesurées
(`isTrackedPath` écarte `PRIVATE_PATHS`, le chemin venant du nouvel en-tête
`x-pathname` du middleware — un layout ne connaît pas l'URL demandée).
- `google_site_verification` rend la balise de validation Search Console via
`verification.google`, après contrôle du jeton.
La CSP gagne les hôtes Google Analytics en `connect-src` : elle est posée sur
le runtime Edge, qui ne peut pas lire la base pour savoir si la mesure est
active. `script-src` est inchangé, `'strict-dynamic'` couvrant les scripts que
gtag.js charge ensuite.
Le plan du site n'a besoin de rien : `/sitemap.xml` est déjà servi et annoncé
dans `/robots.txt`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7v5KcYUo2taDVDgehs7ZH
CLAUDE.md gagne les deux sections et, surtout, les pièges qui coûteraient
une soirée à retrouver : `db.execute` qui rend les colonnes brutes, les
deux interrupteurs de boucle qu'il ne faut pas confondre, la rotation du
jeton Microsoft, l'adresse d'expédition lue chez le fournisseur, et la
raison pour laquelle les mots de passe temporaires ne passent jamais par
la file.
README et .env.example décrivent les trois transports, le piège des sept
jours côté Google, et le repli SMTP par variables d'environnement qui
permet un premier déploiement sans démarche préalable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
`tests/security.test.ts` gagne cinq blocs qui verrouillent ce qui compte
et qu'aucun type ne protège :
- les cinq actions qui émettent un mot de passe appellent `sendNow` et
jamais `queueMail`, et la trace journalisée porte un corps vide ;
- l'écran Boîte mail ne déchiffre aucun secret, saisit en champ masqué et
annonce qu'un champ vide conserve la valeur enregistrée ;
- les trois fonctions qui écrivent un secret en base le chiffrent ;
- ni copie ni copie cachée dans la chaîne d'envoi, et `to_address` reste
une adresse unique et obligatoire ;
- aucune lecture publique ne touche aux informations, et rien de ce qui
les rend n'emprunte `dangerouslySetInnerHTML` ;
- chaque boucle de fond garde son interrupteur et reste hors du bundle
edge.
`tests/backend-nav.test.ts` attrape l'oubli qu'on ne voit qu'à l'écran :
ajouter une page demande de toucher aux titres, aux sections et au jeu
d'icônes, et rater l'un des trois donne un en-tête vide ou une icône
absente.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
Les deux écrans de composition gagnent le choix des destinataires — tous
les adhérents actifs, une catégorie de métier, ou les inscrits à la
rencontre — et un bouton qui envoie pour de bon. « M'envoyer un test »
part en direct, pour un verdict immédiat ; une diffusion passe par la
file, une ligne par destinataire.
Copier, brouillon Outlook et export HTML restent en place : ils servent
encore aux cas particuliers, et au cas où aucune boîte n'est configurée.
L'aperçu comme l'envoi s'appuient désormais sur l'adresse publique
enregistrée plutôt que sur `window.location.origin` : c'est celle que
verront les destinataires, pas celle par laquelle l'administrateur est
arrivé.
Un modérateur gère les rencontres mais n'écrit pas à tous les adhérents :
le bouton d'envoi du composeur reste conditionné à `manageEmails`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
La case « Envoyer aussi par e-mail » du bouton Publier met une ligne par
adhérent dans la file : jamais de copie partagée, aucune adresse visible
des autres. `informations.email_sent_at` fait office de garde
anti-double-diffusion, comme `publishPromoShares` pour les réseaux — une
information dépubliée puis republiée ne repart pas.
La diffusion est réservée à `manageEmails` : un modérateur publie, il
n'écrit pas à tous les adhérents. Sans adresse publique enregistrée, rien
ne part et le bandeau le dit, plutôt que d'expédier un message dont le
logo et tous les liens seraient cassés.
L'image de couverture passe par `/api/informations/<id>/image` et non en
data-URI : Gmail et Outlook suppriment les `<img src="data:">`, et
l'incorporer gonflerait chaque ligne de la file à plusieurs méga-octets.
La route ne sert que les informations publiées.
Deux défauts corrigés en chemin, trouvés en jouant le parcours complet :
- `db.execute` rend les colonnes brutes, sans les rebaptiser comme le fait
le constructeur de requêtes : `to_address` n'arrivait pas dans
`toAddress`, et nodemailer refusait tous les messages faute de
destinataire.
- `PROMO_SCHEDULER=off` empêchait le chargement du module de fond, donc
coupait aussi l'envoi des e-mails. Chaque boucle a maintenant son propre
interrupteur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
Les cinq actions qui émettent un mot de passe temporaire — création d'un
adhérent, rattrapage des comptes manquants, réinitialisation, invitation
staff, approbation d'une demande — l'envoient désormais au destinataire.
L'envoi est **en ligne directe**, jamais mis en file : `mail_messages.html`
est stocké en base, et CLAUDE.md pose que le mot de passe temporaire n'est
jamais conservé. Le clair n'existe donc que dans la portée de l'action, et
seule une trace sans contenu est journalisée.
C'est un plus, jamais un point de rupture : `OneTimeCredentials` continue
d'afficher le mot de passe quoi qu'il arrive, et annonce « envoyé à … » ou
« envoi impossible, transmettez-le à la main ». Boîte non configurée,
l'écran se comporte exactement comme avant.
Au passage, le rattrapage des comptes manquants ne perd plus l'état d'envoi
de chaque ligne : la liste propage l'objet entier au lieu d'en recopier
trois champs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
Le site savait composer de beaux messages et ne savait pas les envoyer :
`email-client.ts` ne proposait que la copie ou le téléchargement d'un
brouillon Outlook, complété à la main. `/backend/boite-mail` branche
désormais la boîte de l'association, sur le modèle exact de l'écran
Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ
laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne
lève jamais.
Trois transports, un seul actif à la fois, désigné par l'administrateur —
pas de cascade automatique : si Google se bloque, la bascule se voit.
- **Google** passe par l'API Gmail plutôt que par SMTP avec XOAUTH2 :
celui-ci exigerait `https://mail.google.com/`, portée *restreinte* et
donc audit de sécurité, là où `gmail.send` est simplement *sensible*.
- **Microsoft** passe par Graph : l'authentification basique SMTP est
désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
- **SMTP** couvre le reste via `nodemailer`, seule dépendance ajoutée.
`from_address` est **lu chez le fournisseur** et non saisi : Gmail expédie
comme l'utilisateur authentifié, Graph comme la boîte. Une adresse d'un
autre domaine ferait tomber SPF et DKIM.
La file `mail_messages` porte un destinataire unique par ligne — la
confidentialité d'une diffusion est structurelle, aucune copie partagée
n'est possible. Elle est vidée par la boucle de fond, avec réclamation en
`FOR UPDATE SKIP LOCKED`, réessais espacés et reprise des verrous laissés
par un conteneur arrêté en plein envoi.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
L'association pouvait publier des promotions et des rencontres, mais rien
à destination de ses seuls adhérents connectés. Un troisième onglet, placé
en tête de l'espace, porte désormais ce que publie le bureau : annonces,
convocations, rappels.
Côté rédaction, `/backend/informations` (capacité `manageInformations`,
administrateur et modérateur) : titre, texte balisé avec barre de mise en
forme et aperçu, image de couverture, brouillon puis publication,
épinglage — une seule information en tête à la fois, appliqué dans
l'action plutôt que par un index conditionnel.
Côté lecture, `information_reads` porte une ligne par lecture plutôt
qu'une date « vu jusqu'ici » sur l'utilisateur : c'est ce qui permet à la
fois la pastille « Nouveau » par information et le « lue par 12 / 40 » du
back-office. Le marquage passe par une action appelée après affichage :
la pastille de l'onglet et celle de la barre latérale sont calculées par
deux composants serveur distincts, dont l'ordre de rendu n'est pas
garanti, et se seraient contredites sur la même page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
Sous-ensemble de Markdown — gras, italique, listes, liens, sous-titres —
analysé par `src/lib/rich-text.ts` puis rendu en éléments React, jamais en
HTML brut : même principe que le journal d'activité. Un éditeur WYSIWYG
aurait imposé de stocker du HTML tiers puis de l'assainir, soit une
seconde dépendance et une seconde surface d'attaque.
Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
`richTextToEmailHtml()` pour le message. L'aperçu du formulaire passera
par le premier, il sera donc fidèle par construction.
`safeHttpUrl()` remplace le `webUrl()` privé d'`email-templates.ts` : un
lien hors http/https perd sa cible et ne garde que son libellé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
`downloadOutlookDraft` assemblait à la main un message RFC 822 complet
(sujet RFC 2047, base64 replié, partie `cid:` en ligne). Ce code a un
second appelant à venir : le champ `raw` de l'API Gmail attend exactement
ce format. Il part donc dans `src/lib/mime.ts`, pur et verrouillé par
`tests/mime.test.ts`, plutôt que d'importer plus tard l'entrée interne
`nodemailer/lib/mail-composer`.
Le constructeur gagne au passage ce que le brouillon Outlook n'avait pas
besoin de gérer : repli des mots encodés longs sans couper une séquence
UTF-8, `multipart/alternative` pour le repli texte, mise en forme des
adresses, et neutralisation des CR/LF dans les valeurs d'en-tête — un
retour à la ligne dans un objet permettrait sinon d'injecter un `Bcc:`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
L'annuaire, les pages métier et la fiche adhérent montrent désormais la
personne à joindre chez l'adhérent — nom, prénom, ligne directe — mais
uniquement à un visiteur connecté.
Trois colonnes sur `members` (`contact_first_name`, `contact_last_name`,
`contact_phone`), saisies dans les deux formulaires de fiche via le
composant partagé `MemberContactFields`.
Le filtrage ne repose pas sur du CSS ni sur un rendu conditionnel tardif :
aucune requête publique ne lit ces colonnes. Seules `getMemberContacts()`
et `getMemberContact()` les rapatrient, et les pages ne les appellent
qu'après un `getSession()` positif. Un visiteur anonyme ne reçoit donc
rien — ni dans le HTML, ni dans le JSON-LD, ni dans les props du composant
client de l'annuaire. `tests/security.test.ts` verrouille cette séparation.
`src/lib/member-contact.ts` reste pur (composition du nom, lien `tel:`) et
est couvert par `tests/member-contact.test.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TAK3c4jAUqxVMzy746wWQ
Sur la page d'accueil, les mentions d'« entreprises » deviennent
« entreprises de service » : texte alternatif du logo, sous-titre du
hero, phrase d'accroche et appel à adhésion (les deux variantes,
avec et sans compteur d'adhérents).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkXREQkpPAKHFLgtnFFkCM
Une description longue étirait sa carte, donc toute la rangée de la grille,
et laissait les voisines avec un grand vide sous leur bouton — même défaut
que les cartes de l'annuaire avant leur uniformisation.
Le titre (2 lignes), le lieu (1 ligne) et la description (3 lignes) ont
désormais une hauteur figée et sont tronqués en fin de ligne ; un espaceur
pousse les compteurs et les boutons en bas de carte. La ligne de lieu reste
réservée quand la rencontre n'en a pas, sinon sa carte remonterait tout son
bas.
Le texte complet n'est plus perdu : « Voir le détail » ouvre une fenêtre
avec l'image, la date, le lieu, la description entière, les places et le
bouton d'inscription. Le texte tronqué reste dans le HTML (troncature CSS),
l'indexation n'y perd rien.
Les dates sont formatées côté serveur et passées déjà rendues : les formater
dans le composant client les aurait rendues dans le fuseau du visiteur et
provoqué une erreur d'hydratation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
L'application publie le fichier tel quel sur Facebook et LinkedIn : aucun
recadrage, aucune marge. Un visuel qui n'est pas au format attendu est donc
recadré — ou entouré de bandes de couleur prises dans l'image — par la
plateforme, ce qui ne se voit nulle part côté site puisque les cartes
recadrent en `cover`.
Le formulaire de dépôt affiche maintenant les dimensions, le format et le
poids du fichier choisi, et dit ce que les réseaux en feront : format non
carré, image trop petite pour un fil d'actualité, fichier trop lourd pour
l'envoi. Purement informatif, rien n'est bloqué.
`src/lib/image-info.ts` est pur et verrouillé par `tests/image-info.test.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
Le bloc « Diffusion demandée » du formulaire « Valider » annonçait une
publication « dès la validation », alors qu'une promotion validée avec une
date future passe en `scheduled` et n'est diffusée qu'à l'échéance, par
`releaseDuePromotions()`. Le texte décrit maintenant les deux cas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
Une demande approuvée gardait ses boutons « Approuver & créer » et
« Rejeter », un message lu son « Marquer lu », un message archivé son
« Archiver » : rien n'indiquait que l'action avait déjà été faite.
Chaque ligne ne propose plus que les transitions qui ont du sens :
- demande nouvelle → Approuver & créer / Rejeter
- demande rejetée → Rouvrir
- demande approuvée → aucun bouton (le compte existe)
- message nouveau → Marquer lu / Archiver
- message lu → Archiver
- message archivé → Désarchiver
Le masquage du bouton d'approbation vit dans `ApproveRequestForm` et non
dans la page : après l'approbation, `router.refresh()` repasse la demande
en « approuvée », et un test côté serveur démonterait le bloc
d'identifiants à usage unique avant qu'il ait été lu.
Deux gardes côté serveur couvrent l'onglet resté ouvert sur l'ancien
état : `approveMembershipRequest` refuse une demande déjà traitée, et
`setRequestStatus` ne touche plus une demande approuvée.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
Une description longue étirait sa carte, donc toute la rangée de la
grille, et laissait les voisines avec un grand vide sous le texte.
Le titre (2 lignes) et la description (4 lignes) ont désormais une
hauteur figée et sont tronqués en fin de ligne ; un espaceur pousse
la ligne d'adresse en bas de carte, et cette adresse tient sur une
seule ligne avec ellipsis. Toutes les cartes font la même hauteur,
quel que soit le texte saisi par l'adhérent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
« Application error: a server-side exception has occurred », digest 2117999835,
sur /backend/adherents : l'action levait `Error("Un compte existe déjà avec cet
e-mail.")`. Next masque le message d'une exception de server action en
production et remplace la page entière par son écran d'erreur — un e-mail déjà
pris, cas de saisie parfaitement ordinaire, mettait donc la page par terre.
Ces échecs attendus sont désormais **renvoyés** (`ActionError`) au lieu d'être
jetés, dans `addMember`, `inviteAdmin` et `approveMembershipRequest` :
- Le formulaire affiche la vraie raison, l'e-mail en cause inclus.
- `AddMemberPanel` réinjecte la saisie : React vide un formulaire non contrôlé
après chaque action, il fallait sinon tout retaper.
- `InviteAdminForm` avait un rattrapage, mais devait deviner le motif faute de
message ; il affiche maintenant celui de l'action. Son `catch` ne couvre plus
que l'imprévu.
- `ApproveRequestForm` n'en avait aucun et tombait de la même façon.
Le `throw` reste réservé aux violations d'accès.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Renouveler un jeton demandait de deviner que « Reconnecter » servait à ça, et
les outils Meta / LinkedIn étaient enfouis sous « Comment obtenir ces
identifiants ? ». Chaque réseau connecté porte maintenant un bloc dédié :
- Un bouton « Renouveler le jeton <réseau> », qui relance l'échange OAuth
complet (jeton court → longue durée → jeton de page).
- Les deux liens utiles à ce moment-là, chacun avec ce qu'il sert à faire :
débogueur de jeton et outil de jetons d'accès pour Facebook, jetons de
l'application et documentation de durée de vie pour LinkedIn.
- Le bloc passe en ambre quand le jeton est mort, expiré ou proche de l'être.
« Reconnecter » disparaît : il faisait exactement la même chose que le nouveau
bouton, à dix pixels de distance. Une fois connecté, il ne reste que
« Déconnecter » ; « Connecter » n'apparaît plus que pour la première liaison.
Les bannières d'alerte nomment désormais le bouton qui existe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
La colonne de droite portait un aperçu étiré sur toute sa largeur : le visuel
carré rendait la carte démesurée, très loin de ce que verra le visiteur.
- L'aperçu est borné à 320 px, la largeur d'une carte dans la grille du site.
- Au-dessus, un récapitulatif « Dates de votre offre » : durée de validité et
moment de publication, les deux informations que la carte ne montre pas.
Sans saisie il affiche les valeurs par défaut en gris (« Sans date limite »,
« Dès la validation par l'association ») ; des bornes incohérentes s'y
signalent en rouge, comme sous le champ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
La première version reprenait le style d'une *section* de l'accueil — un grand
cartouche blanc arrondi — qui détonnait à côté de l'annuaire et de
« L'association ». Elle suit maintenant le même gabarit que l'annuaire :
- `SiteHeader … logo` : toutes les pages internes affichent le logo, seule
l'accueil s'en passe.
- Bloc de titre sur le fond beige (pastille « N offres en cours », `<h1>` au
même `clamp`, chapeau, étincelles) au lieu du cartouche.
- Grille de cartes à même la page, `gap` aligné sur les autres listes.
- Bandeau d'adhésion bleu en pied de page, identique à celui de l'annuaire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
`/promotions` liste toutes les offres en cours ; l'accueil en montre six et
renvoie vers elle. Les deux listes partagent `PromoCard`, extrait de la page
d'accueil : un seul rendu de carte à maintenir.
- `getLivePromotions(limit)` accepte `null` pour « toutes les offres », la
limite restant appliquée en SQL et non après coup.
- Page complète : `<h1>` unique, `<main>`, métadonnées et canonique via
`pageMetadata`, JSON-LD `BreadcrumbList` + `ItemList`, entrée au sitemap,
état vide qui renvoie vers l'annuaire.
- Les liens « Promotions » de l'en-tête, du pied de page et de l'espace
adhérent visent désormais cette page. Idem pour le lien d'une publication
réseau sans adhérent rattaché, qui pointait sur l'ancre `/#promotions`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
L'angle de la carte est rendu à la catégorie ; la pastille du commerce se
place devant son nom, en pied de carte, là où on lit déjà qui propose l'offre.
La période de validité passe sous le nom, dans le même bloc.
Appliqué à la carte d'accueil, à l'aperçu en direct de l'espace adhérent et à
la carte de modération. Retiré de la carte de la fiche adhérent : elle n'y
affiche pas de nom de commerce, et le logo figure déjà en tête de page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Le logo du commerce identifie l'offre avant même qu'on lise le nom. Il est
posé en pastille ronde dans l'angle haut-gauche de la carte, la catégorie se
décalant juste à côté sur la même ligne.
- `src/components/MemberAvatar.tsx` : logo en `contain` sur fond blanc (les
logos d'adhérents sont des marques sur fond clair, un `cover` les rognerait),
repli sur les initiales colorées quand la fiche n'a pas de logo.
- Appliqué à la carte d'accueil, à la carte de la fiche adhérent, à l'aperçu
en direct de l'espace adhérent et à la carte de modération : même composition
partout, l'aperçu montre donc bien ce que verra le visiteur.
- `getLivePromotions` remonte `members.logo_url` ; la page espace le passe au
formulaire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
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
Une promotion peut désormais porter une date de début et une date de fin,
toutes deux facultatives et indépendantes. Sans date, rien n'est affiché.
- Schéma : `promotions.starts_on` / `ends_on` (`date`), migration 0015.
- `src/lib/promo-validity.ts` (pur, testé) : `formatValidity()` produit
« Valable du 1er au 15 mars 2027 », « Valable jusqu'au … », « Valable à
partir du … » en repliant mois et année quand ils se répètent ;
`formatValidityShort()` pour les lignes de méta ; `isRangeInvalid()` pour
la validation.
- Espace adhérent : deux champs date, aperçu en direct de la phrase et
blocage de l'envoi si la fin précède le début.
- `publishPromo` relit et revalide les bornes côté serveur.
- Affichage : carte d'accueil, fiche adhérent, liste de l'adhérent, carte de
modération — et le message Facebook / LinkedIn, via le même formateur.
- L'ancien texte libre `valid_until` reste comme repli d'affichage pour les
promotions de démonstration.
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
Cliquer sur « Connecter » sans avoir enregistré l'URL publique du site
renvoyait une erreur, et la redirection retombait sur https://0.0.0.0 :
la directive upgrade-insecure-requests était posée même sur une page
servie en HTTP, le navigateur basculait donc la navigation vers un
https:// inexistant.
- `publicBaseUrl()` : réglage `site_public_url`, variables d'environnement,
puis origine de la requête (X-Forwarded-Host / X-Forwarded-Proto). L'adresse
de retour OAuth et le lien des publications l'utilisent ; `seo-server`
s'appuie dessus au lieu de dupliquer la déduction.
- `src/lib/site-url.ts` : déduction pure et testée de l'origine, HTTP par
défaut pour localhost et les IP nues, HTTPS pour un nom de domaine.
- Middleware : `upgrade-insecure-requests` uniquement quand la page est servie
en HTTPS.
- Page Réseaux : l'adresse détectée est annoncée comme valeur par défaut, avec
le rappel que Facebook et LinkedIn exigent du HTTPS sur un domaine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
- Espace adhérent : /backend/espace (profil, inscriptions, droit à l'image)
et /backend/espace/promotions (dépôt et suivi). Bandeau et onglets communs
(EspaceHeader) avec le nombre d'offres en ligne ; la page promotions ouvre
sur « Mes promotions en cours », puis le formulaire, puis les offres en
attente et l'historique. Deux entrées dans le menu, titres de page dédiés,
revalidation des deux chemins.
- Fiche adhérent : colonne members.contact_email (migration 0013), saisie
dans l'espace adhérent et la fiche staff ; la fiche publique et le JSON-LD
affichent contact_email, sinon l'e-mail du compte.
- VitrineImage : sans photo de couverture, le logo est contenu à ~60 % d'un
cadre à hauteur fixe sur un fond du logo flouté ; le conteneur sans hauteur
qui laissait le logo déborder est supprimé.
- Promotions : la catégorie devient un type de produit ou de service
(src/lib/promo-categories.ts, 10 groupes, ~85 entrées) pré-sélectionné
depuis le métier de l'adhérent, à la place de la liste des métiers.
- Tests : catégories de promo (largeur, unicité, pré-sélection pour chaque
métier du référentiel).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
- src/lib/tags.ts (pur) : mots-clés par métier pour les 104 catégories,
vocabulaire transversal détecté dans la description (fait maison, bio,
livraison, sur rendez-vous, devis gratuit…), suggestTags() qui combine
métier, commune et description, autoTags() qui n'intervient qu'à vide.
- Enregistrement : addMember, updateMember et updateOwnProfile passent par
resolveMemberTags() ; un champ laissé vide reçoit les suggestions, une
saisie n'est jamais remplacée.
- Formulaires : composant TagsField (champ non contrôlé + pastilles de
suggestions recalculées à chaque frappe depuis catégorie, commune et
description ; « Tout ajouter »), dans l'espace adhérent et la fiche staff.
- Fiche publique : les fiches jamais ré-enregistrées affichent les tags
suggérés et les transmettent dans LocalBusiness.keywords.
- tests/tags.test.ts : couverture de chaque métier, nettoyage, détection,
ordre des suggestions, non-écrasement.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
- Fiches adhérents : URL canonique /adherents/12-au-bon-pain-frouard
(memberPath). L'identifiant en tête suffit ; /adherents/12 et tout slug
périmé sont redirigés de façon permanente vers la forme canonique, donc
les anciens liens et les partages sociaux restent valides. Tous les liens
(accueil, carrousel, annuaire, JSON-LD, sitemap, publications Facebook /
LinkedIn) passent par memberPath.
- Pages métier /annuaire/[categorie] : une page indexable par catégorie
avec titre, introduction (effectif, communes), grille d'adhérents, fil
d'Ariane et ItemList. Sans adhérent : noindex et hors sitemap. Section
« Parcourir par métier » sur l'annuaire et « Autres métiers » sur chaque
page pour le maillage interne.
- Composants partagés MemberCard et CategoryLinks ; slugify déplacé dans
src/lib/slug.ts.
- Backend : conseils de rédaction sous les champs description et tags des
formulaires adhérent (espace adhérent et fiche staff).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
- Accueil : le bandeau « Faites partie de l'aventure » affiche le nombre
d'adhérents validés (statut active) au lieu de « plus de 120 » codé en dur.
- Pied de page : les boutons Facebook / LinkedIn ont la même largeur (grille à
une colonne dimensionnée sur le plus long) et une icône à largeur fixe.
- Catégories : référentiel unique dans src/db/categories.ts (104 métiers, dont
assurance, bazar/discount, grande distribution, librairie-presse, tabac,
vétérinaire, auto-école, etc.), inséré par la migration 0012 avec
ON CONFLICT (slug) : les catégories manquantes apparaissent au prochain
démarrage, sans écraser un libellé renommé depuis le backend. Le seed et
l'action « Ajouter une catégorie » réutilisent ce référentiel et sa palette.
- tests/categories.test.ts vérifie unicité des slugs, contraintes de la table
et cohérence entre le référentiel et la migration.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
Le Caddyfile gagne un bloc fourre-tout http:// à côté du bloc du nom de
domaine. L'installation complète, Caddy compris, devient donc utilisable
immédiatement sur http://IP_DU_SERVEUR, et bascule seule en HTTPS dès que le
DNS pointe sur la machine : Caddy réessaie la demande de certificat en
arrière-plan, sans intervention ni redémarrage.
Le préfixe http:// désactive le HTTPS automatique pour ce seul bloc, aucune
autorité ne délivrant de certificat pour une IP nue. Le bloc du domaine reste
prioritaire et sa redirection HTTP vers HTTPS est insérée avant le fourre-tout.
Vérifié avec le binaire Caddy 2.8.4 : configuration valide, et à l'exécution un
308 vers https:// pour l'en-tête Host du domaine contre un passage au proxy
pour l'accès par IP.
Rend inutile la surcouche docker-compose.http.yml, retirée, la surcouche Caddy
couvrant désormais les deux situations. README et .env.example décrivent ce
parcours unique.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
Ajoute docker-compose.http.yml, qui publie l'application sur le port 80 et
laisse Compose créer le réseau proxy. Permet de valider une installation sur
l'IP du serveur avant que le DNS ne soit en place, sans HTTPS possible à ce
stade (pas de certificat pour une IP nue).
Documente les deux phases dans le README et .env.example : HTTP simple d'abord,
puis bascule vers la surcouche Caddy en changeant la seule ligne COMPOSE_FILE,
les volumes conservant base, compte admin et secret de session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
Ajoute docker-compose.caddy.yml et un Caddyfile pour servir le site sur un
nom de domaine en 80/443 avec certificat automatique, sans Nginx Proxy
Manager. Le port 8413 n'est plus publié dans ce mode et le réseau proxy est
créé par Compose. Procédure documentée dans le README et .env.example.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8UUiQtBWYWNFcsea8spnX
- Layout : metadataBase, gabarit de titre « %s · Plein R », description,
mots-clés, Open Graph / Twitter, robots, icônes, manifeste, viewport et
couleur de thème ; JSON-LD Organization + WebSite (recherche annuaire).
- Chaque page publique pose titre, description (≤ 160 caractères) et
canonique via pageMetadata() ; l'annuaire ignore ainsi ?q=. Fiche adhérent :
titre « Nom · Catégorie à Ville », description dérivée, LocalBusiness avec
adresse, contacts et horaires (OpeningHoursSpecification), BreadcrumbList.
Annuaire : ItemList des adhérents. Association : BusinessEvent par
rencontre à venir. Rencontres passées : fil d'Ariane.
- Nouvelles routes : robots.txt, sitemap.xml (pages statiques + adhérents
actifs), manifest.webmanifest, image de partage 1200×630 générée avec le
logo, page 404 dédiée (noindex).
- /backend, /login, /inscription/* et les fiches inactives en noindex.
- Landmarks <main> sur toutes les pages publiques, fil d'Ariane en <nav>.
- serializeJsonLd() échappe <, > et & : les saisies des adhérents ne peuvent
pas fermer le bloc <script>. Tests unitaires dans tests/seo.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDbKbJrPWweXXYSxA8MXW7
Le seed ne crée plus que le référentiel des catégories et le compte
administrateur initial. Les adhérents, promotions, demandes d'adhésion,
entrées du journal et comptes de démonstration sont déplacés dans
src/db/demo-data.ts et ne sont insérés qu'avec SEED_DEMO=true.
Nouveau script `npm run db:purge-demo` (bundlé en dist/purge-demo.cjs pour le
conteneur) : retire exactement ce jeu de démonstration d'une base qui l'a reçu
lors des démarrages précédents, sans toucher aux saisies de l'association ni
aux catégories. Idempotent, en transaction ; un compte réel rattaché à une
fiche de démo est détaché et non supprimé.
Vérifié en local sur Postgres 16 : seed par défaut = 59 catégories + 1 admin
et rien d'autre ; seed avec SEED_DEMO=true = jeu complet ; purge = retour à
l'état vide ; seconde purge sans effet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
Audit de sécurité complet de l'application, puis correctifs du lot A validés :
- Seed : plus aucun compte de démonstration (mot de passe « changeme123 »)
sans SEED_DEMO=true. L'administrateur initial reçoit un mot de passe
aléatoire affiché une fois dans les journaux (ou SEED_ADMIN_PASSWORD) et
doit le changer à la première connexion. docker-compose ne fournit plus de
mot de passe par défaut.
- Mots de passe temporaires : la colonne users.temp_password (en clair) est
supprimée (migration 0011). Création d'adhérent, réinitialisation,
rattrapage des comptes manquants, approbation de demande et invitation
staff renvoient les identifiants, affichés une seule fois par le composant
OneTimeCredentials, sans redirection. L'invitation staff, qui ne
communiquait jamais le mot de passe, redevient utilisable et impose le
changement à la première connexion.
- Sessions JWT limitées à 7 jours (30 auparavant).
- En-têtes : Strict-Transport-Security ajouté, X-Powered-By supprimé.
- docker-compose : port Postgres publié sur 127.0.0.1 uniquement.
- Image Docker sur node:22 (Node 20 en fin de vie).
- Dépendances : next 15.5.25, pg 8.23, nanoid 3.3.18 (avis GHSA-2v37-7h3g-55p8).
Vérifié en local : tests, typage, build de production, migration + seed sur
un Postgres 16, et parcours navigateur complet (première connexion, changement
forcé, création / réinitialisation / invitation avec affichage unique, cookie
de session à 7 jours).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
Trois colonnes dont une très large laissaient le tiers droit vide, les titres ne
se distinguaient pas des liens, et les deux icônes sociales flottaient en bas à
gauche sans rattachement.
- Quatre colonnes : marque, Accès rapide, Informations, Nous suivre. La largeur
est enfin occupée, et la dernière colonne accueille aussi les coordonnées de
l'association dès qu'elles sont renseignées dans Paramètres.
- Titres en capitales espacées sur le brun de la marque : ils reprennent le
vocabulaire des pastilles utilisées partout ailleurs sur le site, et se lisent
désormais comme des titres.
- Icônes sociales remplacées par des boutons libellés, calés à gauche pour ne
pas s'étirer en pavés vides.
- Filet doré en haut du bloc, pastille dorée devant « Bassin de Pompey » : la
page se referme au lieu de s'arrêter sur une ligne grise.
- Liens plus aérés, décalage léger au survol.
- Deux entrées utiles ajoutées : Rencontres passées et Espace adhérent.
- Replis : 3 colonnes sur tablette, 2 sur téléphone avec la marque et les
réseaux sur toute la largeur.
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 bandeau Mission tenait en une phrase et ne disait pas ce que l'adhésion
apporte concrètement.
- Mission réécrite en trois paragraphes : le réseau et les rencontres, la mise
en avant de chaque adhérent auprès des habitants et des professionnels, et
l'objectif double clients de proximité / courants d'affaires locaux.
- Nouveau bloc de quatre cartes sous le bandeau : se rencontrer, gagner en
visibilité (site et réseaux sociaux), attirer des clients en B to C,
développer des affaires en B to B.
- Les deux contenus sont éditables dans Paramètres : la mission accepte des
paragraphes séparés par une ligne vide, les apports suivent la convention
« Titre|Description » déjà utilisée pour le directoire.
- Migration 0009 : le texte de mission n'est remplacé que s'il est resté celui
d'origine, une version réécrite à la main n'est jamais écrasée.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
Connecter un réseau social imposait de poser NEXT_PUBLIC_SITE_URL dans
docker-compose et de redémarrer le conteneur. L'écran Réseaux sociaux porte
désormais le champ, pré-rempli avec l'adresse par laquelle l'administrateur
consulte la page.
- Nouveau réglage `site_public_url` ; `siteUrl()` lit la base puis retombe sur
les variables d'environnement, donc les déploiements actuels ne bougent pas.
- `siteUrl()`, `redirectUri()`, `authorizeUrl()`, `promoLink()` et
`buildPromoMessage()` deviennent asynchrones.
- Seule l'origine est conservée : le schéma est ajouté s'il manque, un chemin
est retiré, et une adresse invalide revient sur l'écran avec un message au
lieu d'une page d'erreur brute.
- `saveSiteSettings` saute cette clé : le formulaire Paramètres ne la contient
pas et l'aurait écrasée à chaque enregistrement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb