Author SHA1 Message Date
Claude 0e340dde58 Boîte mail : accorder le port SMTP et le chiffrement
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
2026-09-15 13:51:51 +00:00
Claude 2da125eca2 Espace adhérent : plus de barre latérale hors staff
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
2026-09-15 06:47:10 +00:00
Claude cc33cf5fbc Informations : éditeur visuel à la place de la syntaxe Markdown
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
2026-09-15 06:37:52 +00:00
Claude 90b121a3c1 Merge remote-tracking branch 'origin/main' into claude/lucid-lovelace-c5is18 2026-09-15 06:13:05 +00:00
Claude b860eeaf29 Référencement : Google Analytics et Search Console réglables en backoffice
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
2026-09-15 05:12:21 +00:00
Claude 7e7d307cdd Type la redirection de la boîte mail en « never »
`redirect` lève : le déclarer supprime les `!` posés après chaque appel
pour rassurer TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-14 16:26:35 +00:00
Claude 39eb8cfc13 Documente le fil d'informations et la messagerie
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
2026-09-14 16:23:32 +00:00
Claude 0c8542f894 Tests : file d'envoi, destinataires, navigation et invariants
`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
2026-09-14 16:22:07 +00:00
Claude 158958a8f4 Le studio d'e-mails et les invitations partent vraiment
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
2026-09-14 16:19:28 +00:00
Claude 877b0ece78 Diffuser une information par e-mail, à la publication
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
2026-09-14 16:15:11 +00:00
Claude a7ac27e530 Les identifiants partent par e-mail, sans jamais passer par la file
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
2026-09-14 16:05:41 +00:00
Claude 8d4443a1a1 Boîte mail de l'association : trois transports, une file d'attente
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
2026-09-14 16:00:11 +00:00
Claude d57e544a18 Fil d'informations dans l'espace adhérent
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
2026-09-14 15:49:42 +00:00
Claude 1701fc2834 Analyseur de texte riche pour les informations adhérents
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
2026-09-14 15:28:37 +00:00
Claude 3df3e55974 Extrait le constructeur MIME dans un module pur
`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
2026-09-14 15:25:22 +00:00
Claude 67cd9d6684 Affiche les coordonnées du référent aux visiteurs connectés
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
2026-09-14 11:31:59 +00:00
Claude fb09bf0e3a Accueil : préciser « entreprises de service »
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
2026-09-14 11:04:53 +00:00
MichaelandClaude Opus 5 7d72016bfb Prochaines dates : cartes de hauteur uniforme, détail en fenêtre
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
2026-09-14 06:01:57 +00:00
MichaelandClaude Opus 5 e59ae3a908 Espace adhérent : taille et format de l'image annoncés au dépôt
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
2026-09-14 05:52:13 +00:00
MichaelandClaude Opus 5 146250f973 Modération : la diffusion réseaux suit la date de publication
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
2026-09-14 05:44:01 +00:00
Claude dd16e05b47 Demandes & messages : boutons d'action selon l'état de la ligne
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
2026-09-13 07:48:59 +00:00
Claude e9b6fdfd91 Annuaire : cartes adhérents de hauteur uniforme
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
2026-09-13 07:25:22 +00:00
Claude 503adcb20e Corrige la page blanche à la création d'un adhérent en doublon
« 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
2026-09-07 07:59:25 +00:00
Claude 50b8732080 Réseaux : bouton de renouvellement du jeton, avec ses deux liens de contrôle
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
2026-09-06 15:33:44 +00:00
Claude 4b12dc5164 Espace adhérent : récapitulatif des dates, aperçu ramené à sa taille réelle
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
2026-09-06 15:10:41 +00:00
Claude 353d38bebf Promotions : la page publique suit le gabarit des pages internes
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
2026-09-06 14:50:41 +00:00
Claude a52e13e623 Promotions : page publique dédiée, l'accueil garde les six dernières
`/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
2026-09-06 14:34:27 +00:00
Claude cf8cf5c8e2 Promotions : le logo revient à gauche du nom de l'adhérent
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
2026-09-06 14:22:03 +00:00
Claude d99ee7ec07 Promotions : logo de l'adhérent dans l'angle des cartes
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
2026-09-06 14:02:53 +00:00
Claude e980ab6e04 Promotions : publication programmée et affichage limité à la période
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
2026-09-06 11:17:49 +00:00
Claude 02c5378a47 Promotions : période de validité (date de début / date de fin)
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
2026-09-06 11:00:44 +00:00
Claude 81b99ed5fb Réseaux : contrôle réel de validité des jetons et outils d'inspection
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
2026-09-06 10:43:11 +00:00
Claude 17511a73c3 Connexion réseaux : adresse de retour déduite de la requête, CSP sans upgrade en HTTP
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
2026-09-06 10:11:06 +00:00
117 changed files with 19312 additions and 487 deletions

No files matched your search

+26
View File
@@ -72,3 +72,29 @@ FACEBOOK_PAGE_ACCESS_TOKEN=
LINKEDIN_ORGANIZATION_URN=
LINKEDIN_ORGANIZATION_ID=
LINKEDIN_ACCESS_TOKEN=
# ---- Boîte mail de l'association ----
# La configuration se fait dans Backend › Boîte mail : on y branche Google
# (API Gmail), Microsoft (API Graph) ou un serveur SMTP, et les secrets sont
# stockés chiffrés. Rien à mettre ici dans le cas normal.
#
# Les jetons et mots de passe utilisent la même clé que les réseaux sociaux
# (SOCIAL_TOKEN_KEY, avec repli sur AUTH_SECRET).
#
# `off` arrête la file d'envoi sans toucher au reste (le libérateur de
# publications programmées a son propre interrupteur, PROMO_SCHEDULER).
#MAIL_WORKER=off
# Débit de la file, en messages par passage (défaut : 20). Gmail plafonne
# autour de 500 destinataires par jour sur un compte gratuit.
#MAIL_RATE_PER_MINUTE=20
# --- Repli avant toute configuration depuis le backoffice ---
# Permet un premier déploiement sans passer par l'écran. Dès qu'un compte est
# enregistré dans Backend › Boîte mail, il prime sur ces variables.
SMTP_HOST=
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_FROM_NAME=
+264 -6
View File
@@ -50,11 +50,68 @@ docker compose up --build # full stack
## Promotions
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`).
Statuts : `pending` → (`scheduled` →) `live` → `suspended` ⇄ `live`
(+ `rejected` / `expired`).
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
suspension par le staff ne peut être levée que par le staff. Les lectures
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du
site sans traitement supplémentaire.
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
programmée — disparaît du site sans traitement supplémentaire.
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
et verrouillé par `tests/promo-validity.test.ts` : `formatValidity()` rend
« Valable du 1er au 15 mars 2027 » / « Valable jusqu'au … » / « Valable à partir
du … », `formatValidityShort()` la variante sans « Valable » pour les lignes de
méta, `isRangeInvalid()` garde le formulaire et l'action. Sans date, rien n'est
affiché ; l'ancien texte libre `valid_until` (jamais écrit par l'application)
ne sert plus que de repli d'affichage. La même phrase part dans le message
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
le backoffice et les réseaux.
La liste publique vit sur **`/promotions`** (toutes les offres en cours) ;
l'accueil n'en montre que les six dernières et renvoie vers elle. Les deux
listes partagent `src/components/PromoCard.tsx` : un seul rendu, pas deux
cartes à maintenir. `getLivePromotions(limit)` accepte `null` pour « tout »,
la limite restant appliquée en SQL. Les liens « Promotions » de l'en-tête, du
pied de page et des publications réseaux pointent sur cette page, plus sur
l'ancre `/#promotions`.
Une promotion n'est **affichée que pendant sa période** : les lectures de
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors
période, le backoffice affiche `visibilityNote()` (« pas encore affichée » /
« période terminée »), et l'offre revient d'elle-même si les dates changent.
**Publication programmée** : `promotions.publish_at` (`timestamptz`, vide =
mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le
modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases
réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée
avec une échéance future passe au statut `scheduled` : invisible du site et
**rien n'est diffusé**. `releaseDuePromotions()` (`src/lib/promo-publish.ts`)
fait la bascule `scheduled → live` par un `UPDATE … RETURNING` filtré sur le
statut — atomique, donc sans double publication même à plusieurs instances —
puis appelle `publishPromoShares()`. Le bouton « Publier maintenant » de la
modération court-circuite l'attente.
`src/lib/promo-schedule.ts` est **pur** (`tests/promo-schedule.test.ts`) :
il convertit la saisie `datetime-local` depuis/vers l'heure de l'association
(`Europe/Paris`, changements d'heure compris) et refuse une saisie illisible
plutôt que de la transformer en publication immédiate.
Le déclencheur est `src/instrumentation.ts` : une boucle d'une minute démarrée
avec le serveur, avec un premier passage au démarrage pour rattraper les
échéances tombées pendant un redéploiement. `PROMO_SCHEDULER=off` la désactive.
Le travail vit dans `src/instrumentation-node.ts` parce que Next compile aussi
`instrumentation.ts` pour le runtime edge du middleware : `next.config.mjs`
l'écarte de ce bundle (`IgnorePlugin`), sans quoi webpack tente d'y embarquer
`pg` et ses dépendances Node.
`src/lib/promo-publish.ts` porte `publishPromoShares()` — et non plus
`backend/actions.ts` — parce qu'il a deux appelants : une action serveur et la
boucle de libération, qui n'a pas de requête et ne peut donc pas appeler
`revalidatePath()`. `src/lib/activity-log.ts` porte `logActivity()` pour la
même raison.
## Réseaux sociaux
@@ -74,24 +131,51 @@ 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.
- La diffusion est déclenchée **par la validation**, pas par un bouton :
- L'image part **telle quelle** : aucun recadrage ni redimensionnement côté
application. Un visuel non carré est donc recadré — ou entouré de bandes de
couleur — par la plateforme. `src/lib/image-info.ts` (pur, verrouillé par
`tests/image-info.test.ts`) décrit le fichier déposé (dimensions, format,
poids) et `MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne
bloque pas.
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
publication programmée), pas par un bouton :
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
ajustables par le modérateur dans le formulaire « Valider », puis figés
(`status !== 'pending'`).
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de
- `publishPromoShares()` dans `src/lib/promo-publish.ts` est le seul point de
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
aussi le cycle suspension → remise en ligne.
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
choisi ; il ne peut pas élargir la diffusion.
- L'URL publique du site : `siteUrl()` lit le réglage `site_public_url` puis
`NEXT_PUBLIC_SITE_URL` / `AUTH_URL` ; `publicBaseUrl()` y ajoute un repli sur
l'origine de la requête (`src/lib/site-url.ts`, en-têtes `X-Forwarded-*`).
L'adresse de retour OAuth et les liens des publications passent par
`publicBaseUrl()` : la connexion marche sans réglage tant que l'admin passe
par l'adresse publique.
- La CSP (`src/middleware.ts`) ne pose `upgrade-insecure-requests` qu'en HTTPS,
sinon un test en HTTP par IP voit toutes ses navigations basculer vers un
`https://` inexistant.
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
## Sécurité
- **Erreurs des server actions** : un `throw` dans une action est masqué par
Next en production (message remplacé par un digest) et fait tomber la page
sur « Application error ». Les échecs **attendus** — e-mail déjà pris, champ
manquant — sont donc **renvoyés** (`ActionError = { error: string }`) et
affichés par le formulaire, qui conserve la saisie. Le `throw` reste réservé
aux violations d'accès, qui n'ont pas à s'expliquer à l'utilisateur.
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
rendu en éléments React (`activityNodes`), jamais en HTML brut.
@@ -118,7 +202,13 @@ site sans traitement supplémentaire.
- Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de
`X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose`
n'est publié que sur `127.0.0.1`.
- `npm test` verrouille ces protections (`tests/security.test.ts`).
- `npm test` verrouille ces protections (`tests/security.test.ts`) : XSS du
journal, limitation des connexions, chiffrement des secrets, coordonnées du
référent, **mots de passe temporaires jamais mis en file**, secrets de
messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne
d'envoi, rien rendu en HTML brut côté informations, et **le HTML de l'éditeur
visuel qui ne quitte jamais la page** (formulaire à champ caché, collage passé
par `DOMParser`, réinjection limitée à `richTextToEditorHtml`).
## Référencement (SEO)
@@ -137,6 +227,24 @@ site sans traitement supplémentaire.
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
- **Mesure d'audience** : `src/lib/analytics.ts` est **pur**
(`tests/analytics.test.ts`). `normalizeMeasurementId()` n'accepte qu'un
identifiant GA4 (`G-…`, ni `UA-…` ni texte libre) : c'est la garde qui interdit
d'injecter une chaîne arbitraire dans le script en ligne. Le réglage
`google_analytics_id` (Paramètres) vide = **aucune balise posée**.
`<Analytics>` (`src/components/Analytics.tsx`) rend les deux `<script>` avec le
nonce de la CSP et n'agit que sur les pages publiques : `isTrackedPath()`
écarte `PRIVATE_PATHS`, le chemin venant de l'en-tête `x-pathname` posé par le
middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes
`*.google-analytics.com` / `*.analytics.google.com` / `*.googletagmanager.com`
en `connect-src` sans condition : elle est posée sur le runtime Edge, qui ne
peut pas interroger la base. `script-src` n'a pas besoin d'eux —
`'strict-dynamic'` couvre les scripts que gtag.js charge ensuite.
- **Search Console** : le réglage `google_site_verification` alimente
`verification.google` dans `app/layout.tsx` (balise `<meta>`).
`normalizeSiteVerification()` refuse la balise `<meta>` complète collée telle
quelle. Le plan du site est déjà servi sur `/sitemap.xml` et annoncé dans
`/robots.txt` : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
@@ -160,10 +268,33 @@ site sans traitement supplémentaire.
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
onglets ; toute action qui touche l'espace revalide les deux chemins.
- **Pas de barre latérale hors staff** : `BackendShell` branche sur
`isStaff(user.role)`. Chez un adhérent la barre ne porterait que la section
« Côté adhérent », c'est-à-dire les trois entrées déjà rendues en onglets par
`EspaceHeader` — deux menus pour la même chose. Il ne reste qu'un bandeau de
marque, aligné sur la colonne de contenu (`max-width` 1040). Conséquence à ne
pas perdre de vue : sous 1024px les actions du compte vivaient **dans le
tiroir** ; sans tiroir, `.backend-header--plain` rétablit leur affichage dans
l'entête, sinon la déconnexion deviendrait inatteignable sur téléphone.
`tests/backend-nav.test.ts` verrouille les deux points. Le staff garde la
barre : elle y est la navigation principale, et « Côté adhérent » son seul
chemin vers son propre espace.
- La catégorie d'une promotion est un **type de produit ou de service**
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
- **Référent** : `members.contact_first_name` / `contact_last_name` /
`contact_phone` portent la personne à joindre et sa ligne directe. Ils sont
**réservés aux visiteurs connectés** : aucune requête publique ne les lit
(`tests/security.test.ts` le verrouille), seules `getMemberContacts()` /
`getMemberContact()` les rapatrient, et uniquement après un `getSession()`
positif. L'annuaire, les pages métier et la fiche affichent alors un bloc
« Contact adhérent » ; hors session la requête n'est pas faite, donc rien
n'est masqué en CSS, rien ne part dans le HTML ni dans le JSON-LD.
`src/lib/member-contact.ts` est **pur** (`tests/member-contact.test.ts`) :
`memberContact()` compose « Prénom Nom » et renvoie `null` s'il n'y a rien à
montrer, `telHref()` fabrique le lien `tel:`. Les deux formulaires de fiche
(espace adhérent et écran staff) partagent `MemberContactFields`.
- `members.email` est l'e-mail **administratif** (identifiant de connexion à la
création, échanges avec l'association) ; `members.contact_email` est l'e-mail
**public** de la fiche, saisi par l'adhérent ou le staff. La fiche et le
@@ -178,6 +309,133 @@ site sans traitement supplémentaire.
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
`members` row via `users.memberId`. Capability matrix is in `src/lib/rbac.ts`.
`manageInformations` (admin + modérateur) ouvre la rédaction des informations ;
écrire à tous les adhérents reste sous `manageEmails` (admin seul), et la
configuration de la boîte mail sous `manageSettings`. Un modérateur publie donc
une information sans pouvoir la diffuser.
## Informations adhérents
Le fil de l'espace adhérent (`/backend/espace/informations`, onglet en tête)
porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
`informations` (brouillon → publiée, `pinned`, `email_sent_at`) et
`information_reads`.
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
accepté en lecture, et un tiret bas au milieu d'un mot
(`fichier_de_sauvegarde`) n'ouvre rien.
- **La syntaxe ne se montre jamais** : `RichTextEditor` est un
`contentEditable` avec une barre d'outils (G, I, Sous-titre, listes, lien).
L'adhérent qui rédige voit du gras, pas des étoiles. Le balisage voyage dans
un `<input type="hidden">`.
- **Le WYSIWYG porte sur la saisie, pas sur le stockage.** C'est l'invariant à
ne pas casser : à chaque frappe, `src/lib/rich-text-dom.ts`
(`serializeToRichText`, **pur**, `tests/rich-text-dom.test.ts`) retraverse le
contenu édité et n'en garde que le gras, l'italique, les listes, les
sous-titres et les liens. Le HTML de l'éditeur ne quitte jamais la page : la
base ne reçoit que le format balisé, que le serveur ré-analyse avec le même
analyseur qu'avant. Aucun assainisseur HTML n'est donc nécessaire.
Le module lit aussi le gras/italique **codés en style CSS**
(`<span style="font-weight:700">` de Google Docs), le style explicite
l'emportant sur la balise — sans quoi le `<b style="font-weight:normal">` dont
Docs enveloppe tout document mettrait le texte entier en gras.
- **Un collage est analysé par `DOMParser`**, jamais par une affectation
d'`innerHTML` : le document produit est inerte, donc un `<img onerror>` collé
ne s'exécute pas. Ce qui est réinjecté dans l'éditeur est du HTML **produit
par nous** (`richTextToEditorHtml`), jamais celui du presse-papier.
- **Les sauts de ligne sont normalisés à l'enregistrement** : l'encodage des
formulaires HTML convertit tout `\n` en `\r\n`, et le balisage stocké
différerait sinon de celui que l'éditeur a produit (`saveInformation`).
- **Une seule image**, en couverture, jamais dans le corps : une data-URI
recopiée dans chaque ligne de la file pèserait 3 Mo par destinataire. Elle est
servie aux clients mail par `/api/informations/[id]/image`, qui ne répond que
pour une information publiée — Gmail et Outlook suppriment les
`<img src="data:">`.
- **Non-lues** : une ligne par lecture (`information_reads`) plutôt qu'une date
« vu jusqu'ici ». 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
l'action `markInformationsRead`, appelée **après** affichage par
`MarkInformationsRead` : 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 contrediraient sur la même page.
- **Une seule épinglée** : appliqué dans l'action (`unpinOthers`), pas par un
index conditionnel que drizzle-kit ne génère pas.
- Les trois pages de l'espace passent `infoBadge` : sans cela le compteur
disparaîtrait en changeant d'onglet.
## Envoi d'e-mails
`/backend/boite-mail` est le décalque de `/backend/reseaux` : mêmes règles,
mêmes garanties. Trois transports dans `mail_accounts`, un seul `is_active` —
pas de cascade automatique, la bascule est un choix visible.
- **Google → API Gmail** (`gmail.send`) et non SMTP+XOAUTH2 : celui-ci exigerait
`https://mail.google.com/`, portée *restreinte* donc audit de sécurité.
**Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP
est désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
**SMTP générique** via `nodemailer`, seule dépendance d'envoi.
- **Port et chiffrement SMTP sont un seul choix.** `src/lib/smtp-config.ts` est
**pur** (`tests/smtp-config.test.ts`) : `impliedSecure()` dit que seul le port
465 chiffre dès l'ouverture, les autres (587, 25, 2525) passent par STARTTLS,
et `tlsMismatch()` prévient quand la case et le port se contredisent. Le
formulaire (`SmtpAccountForm`, client) réaligne la case sur le port choisi :
un TLS tenté sur le 587 échoue sur la bannière en clair (« wrong version
number »), un dialogue en clair sur le 465 attend une réponse qui ne vient
jamais. `describeSmtpError()` traduit ces échecs avant affichage —
l'explication d'abord, la trace ensuite, parce que l'écran tronque.
- Le transport pose `requireTLS` hors TLS implicite : sans lui, STARTTLS est
*opportuniste* et l'identifiant partirait en clair chez un serveur qui ne
l'annonce pas. Les délais (`connectionTimeout`, `greetingTimeout`) sont
ramenés à quelques secondes : ces connexions s'ouvrent depuis une action
serveur, et un port filtré en sortie — cas courant chez les hébergeurs — ne
doit pas bloquer l'écran deux minutes.
- `mail_accounts.from_address` est **lu chez le fournisseur** au retour OAuth,
jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la
boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM.
- Microsoft fait **tourner** le jeton de rafraîchissement à chaque
renouvellement : `ensureAccessToken` réenregistre celui qui revient, sinon
l'envoi meurt au bout d'une heure.
- `src/lib/mime.ts` est **pur** (`tests/mime.test.ts`) : extrait de
`downloadOutlookDraft`, il sert le brouillon `.eml` **et** le champ `raw` de
l'API Gmail. CRLF stricts, mots encodés RFC 2047 repliés sans couper une
séquence UTF-8, CR/LF neutralisés dans les valeurs d'en-tête — un retour à la
ligne dans un objet permettrait sinon d'injecter un `Bcc:`.
- `src/lib/mailer.ts` porte `sendNow()`, qui **ne lève jamais** : un envoi raté
est un verdict, pas une exception.
- `src/lib/mail-outbox.ts` porte la file. Réclamation en
`UPDATE … FOR UPDATE SKIP LOCKED` — `releaseDuePromotions` s'en passe parce
que la transition de statut y fait office de verrou, pas ici. Faucheur des
verrous laissés par un conteneur arrêté en plein envoi, donc livraison **au
moins une fois**. ⚠ `db.execute` rend les colonnes **brutes** : il faut
reconvertir `to_address` en `toAddress`, le constructeur de requêtes le fait,
pas lui.
- **Les mots de passe temporaires ne passent jamais par la file** :
`mail_messages.html` est stocké en base. Les cinq actions à identifiants
appellent `sendNow` en ligne directe et journalisent une trace sans contenu
(`logSentMail`). `tests/security.test.ts` le verrouille. L'envoi est un plus,
jamais un point de rupture : `OneTimeCredentials` affiche le mot de passe quoi
qu'il arrive.
- **Une ligne `mail_messages` par destinataire** : `to_address` est un `varchar`
unique, la confidentialité d'une diffusion est structurelle.
- La configuration mail ne passe **surtout pas** par `site_settings` :
`saveSiteSettings` boucle sur toutes les clés de `SITE_SETTING_DEFAULTS` et
écrase d'une chaîne vide celles qu'aucun champ ne porte.
- La boucle d'envoi vit dans `src/instrumentation-node.ts`, à côté du libérateur
de promotions, et `next.config.mjs` déclare `serverExternalPackages:
["nodemailer"]`. **Chaque boucle a son propre interrupteur** (`MAIL_WORKER`,
`PROMO_SCHEDULER`) : couper l'un dans `instrumentation.ts` couperait l'autre,
puisque le module entier ne serait plus chargé.
- `emailBrand(settings)` (`src/lib/site-settings.ts`) compose l'identité en pied
de tous les messages : un seul endroit à changer.
## Docker notes
- Migrations + optional seed run on container start via `docker-entrypoint.sh`.
+52 -9
View File
@@ -87,12 +87,12 @@ vivent dans le volume `caddy_data` et se renouvellent seuls. Une fois le DNS
posé, vérifier la délivrance avec
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive
> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le
> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS,
> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis
> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la
> directive ne s'y applique pas.
> **Vérification par l'IP** : la directive `upgrade-insecure-requests` de la
> CSP (`src/middleware.ts`) n'est posée que sur les pages servies en HTTPS. Un
> test en HTTP par l'IP ou par le port direct fonctionne donc sans tunnel. En
> revanche, Facebook et LinkedIn n'acceptent qu'une adresse de retour OAuth en
> HTTPS sur un nom de domaine : la connexion des réseaux sociaux ne peut être
> validée qu'une fois le DNS posé.
Mise à jour : `git pull && docker compose up -d --build`.
@@ -114,8 +114,10 @@ Mise à jour : `git pull && docker compose up -d --build`.
| Adhérents (CRUD) | ✅ | ✅ | ✅ | — |
| Modération des promotions | ✅ | ✅ | — | — |
| Publication réseaux sociaux | ✅ | ✅ | — | — |
| Informations adhérents | ✅ | ✅ | — | — |
| E-mails & boîte mail | ✅ | — | — | — |
| Administrateurs | ✅ | — | — | — |
| Mon espace (publier une promo) | — | — | — | ✅ |
| Mon espace (promos, informations) | — | — | — | ✅ |
`/backend` est protégé par le middleware ; chaque vue affine l'accès selon le rôle.
@@ -217,7 +219,7 @@ npm run dev # http://localhost:3000
| `npm run db:generate` | Génère les migrations Drizzle |
| `npm run db:migrate` | Applique les migrations |
| `npm run db:seed` | Insère les données de démonstration |
| `npm test` | Tests de sécurité (filtre XSS, limitation de connexion, chiffrement) |
| `npm test` | Tests unitaires et de sécurité (XSS, chiffrement, MIME, file d'envoi) |
## Variables d'environnement
@@ -230,10 +232,51 @@ Voir [`.env.example`](./.env.example). Les principales :
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin
- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise.
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
- `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux **et de la boîte
mail** (défaut : `AUTH_SECRET`)
- `MAIL_WORKER=off` — arrête la file d'envoi ; `PROMO_SCHEDULER=off` arrête le
libérateur de publications programmées. Les deux sont indépendants.
- `MAIL_RATE_PER_MINUTE` — débit de la file d'envoi (défaut : 20)
- `SMTP_HOST` / `SMTP_USER` / `SMTP_PASSWORD` … — repli avant toute
configuration depuis **Backend › Boîte mail**
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
## Informations aux adhérents
L'association publie ses annonces dans **Backend › Informations** : titre, texte
avec une mise en forme simple (`**gras**`, `*italique*`, listes, liens), image de
couverture, brouillon puis publication, et une seule information épinglée en tête.
Les adhérents les retrouvent sous l'onglet **Informations** de leur espace, avec
une pastille « Nouveau » et un compteur de non-lues qui retombe à la lecture.
À la publication, une case permet d'**envoyer aussi le message par e-mail** : un
message par adhérent, jamais de copie partagée.
## Envoi d'e-mails
Le site expédie depuis la boîte de l'association, branchée dans
**Backend › Boîte mail**. Trois transports, un seul actif à la fois :
| Transport | Chemin | À savoir |
|---|---|---|
| Google | API Gmail (`gmail.send`) | Une application laissée en mode « Test » voit son autorisation expirer au bout de 7 jours : passez-la « En production », ou déclarez-la « Interne » avec un compte Workspace. |
| Microsoft | API Graph (`Mail.Send`) | Fonctionne pour `@outlook.com` et `@hotmail.com` comme pour une boîte 365 ; l'authentification SMTP par mot de passe y est désactivée depuis 2024. |
| Autre serveur | SMTP (`nodemailer`) | Couvre aussi un mot de passe d'application Gmail, Outlook professionnel, OVH, Ionos. Marche immédiatement, sans démarche préalable. |
Pour Google et Microsoft, l'adresse d'expédition est **lue chez le fournisseur**
et non saisie : expédier depuis un autre domaine ferait échouer SPF et DKIM.
Ce qui part du site : mots de passe temporaires (création, réinitialisation,
invitation), invitations aux rencontres, diffusion d'une information, et les
envois du studio de composition. Les diffusions passent par une file d'attente
vidée en tâche de fond ; les mots de passe partent en direct, sans jamais être
écrits en base.
Sans boîte configurée, rien ne casse : les mots de passe temporaires restent
affichés une fois à l'écran, comme auparavant.
## Note sur le logo
Le logo (`public/assets/logo.svg`) est une recréation vectorielle aux couleurs de
+3
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "promotions" ADD COLUMN "starts_on" date;--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "ends_on" date;
+2
View File
@@ -0,0 +1,2 @@
ALTER TYPE "public"."promo_status" ADD VALUE 'scheduled';--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "publish_at" timestamp with time zone;
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "members" ADD COLUMN "contact_first_name" varchar(120);--> statement-breakpoint
ALTER TABLE "members" ADD COLUMN "contact_last_name" varchar(120);--> statement-breakpoint
ALTER TABLE "members" ADD COLUMN "contact_phone" varchar(40);
+27
View File
@@ -0,0 +1,27 @@
CREATE TYPE "public"."info_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TABLE "information_reads" (
"id" serial PRIMARY KEY NOT NULL,
"information_id" integer NOT NULL,
"user_id" integer NOT NULL,
"read_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "informations" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar(200) NOT NULL,
"body" text NOT NULL,
"image_url" text,
"status" "info_status" DEFAULT 'draft' NOT NULL,
"pinned" boolean DEFAULT false NOT NULL,
"published_at" timestamp with time zone,
"author_id" integer,
"email_sent_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "informations" ADD CONSTRAINT "informations_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "information_reads_user_info_idx" ON "information_reads" USING btree ("user_id","information_id");--> statement-breakpoint
CREATE INDEX "informations_feed_idx" ON "informations" USING btree ("status","published_at");
+58
View File
@@ -0,0 +1,58 @@
CREATE TYPE "public"."mail_kind" AS ENUM('credentials', 'information', 'meeting', 'studio', 'test');--> statement-breakpoint
CREATE TYPE "public"."mail_provider" AS ENUM('google', 'microsoft', 'smtp');--> statement-breakpoint
CREATE TYPE "public"."mail_status" AS ENUM('queued', 'sending', 'sent', 'failed', 'cancelled');--> statement-breakpoint
CREATE TABLE "mail_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"provider" "mail_provider" NOT NULL,
"from_address" varchar(200),
"from_name" varchar(200),
"is_active" boolean DEFAULT false NOT NULL,
"app_id" varchar(200),
"app_secret" text,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"smtp_host" varchar(200),
"smtp_port" integer,
"smtp_secure" boolean DEFAULT true NOT NULL,
"smtp_user" varchar(200),
"smtp_password" text,
"connected_by_id" integer,
"connected_at" timestamp with time zone,
"last_check_at" timestamp with time zone,
"last_check_ok" boolean,
"last_check_error" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mail_accounts_provider_unique" UNIQUE("provider")
);
--> statement-breakpoint
CREATE TABLE "mail_messages" (
"id" serial PRIMARY KEY NOT NULL,
"kind" "mail_kind" NOT NULL,
"status" "mail_status" DEFAULT 'queued' NOT NULL,
"to_address" varchar(200) NOT NULL,
"to_name" varchar(200),
"subject" varchar(300) NOT NULL,
"html" text NOT NULL,
"text" text,
"reply_to" varchar(200),
"information_id" integer,
"meeting_id" integer,
"member_id" integer,
"created_by_id" integer,
"provider" "mail_provider",
"attempts" integer DEFAULT 0 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"locked_at" timestamp with time zone,
"sent_at" timestamp with time zone,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mail_accounts" ADD CONSTRAINT "mail_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mail_messages_due_idx" ON "mail_messages" USING btree ("status","next_attempt_at");--> statement-breakpoint
CREATE INDEX "mail_messages_information_idx" ON "mail_messages" USING btree ("information_id");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+42
View File
@@ -99,6 +99,48 @@
"when": 1788680191881,
"tag": "0013_contact_email",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1788691242685,
"tag": "0014_lean_marvel_zombies",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1788691741621,
"tag": "0015_curious_winter_soldier",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788692576315,
"tag": "0016_tiresome_korg",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789385157231,
"tag": "0017_regular_devos",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1789399747132,
"tag": "0018_lazy_big_bertha",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789401051538,
"tag": "0019_nostalgic_warstar",
"breakpoints": true
}
]
}
+17
View File
@@ -4,10 +4,27 @@ const nextConfig = {
reactStrictMode: true,
// Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile.
poweredByHeader: false,
// `nodemailer` charge net/tls/dns par des requires dynamiques : laissé au
// bundler, il se retrouve à moitié inliné et casse à l'exécution.
serverExternalPackages: ["nodemailer"],
experimental: {
// Les images d'entête/logo sont envoyées en data-URL via server action.
serverActions: { bodySizeLimit: "4mb" },
},
// `instrumentation.ts` est compilé pour les deux runtimes dès qu'un
// middleware existe. Son chargement du libérateur de publications est déjà
// conditionné à `NEXT_RUNTIME === "nodejs"`, mais webpack suit quand même
// l'import et tente d'embarquer `pg` (donc `fs`, `path`, `stream`) dans le
// bundle edge, où ces modules n'existent pas. On coupe la branche à la
// source : côté edge, le module Node n'est tout simplement pas résolu.
webpack: (config, { nextRuntime, webpack }) => {
if (nextRuntime === "edge") {
config.plugins.push(
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/instrumentation-node$/ })
);
}
return config;
},
async headers() {
return [
{
+21
View File
@@ -13,6 +13,7 @@
"drizzle-orm": "^0.45.2",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -21,6 +22,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
@@ -1220,6 +1222,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1711,6 +1723,15 @@
}
}
},
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
+2
View File
@@ -20,6 +20,7 @@
"drizzle-orm": "^0.45.2",
"next": "^15.5.25",
"next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"pg": "^8.23.0",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -28,6 +29,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.10",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
+37 -4
View File
@@ -7,7 +7,9 @@ import { SiteFooter } from "@/components/SiteFooter";
import { VitrineImage } from "@/components/VitrineImage";
import { PromoImage } from "@/components/PromoImage";
import { JsonLd } from "@/components/JsonLd";
import { getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getMemberContact, getMemberLivePromotions, getPublicMember } from "@/lib/queries";
import { getSession } from "@/lib/session";
import { telHref } from "@/lib/member-contact";
import {
NOINDEX,
breadcrumbJsonLd,
@@ -26,6 +28,7 @@ import {
parseMemberHours,
websiteLabel,
} from "@/lib/member-profile";
import { formatValidity } from "@/lib/promo-validity";
export const dynamic = "force-dynamic";
@@ -143,7 +146,16 @@ export default async function FicheAdherentPage({
const canonical = memberPath(member);
if (`/adherents/${id}` !== canonical) permanentRedirect(canonical);
const [promos, baseUrl] = await Promise.all([getMemberLivePromotions(memberId), publicBaseUrl()]);
const [promos, baseUrl, session] = await Promise.all([
getMemberLivePromotions(memberId),
publicBaseUrl(),
getSession(),
]);
// Nom, prénom et ligne directe du référent : réservés aux visiteurs
// connectés. La requête n'est pas faite pour un visiteur anonyme, et ces
// champs restent hors du JSON-LD et des métadonnées de la fiche.
const contact = session?.user ? await getMemberContact(memberId) : null;
const contactTel = telHref(contact?.phone);
const accent = member.accent ?? "#E0A63C";
const fullAddress = [member.address, [member.postalCode, member.city].filter(Boolean).join(" ")]
@@ -383,6 +395,27 @@ export default async function FicheAdherentPage({
<div className="practical-empty">Coordonnées non communiquées.</div>
)}
</div>
{contact && (
<div className="contact-referent">
<div className="contact-referent__label">
<span style={{ width: 7, height: 7, borderRadius: "50%", background: accent }} />
Contact adhérent
</div>
{contact.name && <div className="contact-referent__name">{contact.name}</div>}
{contact.phone &&
(contactTel ? (
<a className="contact-referent__phone" href={contactTel}>
{contact.phone}
</a>
) : (
<div className="contact-referent__phone">{contact.phone}</div>
))}
<div className="contact-referent__hint">
Visible uniquement parce que vous êtes connecté : ces coordonnées ne sont pas
publiées sur le site.
</div>
</div>
)}
</section>
</div>
</section>
@@ -437,8 +470,8 @@ export default async function FicheAdherentPage({
<div style={{ padding: "14px 15px 15px" }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 5px", color: "#26201a" }}>{p.title}</h3>
<p style={{ margin: "0 0 11px", fontSize: 13, color: "#8c8068", lineHeight: 1.5 }}>{p.text}</p>
{p.validUntil && (
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{p.validUntil}</div>
{formatValidity(p) && (
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 10, fontSize: 12, color: "#a99c82" }}>{formatValidity(p)}</div>
)}
</div>
</article>
+4 -1
View File
@@ -2,10 +2,13 @@
import { useMemo, useState } from "react";
import { MemberCard, type MemberCardData } from "@/components/MemberCard";
import type { MemberContact } from "@/lib/member-contact";
export type DirectoryMember = MemberCardData & {
hasPromo: boolean;
promoBadge: string | null;
/** Référent de l'adhérent, `null` tant que le visiteur n'est pas connecté. */
contact: MemberContact | null;
};
export function AnnuaireClient({
@@ -176,7 +179,7 @@ export function AnnuaireClient({
{list.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{list.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
<MemberCard key={m.id} m={m} index={i} contact={m.contact} />
))}
</div>
) : (
+8 -2
View File
@@ -13,7 +13,9 @@ import {
getCategoriesInUse,
getCategoryBySlug,
getLiveBadgesByMember,
getMemberContacts,
} from "@/lib/queries";
import { getSession } from "@/lib/session";
import {
NOINDEX,
breadcrumbJsonLd,
@@ -77,12 +79,16 @@ export default async function CategoriePage({ params }: { params: Params }) {
const category = await getCategoryBySlug(categorie);
if (!category) notFound();
const [rawMembers, badges, categoriesInUse, baseUrl] = await Promise.all([
const [rawMembers, badges, categoriesInUse, baseUrl, session] = await Promise.all([
getActiveMembersByCategory(category.id),
getLiveBadgesByMember(),
getCategoriesInUse(),
publicBaseUrl(),
getSession(),
]);
// Même règle que sur l'annuaire : le référent n'est lu que pour un visiteur
// connecté, et reste hors du JSON-LD (construit sur `members`).
const contacts = session?.user ? await getMemberContacts() : null;
const badgeByMember = new Map<number, string | null>();
for (const b of badges) {
if (b.memberId != null && !badgeByMember.has(b.memberId)) badgeByMember.set(b.memberId, b.badge);
@@ -148,7 +154,7 @@ export default async function CategoriePage({ params }: { params: Params }) {
{members.length > 0 ? (
<div className="grid grid-3" style={{ gap: 20 }}>
{members.map((m, i) => (
<MemberCard key={m.id} m={m} index={i} />
<MemberCard key={m.id} m={m} index={i} contact={contacts?.get(m.id) ?? null} />
))}
</div>
) : (
+17 -1
View File
@@ -8,7 +8,9 @@ import {
getAllCategories,
getCategoriesInUse,
getLiveBadgesByMember,
getMemberContacts,
} from "@/lib/queries";
import { getSession } from "@/lib/session";
import { CategoryLinks } from "@/components/CategoryLinks";
import { JsonLd } from "@/components/JsonLd";
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
@@ -32,14 +34,21 @@ export default async function AnnuairePage({
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const [rawMembers, categories, badges, baseUrl, categoriesInUse] = await Promise.all([
const [rawMembers, categories, badges, baseUrl, categoriesInUse, session] = await Promise.all([
getActiveMembersWithCategory(),
getAllCategories(),
getLiveBadgesByMember(),
publicBaseUrl(),
getCategoriesInUse(),
getSession(),
]);
// Coordonnées du référent : réservées aux visiteurs connectés. Pour un
// visiteur anonyme la requête n'est pas faite du tout, donc rien ne part
// dans le HTML ni vers le composant client.
const signedIn = Boolean(session?.user);
const contacts = signedIn ? await getMemberContacts() : null;
const badgeByMember = new Map<number, string | null>();
for (const b of badges) {
if (b.memberId != null && !badgeByMember.has(b.memberId)) {
@@ -51,6 +60,7 @@ export default async function AnnuairePage({
...m,
hasPromo: badgeByMember.has(m.id),
promoBadge: badgeByMember.get(m.id) ?? null,
contact: contacts?.get(m.id) ?? null,
}));
return (
@@ -97,6 +107,12 @@ export default async function AnnuairePage({
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
Trouvez un professionnel du Bassin de Pompey, découvrez sa fiche et ses bons plans.
</p>
{signedIn && (
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#9a8d72", maxWidth: 560 }}>
Vous êtes connecté : les coordonnées du référent de chaque adhérent (nom, prénom,
téléphone) sont affichées sur les fiches ci-dessous.
</p>
)}
</section>
<AnnuaireClient members={members} categories={categories} initialQuery={q ?? ""} />
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getInformation } from "@/lib/informations";
export const dynamic = "force-dynamic";
/**
* Sert l'image de couverture d'une information **publiée**, en octets.
*
* Elle est stockée en data-URI, ce qui convient au site mais pas aux e-mails :
* Gmail et Outlook suppriment les `<img src="data:">`. Cette route donne au
* gabarit une URL ordinaire, et évite au passage de recopier plusieurs
* méga-octets dans chaque ligne de la file d'attente.
*
* Seules les informations publiées répondent : un brouillon ne doit pas fuiter
* par une URL devinable.
*/
const DATA_URI = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: raw } = await params;
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return new NextResponse("Introuvable", { status: 404 });
const information = await getInformation(id);
if (!information || information.status !== "published" || !information.imageUrl) {
return new NextResponse("Introuvable", { status: 404 });
}
const match = DATA_URI.exec(information.imageUrl);
if (!match) return new NextResponse("Introuvable", { status: 404 });
const bytes = Buffer.from(match[2].replace(/\s+/g, ""), "base64");
return new NextResponse(bytes, {
headers: {
"Content-Type": match[1],
"Content-Length": String(bytes.length),
// L'image d'une information ne change pas : les clients mail peuvent la
// garder, et un message relu six mois plus tard l'affiche encore.
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
},
});
}
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
exchangeMailCode,
getActiveMailAccount,
getDecryptedAppSecret,
getMailAccount,
saveMailConnection,
setActiveMailProvider,
MAIL_PROVIDERS,
} from "@/lib/mail-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
import type { MailProvider } from "@/db/schema";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/boite-mail";
function back(request: Request, params: Record<string, string>) {
const url = new URL(SETTINGS, request.url);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const response = NextResponse.redirect(url);
// Le state a fait son office, quel que soit le résultat.
for (const provider of MAIL_PROVIDERS) response.cookies.delete(`plr_mail_oauth_${provider}`);
return response;
}
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { provider: raw } = await params;
const provider = raw as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
return back(request, { error: "Fournisseur inconnu." });
}
const url = new URL(request.url);
const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (failure) return back(request, { error: `Autorisation refusée : ${failure}` });
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const expected = request.headers
.get("cookie")
?.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`plr_mail_oauth_${provider}=`))
?.split("=")[1];
if (!code || !state || !expected || state !== expected) {
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, { error: "Identifiants d'application introuvables." });
}
const base = await publicBaseUrl();
if (!base) return back(request, { error: "Adresse publique du site introuvable." });
try {
const result = await exchangeMailCode(provider, account.appId, appSecret, code, base);
const userId = Number(session?.user.id);
await saveMailConnection({
provider,
...result,
connectedById: Number.isFinite(userId) ? userId : null,
});
// Première boîte connectée : elle devient l'expéditeur, sinon l'écran
// afficherait une connexion réussie sans qu'aucun message ne parte. Si un
// autre fournisseur expédie déjà, on ne le détrône pas dans son dos.
if (!(await getActiveMailAccount())) await setActiveMailProvider(provider);
return back(request, { connected: provider });
} catch (caught) {
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
return back(request, { error: message.slice(0, 400) });
}
}
@@ -0,0 +1,62 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
getDecryptedAppSecret,
getMailAccount,
mailAuthorizeUrl,
MAIL_PROVIDERS,
} from "@/lib/mail-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
import type { MailProvider } from "@/db/schema";
export const dynamic = "force-dynamic";
const SETTINGS = "/backend/boite-mail";
function back(request: Request, error: string) {
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
}
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
const { provider: raw } = await params;
const provider = raw as MailProvider;
if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") {
return back(request, "Fournisseur inconnu.");
}
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
// arrivé : c'est celle où le fournisseur nous renverra.
const base = await publicBaseUrl();
if (!base) {
return back(
request,
"Impossible de déterminer l'adresse publique du site : renseignez-la dans Backend › Réseaux sociaux."
);
}
const account = await getMailAccount(provider);
const appSecret = await getDecryptedAppSecret(provider);
if (!account?.appId || !appSecret) {
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
}
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée. Aucun jeton ne transite jamais par une URL.
const state = randomBytes(24).toString("hex");
const response = NextResponse.redirect(await mailAuthorizeUrl(provider, account.appId, state, base));
response.cookies.set(`plr_mail_oauth_${provider}`, state, {
httpOnly: true,
sameSite: "lax",
secure: new URL(base).protocol === "https:",
path: "/",
maxAge: 600,
});
return response;
}
@@ -6,7 +6,7 @@ import {
authorizeUrl,
getDecryptedAppSecret,
getSocialAccount,
siteUrl,
publicBaseUrl,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social-accounts";
@@ -34,11 +34,13 @@ export async function GET(
return back(request, "Réseau inconnu.");
}
const base = await siteUrl();
// Réglage enregistré, sinon l'adresse par laquelle l'administrateur est
// arrivé : c'est celle où le réseau nous renverra.
const base = await publicBaseUrl();
if (!base) {
return back(
request,
"Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour."
"Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »."
);
}
+26 -34
View File
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { JsonLd } from "@/components/JsonLd";
import { MeetingCard } from "@/components/MeetingCard";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
@@ -232,40 +233,31 @@ export default async function AssociationPage() {
const remaining = Math.max(0, meeting.capacity - registered);
const isRegistered = myMeetingIds.has(meeting.id);
return (
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
{isRegistered && (
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
✓ Inscrit
</span>
)}
</div>
<div style={{ padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
</div>
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
{meeting.title}
</h3>
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
{meeting.description}
</p>
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
<span>{registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s)</span>
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
</div>
<Link
href={`/inscription/${meeting.id}`}
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
>
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
</Link>
</div>
</article>
<MeetingCard
key={meeting.id}
meeting={{
id: meeting.id,
title: meeting.title,
description: meeting.description,
location: meeting.location,
imageUrl: meeting.imageUrl,
// Formatées ici : côté client, le fuseau du visiteur
// donnerait une autre heure et casserait l'hydratation.
dateLabel: formatDate(meeting.startsAt),
timeLabel: formatTime(meeting.startsAt),
capacity: meeting.capacity,
registered,
participantsPerAccount: meeting.participantsPerAccount,
isRegistered,
ctaLabel: isRegistered
? "Modifier mon inscription"
: remaining <= 0
? "Complet"
: memberId
? "S'inscrire"
: "Connexion et inscription",
}}
/>
);
})}
</div>
+48 -3
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
import { can, ROLE_LABELS } from "@/lib/rbac";
import { can, isStaff, ROLE_LABELS } from "@/lib/rbac";
import type { AppRole } from "@/types/next-auth";
import { doSignOut } from "./actions";
@@ -19,18 +19,21 @@ const TITLES: Record<string, [string, string]> = {
"/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"],
"/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"],
"/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"],
"/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"],
"/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"],
"/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"],
"/backend/boite-mail": ["Boîte mail", "Brancher la boîte de l'association pour les envois du site"],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"],
"/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"],
"/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"],
"/backend/espace/informations": ["Informations", "Les annonces de l'association"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
};
type Capability = Parameters<typeof can>[1];
type BadgeKey = "inbox" | "promos";
type BadgeKey = "inbox" | "promos" | "infos";
type NavItem = {
href: string;
@@ -72,6 +75,7 @@ const SECTIONS: NavSection[] = [
{
label: "Communication",
items: [
{ href: "/backend/informations", label: "Informations", icon: "news", capability: "manageInformations" },
{ href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" },
{ href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" },
],
@@ -80,6 +84,7 @@ const SECTIONS: NavSection[] = [
label: "Configuration",
items: [
{ href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" },
{ href: "/backend/boite-mail", label: "Boîte mail", icon: "mailbox", capability: "manageSettings" },
{ href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" },
{ href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" },
],
@@ -87,6 +92,7 @@ const SECTIONS: NavSection[] = [
{
label: "Côté adhérent",
items: [
{ href: "/backend/espace/informations", label: "Informations", icon: "news", badge: "infos" },
{ href: "/backend/espace", label: "Mon profil", icon: "space" },
{ href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" },
],
@@ -124,11 +130,13 @@ export function BackendShell({
user,
pendingCount,
inboxCount = 0,
infoCount = 0,
children,
}: {
user: { name: string; role: AppRole };
pendingCount: number;
inboxCount?: number;
infoCount?: number;
children: ReactNode;
}) {
const pathname = usePathname();
@@ -164,7 +172,7 @@ export function BackendShell({
return () => window.removeEventListener("keydown", onKey);
}, [menuOpen]);
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount };
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount, infos: infoCount };
const sections = SECTIONS.map((section) => ({
...section,
@@ -184,6 +192,43 @@ export function BackendShell({
</form>
);
/**
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
* « Côté adhérent », c'est-à-dire les trois entrées déjà présentes en onglets
* sous le bandeau de l'espace. Deux menus pour la même chose, dont un qui
* mange un quart de l'écran. Il ne reste donc qu'un bandeau de marque, et le
* contenu prend toute la largeur — recentré pour ne pas s'étirer.
*
* Le staff la garde : chez lui elle est la navigation principale, et
* « Côté adhérent » y est le seul chemin vers son propre espace.
*/
if (!isStaff(user.role)) {
return (
<div className="backend backend--plain" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<header className="backend-header backend-header--plain">
<Link href="/backend/espace/informations" className="backend-brand">
<span className="backend-brand__mark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/assets/logo.png" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
</span>
<span className="backend-brand__text">
<span className="font-display backend-brand__name">Plein R</span>
<span className="backend-brand__sub">Espace adhérent</span>
</span>
</Link>
<div className="backend-header__actions">
{siteLink}
{signOut}
</div>
</header>
{/* Le bandeau de l'espace porte déjà le nom et les onglets : répéter
ici le titre de la page ferait un troisième étage d'en-tête. */}
<div className="backend-content backend-content--plain">{children}</div>
</div>
);
}
return (
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
{menuOpen && (
+729 -121
View File
File diff suppressed because it is too large. Load diff
+25 -5
View File
@@ -14,6 +14,11 @@ export function AddMemberPanel({
}) {
const [open, setOpen] = useState(false);
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [error, setError] = useState<string | null>(null);
// React vide un formulaire non contrôlé après chaque action : sur un refus,
// on réinjecte la saisie plutôt que de la faire retaper.
const [draft, setDraft] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const router = useRouter();
return (
@@ -38,8 +43,18 @@ export function AddMemberPanel({
{open && (
<form
key={attempt}
action={async (fd) => {
setError(null);
const result = await addMember(fd);
// Échec de saisie : le formulaire reste ouvert avec la raison.
if (result && "error" in result) {
setDraft(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
setAttempt((n) => n + 1);
setError(result.error);
return;
}
setDraft({});
setOpen(false);
// On reste sur la page : le mot de passe temporaire n'est affiché
// qu'ici, une seule fois, et n'est conservé nulle part.
@@ -48,18 +63,23 @@ export function AddMemberPanel({
}}
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
>
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e", marginBottom: 16 }}>
{error}
</div>
)}
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Nom de l&apos;adhérent</label>
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
<input name="name" required placeholder="ex : Au Bon Pain" className="field" defaultValue={draft.name ?? ""} />
</div>
<div>
<label className="field-label">E-mail (identifiant de connexion)</label>
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" />
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" defaultValue={draft.email ?? ""} />
</div>
<div>
<label className="field-label">Catégorie</label>
<select name="categoryId" className="field" defaultValue="">
<select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
@@ -70,7 +90,7 @@ export function AddMemberPanel({
</div>
<div>
<label className="field-label">Commune</label>
<select name="city" className="field" defaultValue="">
<select name="city" className="field" defaultValue={draft.city ?? ""}>
<option value="">—</option>
{BASSIN_POMPEY_COMMUNES.map((c) => (
<option key={c} value={c}>
@@ -81,7 +101,7 @@ export function AddMemberPanel({
</div>
<div>
<label className="field-label">Statut</label>
<select name="status" className="field" defaultValue="pending">
<select name="status" className="field" defaultValue={draft.status ?? "pending"}>
<option value="pending">En attente</option>
<option value="active">Actif</option>
</select>
@@ -13,7 +13,7 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
if (issued) {
return (
<OneTimeCredentials
items={issued.map((i) => ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))}
items={issued}
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
/>
);
@@ -25,7 +25,8 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
setPending(true);
try {
const created = await createMissingMemberAccounts();
setIssued(created.map((c) => ({ label: c.name, email: c.email, tempPassword: c.tempPassword })));
// On propage tout : l'état d'envoi accompagne chaque ligne.
setIssued(created.map((c) => ({ ...c, label: c.name })));
router.refresh();
} finally {
setPending(false);
+7
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { ImageField } from "@/components/ImageField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { communeOptions } from "@/lib/communes";
@@ -132,6 +133,12 @@ export default async function EditMemberPage({
</div>
</div>
<MemberContactFields
firstName={member.contactFirstName}
lastName={member.contactLastName}
phone={member.contactPhone}
/>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={member.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={member.logoUrl ?? ""} height={130} fit="contain" />
@@ -25,11 +25,16 @@ export function InviteAdminForm() {
setError(null);
try {
const result = await inviteAdmin(fd);
if (result && "error" in result) {
setError(result.error);
return;
}
setIssued(result ?? null);
router.refresh();
} catch {
// Next masque le détail des erreurs serveur en production.
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé.");
// Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés
// par l'action et affichés tels quels ci-dessus.
setError("Impossible de créer ce compte. Réessayez dans un instant.");
} finally {
setPending(false);
}
+363
View File
@@ -0,0 +1,363 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties, ReactNode } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
MAIL_LABELS,
MAIL_PROVIDERS,
getMailAccounts,
mailHealthCached,
type MailHealth,
} from "@/lib/mail-accounts";
import { recentMails } from "@/lib/mail-outbox";
import { SmtpAccountForm } from "@/components/SmtpAccountForm";
import { publicBaseUrl } from "@/lib/social-accounts";
import { getSiteSettings } from "@/lib/site-settings";
import type { MailAccount, MailProvider } from "@/db/schema";
import {
cancelMail,
checkMailProvider,
disconnectMail,
retryMail,
saveMailApp,
saveMailSmtp,
sendMailTest,
setMailProvider,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "18px 20px", marginBottom: 16 };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const ghost: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12.5, padding: "8px 13px", borderRadius: 9, cursor: "pointer", textDecoration: "none", display: "inline-block" };
const primary: CSSProperties = { ...ghost, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 };
const danger: CSSProperties = { ...ghost, color: "#d8472b", borderColor: "#e0c3bb" };
const pill: CSSProperties = { borderRadius: 999, padding: "5px 11px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
const BRAND: Record<MailProvider, { color: string; mark: string }> = {
google: { color: "#1a73e8", mark: "G" },
microsoft: { color: "#0f6cbd", mark: "M" },
smtp: { color: "#6c6150", mark: "@" },
};
const HELP: Record<"google" | "microsoft", { portal: string; steps: string[]; caution: string }> = {
google: {
portal: "https://console.cloud.google.com/apis/credentials",
steps: [
"Créez un projet, puis un identifiant OAuth de type « Application Web ».",
"Activez l'API Gmail dans la bibliothèque d'API du projet.",
"Sur l'écran de consentement, ajoutez la portée .../auth/gmail.send.",
"Déclarez l'adresse de retour ci-dessous comme URI de redirection autorisé.",
"Collez l'ID client et le code secret du client ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Tant que l'application reste en mode « Test », Google fait expirer l'autorisation au bout de 7 jours et l'envoi s'arrête sans prévenir. Passez-la « En production » (un écran d'avertissement subsiste, sans conséquence), ou déclarez-la « Interne » si l'association a un compte Google Workspace.",
},
microsoft: {
portal: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
steps: [
"Inscrivez une application, en autorisant « comptes dans un annuaire quelconque et comptes Microsoft personnels ».",
"Ajoutez l'adresse de retour ci-dessous comme URI de redirection de type « Web ».",
"Dans les autorisations d'API, ajoutez les permissions déléguées Mail.Send, User.Read et offline_access.",
"Créez un secret client et notez sa valeur : elle n'est affichée qu'une fois.",
"Collez l'ID d'application et le secret ci-dessus, puis connectez-vous avec la boîte de l'association.",
],
caution:
"Microsoft a désactivé l'authentification par simple mot de passe (SMTP AUTH) sur les boîtes outlook.com et hotmail.com : cette connexion par bouton est le seul chemin fiable pour ces adresses.",
},
};
function Banner({ tone, children }: { tone: "error" | "ok" | "warn"; children: ReactNode }) {
const palette = {
error: { background: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
ok: { background: "#e6f4ec", border: "#c4e2d1", color: "#1f8a5b" },
warn: { background: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
}[tone];
return (
<div role="status" style={{ ...palette, border: `1px solid ${palette.border}`, borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{children}
</div>
);
}
function StatusChip({ account, health }: { account: MailAccount | undefined; health: MailHealth | null }) {
if (!account) return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
if (health && !health.ok) return <span style={{ ...pill, background: "#fbe9e6", color: "#d8472b" }}>Ne répond plus</span>;
if (account.provider !== "smtp" && !account.fromAddress) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non connectée</span>;
}
if (account.provider === "smtp" && !account.smtpHost) {
return <span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Non configurée</span>;
}
return <span style={{ ...pill, background: "#e6f4ec", color: "#1f8a5b" }}>Connectée</span>;
}
function formatMoment(value: Date) {
return new Date(value).toLocaleString("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
const MAIL_STATUS: Record<string, { label: string; background: string; color: string }> = {
queued: { label: "En file", background: "#eaf0f6", color: "#2C6FB3" },
sending: { label: "En cours", background: "#eaf0f6", color: "#2C6FB3" },
sent: { label: "Remis", background: "#e6f4ec", color: "#1f8a5b" },
failed: { label: "Échec", background: "#fbe9e6", color: "#d8472b" },
cancelled: { label: "Annulé", background: "#f1efe7", color: "#a99c82" },
};
/**
* Boîte mail de l'association.
*
* Même structure que Backend › Réseaux sociaux, et mêmes règles : aucun secret
* ne descend vers le navigateur — le champ mot de passe part vide et un champ
* vide conserve la valeur enregistrée.
*/
export default async function BoiteMailPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; ok?: string; connected?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const { error, ok, connected } = await searchParams;
const [accounts, settings, base, log] = await Promise.all([
getMailAccounts(),
getSiteSettings(),
publicBaseUrl(),
recentMails(20),
]);
const byProvider = new Map(accounts.map((account) => [account.provider, account]));
const health = Object.fromEntries(
await Promise.all(MAIL_PROVIDERS.map(async (provider) => [provider, await mailHealthCached(provider)] as const)),
) as Record<MailProvider, MailHealth | null>;
const active = accounts.find((account) => account.isActive) ?? null;
return (
<div>
{error && <Banner tone="error">{error}</Banner>}
{ok && <Banner tone="ok">{ok}</Banner>}
{connected && <Banner tone="ok">Boîte {MAIL_LABELS[connected as MailProvider] ?? connected} connectée.</Banner>}
{!active && (
<Banner tone="warn">
Aucune boîte n&apos;expédie pour l&apos;instant : les mots de passe temporaires restent affichés à
l&apos;écran, et aucune information ne part par e-mail.
</Banner>
)}
{MAIL_PROVIDERS.map((provider) => {
const account = byProvider.get(provider);
const verdict = health[provider];
const brand = BRAND[provider];
const isActive = account?.isActive ?? false;
const connectable = provider !== "smtp";
const usable = provider === "smtp" ? Boolean(account?.smtpHost) : Boolean(account?.fromAddress);
return (
<section
key={provider}
style={{ ...panel, ...(isActive ? { borderColor: "#b9d3c6", boxShadow: "inset 4px 0 0 #1f8a5b" } : {}) }}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<div style={{ display: "flex", gap: 13, alignItems: "center" }}>
<span
aria-hidden="true"
className="font-display"
style={{ width: 40, height: 40, borderRadius: 10, background: brand.color, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: 16, flexShrink: 0 }}
>
{brand.mark}
</span>
<div>
<div className="font-display" style={{ fontWeight: 700, fontSize: 16, color: "#26201a" }}>{MAIL_LABELS[provider]}</div>
<div style={{ color: "#9a8d72", fontSize: 12.5 }}>
{provider === "smtp"
? account?.smtpHost
? `${account.smtpHost} : ${account.smtpPort ?? 465} · ${account.smtpUser}`
: "Aucun serveur enregistré"
: account?.fromAddress
? `${account.fromAddress}${account.connectedAt ? ` · connectée le ${formatMoment(account.connectedAt)}` : ""}`
: "Aucun compte relié"}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 9, alignItems: "center" }}>
<StatusChip account={account} health={verdict} />
{usable && (
<form action={setMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" disabled={isActive} style={{ ...(isActive ? { ...ghost, borderColor: "#b9d3c6", color: "#1f8a5b", cursor: "default" } : ghost) }}>
{isActive ? "✓ Expédie les messages" : "Expédier par ici"}
</button>
</form>
)}
</div>
</div>
{verdict && !verdict.ok && (
<div style={{ marginTop: 13 }}>
<Banner tone="error">{verdict.reason.slice(0, 400)}</Banner>
</div>
)}
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
{provider === "smtp" ? (
<SmtpAccountForm
saveAction={saveMailSmtp}
host={account?.smtpHost ?? ""}
port={account?.smtpPort ?? 465}
secure={account?.smtpSecure ?? true}
user={account?.smtpUser ?? ""}
hasPassword={Boolean(account?.smtpPassword)}
fromAddress={account?.fromAddress ?? ""}
fromName={account?.fromName ?? settings.association_name}
submitStyle={primary}
/>
) : (
<>
<form action={saveMailApp}>
<input type="hidden" name="provider" value={provider} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 12 }}>
<label className="field-label">
Identifiant de l&apos;application
<input className="field" name="appId" required defaultValue={account?.appId ?? ""} style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Secret de l&apos;application
<input
className="field"
name="appSecret"
type="password"
autoComplete="new-password"
placeholder={account?.appSecret ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
style={{ marginTop: 6 }}
/>
</label>
</div>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
<button type="submit" style={primary}>Enregistrer</button>
<Link
href={account?.appId ? `/api/mail/${provider}/connect` : "#"}
aria-disabled={!account?.appId}
style={{ ...ghost, ...(account?.appId ? {} : { opacity: 0.5, pointerEvents: "none" }) }}
>
{account?.fromAddress ? "Reconnecter la boîte" : "Connecter la boîte"}
</Link>
</div>
</form>
<details style={{ marginTop: 14 }}>
<summary style={{ cursor: "pointer", fontSize: 13, fontWeight: 700, color: "#9a6638" }}>
Comment obtenir ces identifiants ?
</summary>
<ol style={{ margin: "11px 0 0", paddingLeft: 20, color: "#6c6150", fontSize: 13, lineHeight: 1.7 }}>
{HELP[provider].steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p style={{ margin: "11px 0 0", color: "#6c6150", fontSize: 13 }}>
Adresse de retour à déclarer :{" "}
<code style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 5, padding: "1px 6px", fontSize: 12.5 }}>
{base ? `${base}/api/mail/${provider}/callback` : "définissez d'abord l'URL publique du site"}
</code>
</p>
<p style={{ margin: "11px 0 0" }}>
<Link href={HELP[provider].portal} target="_blank" style={{ color: "#2C6FB3", fontSize: 13, fontWeight: 700 }}>
Ouvrir la console {provider === "google" ? "Google Cloud" : "Microsoft Entra"} ↗
</Link>
</p>
<div style={{ marginTop: 12 }}>
<Banner tone="warn">{HELP[provider].caution}</Banner>
</div>
</details>
</>
)}
{usable && (
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<form action={checkMailProvider}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={ghost}>Vérifier la connexion</button>
</form>
{connectable && account?.fromAddress && (
<form action={disconnectMail}>
<input type="hidden" name="provider" value={provider} />
<button type="submit" style={danger}>Déconnecter</button>
</form>
)}
{verdict?.ok && <span style={{ alignSelf: "center", color: "#1f8a5b", fontSize: 12.5 }}>✓ {verdict.detail}</span>}
</div>
)}
</section>
);
})}
<section style={panel}>
<div style={eyebrow}>Envoi de contrôle</div>
<form action={sendMailTest} style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<button type="submit" disabled={!active} style={{ ...primary, ...(active ? {} : { opacity: 0.5 }) }}>
M&apos;envoyer un message de test
</button>
<span className="field-hint">
Part immédiatement vers votre propre adresse et affiche le verdict, sans passer par la file.
</span>
</form>
</section>
<section style={panel}>
<div style={eyebrow}>Journal d&apos;envoi</div>
{log.length === 0 ? (
<div style={{ color: "#a99c82", fontSize: 13 }}>Aucun message expédié pour l&apos;instant.</div>
) : (
<div className="table-scroll">
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12.8 }}>
<thead>
<tr>
{["Quand", "Destinataire", "Objet", "État", ""].map((header) => (
<th key={header} style={{ textAlign: "left", fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, padding: "0 10px 8px 0", whiteSpace: "nowrap" }}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{log.map((message) => {
const status = MAIL_STATUS[message.status] ?? MAIL_STATUS.queued;
return (
<tr key={message.id}>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068", whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>
{formatMoment(message.createdAt)}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#26201a", fontWeight: 600 }}>{message.toAddress}</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6", color: "#8c8068" }}>
{message.subject}
{message.error && <div style={{ color: "#d8472b", fontSize: 11.5, marginTop: 3 }}>{message.error.slice(0, 160)}</div>}
</td>
<td style={{ padding: "9px 10px 9px 0", borderTop: "1px solid #f0e8d6" }}>
<span style={{ ...pill, background: status.background, color: status.color }}>{status.label}</span>
</td>
<td style={{ padding: "9px 0", borderTop: "1px solid #f0e8d6", whiteSpace: "nowrap" }}>
{message.status === "failed" && (
<form action={retryMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Réessayer</button>
</form>
)}
{message.status === "queued" && (
<form action={cancelMail}>
<input type="hidden" name="id" value={message.id} />
<button type="submit" style={{ ...ghost, padding: "5px 10px", fontSize: 11.5 }}>Annuler</button>
</form>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
@@ -10,10 +10,22 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
* Approuve une demande et affiche une seule fois les identifiants du compte
* créé. On reste sur la page : une redirection ferait perdre le mot de passe,
* qui n'est conservé nulle part.
*
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
* composant — et non la page — qui en décide : après l'approbation,
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
*/
export function ApproveRequestForm({ requestId }: { requestId: number }) {
export function ApproveRequestForm({
requestId,
status,
}: {
requestId: number;
status: string;
}) {
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
if (created) {
@@ -27,13 +39,20 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
);
}
if (status !== "new") return null;
return (
<form
action={async (fd) => {
setPending(true);
try {
const result = await approveMembershipRequest(fd);
if (result) {
// Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
// raison s'affiche à côté du bouton, la page reste en place.
if (result && "error" in result) {
setError(result.error);
} else if (result) {
setError(null);
setCreated(result);
router.refresh();
}
@@ -43,6 +62,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
}}
>
<input type="hidden" name="id" value={requestId} />
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 9, padding: "8px 11px", fontSize: 12.5, color: "#a3372e", marginBottom: 8, maxWidth: 280, lineHeight: 1.5 }}>
{error}
</div>
)}
<button
type="submit"
disabled={pending}
+41 -16
View File
@@ -72,13 +72,25 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={REQ_STATUS} status={r.status} />
{/* Une demande traitée ne garde que la sortie qui a du sens :
« Rejeter » disparaît une fois l'adhérent créé, et un rejet
reste réversible tant qu'aucun compte n'existe. */}
<div style={{ display: "flex", gap: 8 }}>
<ApproveRequestForm requestId={r.id} />
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
<ApproveRequestForm requestId={r.id} status={r.status} />
{r.status === "new" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
)}
{r.status === "rejected" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="new" />
<ActionBtn color="#6c6150">Rouvrir</ActionBtn>
</form>
)}
</div>
</div>
{r.message && (
@@ -109,17 +121,30 @@ export default async function DemandesPage() {
</div>
</div>
<StatusPill map={MSG_STATUS} status={m.status} />
{/* Chaque bouton fait avancer le message d'un cran : on ne
propose plus « Marquer lu » sur un message déjà lu ni
« Archiver » sur un message archivé. */}
<div style={{ display: "flex", gap: 8 }}>
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
</form>
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
{m.status === "new" && (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
</form>
)}
{m.status !== "archived" ? (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
) : (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#6c6150">Désarchiver</ActionBtn>
</form>
)}
</div>
</div>
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
+19 -10
View File
@@ -2,21 +2,30 @@ import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { EmailCreator } from "@/components/EmailCreator";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { audienceOptions } from "@/lib/mail-recipients";
import { isMailConfigured } from "@/lib/mail-accounts";
import { siteUrl } from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
export default async function EmailsPage() {
const session = await getSession();
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
const settings = await getSiteSettings();
const brand = {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
return <EmailCreator brand={brand} />;
const [settings, choices, mailReady, base] = await Promise.all([
getSiteSettings(),
audienceOptions(),
isMailConfigured(),
siteUrl(),
]);
return (
<EmailCreator
brand={emailBrand(settings)}
choices={choices}
mailReady={mailReady}
siteUrl={base}
/>
);
}
+12 -4
View File
@@ -5,6 +5,7 @@ function initialsOf(name: string) {
}
const TABS = [
{ key: "informations", href: "/backend/espace/informations", label: "Informations" },
{ key: "profil", href: "/backend/espace", label: "Mon profil" },
{ key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" },
] as const;
@@ -12,9 +13,12 @@ const TABS = [
export type EspaceTab = (typeof TABS)[number]["key"];
/**
* Bandeau de l'espace adhérent et onglets Profil / Promotions : deux pages
* distinctes pour que la fiche publique et la vie des promotions ne se
* mélangent pas dans une seule longue page.
* Bandeau de l'espace adhérent et ses onglets. Trois pages distinctes pour que
* les annonces de l'association, la fiche publique et la vie des promotions ne
* se mélangent pas dans une seule longue page.
*
* Les compteurs sont passés par chaque page : sans cela ils disparaîtraient
* en changeant d'onglet.
*/
export function EspaceHeader({
memberName,
@@ -22,6 +26,7 @@ export function EspaceHeader({
active,
publicPath,
promoBadge,
infoBadge,
}: {
memberName: string;
subtitle: string;
@@ -29,6 +34,8 @@ export function EspaceHeader({
publicPath?: string | null;
/** Nombre de promotions en ligne, affiché sur l'onglet. */
promoBadge?: number;
/** Nombre d'informations non lues. */
infoBadge?: number;
}) {
return (
<div style={{ marginBottom: 22 }}>
@@ -49,10 +56,11 @@ export function EspaceHeader({
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
{TABS.map((tab) => {
const isActive = tab.key === active;
const badge = tab.key === "promotions" ? promoBadge : tab.key === "informations" ? infoBadge : 0;
return (
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
{tab.label}
{tab.key === "promotions" && promoBadge ? <span className="espace-tab__badge">{promoBadge}</span> : null}
{badge ? <span className="espace-tab__badge">{badge}</span> : null}
</Link>
);
})}
+192 -4
View File
@@ -4,9 +4,13 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { publishPromo } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
import { imageNotes, imageSummary, type ImageInfo } from "@/lib/image-info";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -16,13 +20,83 @@ const NETWORK_LABELS: Record<SocialNetwork, string> = {
linkedin: "LinkedIn",
};
/** Une ligne du récapitulatif de dates, à droite du formulaire. */
function SummaryRow({
label,
value,
tone,
last = false,
}: {
label: string;
value: string;
tone: "set" | "muted" | "error";
last?: boolean;
}) {
const color = tone === "error" ? "#d8472b" : tone === "set" ? "#3c3322" : "#a99c82";
return (
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 14,
padding: "12px 0",
borderBottom: last ? "none" : "1px solid #f0e8d6",
}}
>
<span style={{ fontSize: 12.5, color: "#9a8d72", fontWeight: 700, whiteSpace: "nowrap" }}>{label}</span>
<span style={{ fontSize: 13, color, fontWeight: tone === "muted" ? 500 : 700, textAlign: "right" }}>
{value}
</span>
</div>
);
}
/**
* Taille, format et poids de l'image déposée, avec ce que les réseaux en
* feront. Purement informatif : aucune remarque n'empêche l'envoi.
*/
function ImageReport({ info }: { info: ImageInfo }) {
const notes = imageNotes(info);
return (
<div
style={{
marginTop: -8,
marginBottom: 18,
background: "#faf7ef",
border: "1px solid #f0e8d6",
borderRadius: 12,
padding: "10px 12px",
}}
>
<div style={{ fontSize: 12.5, fontWeight: 700, color: "#6c6150" }}>{imageSummary(info)}</div>
{notes.map((note, i) => (
<div
key={i}
style={{
fontSize: 11.5,
lineHeight: 1.45,
marginTop: 5,
color: note.tone === "warn" ? "#a8503c" : "#1f8a5b",
}}
>
{note.text}
</div>
))}
</div>
);
}
export function MemberSpaceForm({
memberName,
memberLogoUrl,
categoryGroups,
defaultCategory,
networks = [],
}: {
memberName: string;
/** Logo de l'adhérent, affiché dans l'angle de l'aperçu comme sur le site. */
memberLogoUrl?: string | null;
/** Types de produits / services, groupés pour le `<select>`. */
categoryGroups: PromoCategoryGroup[];
/** Pré-sélection déduite du métier de l'adhérent. */
@@ -36,7 +110,14 @@ export function MemberSpaceForm({
const [text, setText] = useState("");
const [cat, setCat] = useState(initialCategory);
const [badge, setBadge] = useState("");
const [startsOn, setStartsOn] = useState("");
const [endsOn, setEndsOn] = useState("");
const [publishAt, setPublishAt] = useState("");
const [imgData, setImgData] = useState("");
// Dimensions et poids du fichier déposé : rien n'est retouché à l'envoi, donc
// c'est ici — dernier moment où l'adhérent peut changer de visuel — qu'on lui
// dit ce que les réseaux feront d'un format inattendu.
const [imgInfo, setImgInfo] = useState<ImageInfo | null>(null);
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
const [pending, setPending] = useState(false);
@@ -50,10 +131,20 @@ export function MemberSpaceForm({
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const bytes = f.size;
setImgInfo(null);
const reader = new FileReader();
reader.onload = () => {
setImgData(String(reader.result));
const dataUrl = String(reader.result);
setImgData(dataUrl);
setSubmitted(false);
// Les dimensions ne sont lisibles qu'une fois l'image décodée par le
// navigateur. Un échec de décodage laisse simplement le résumé vide :
// l'information est un confort, elle ne conditionne pas le dépôt.
const probe = new Image();
probe.onload = () => setImgInfo({ width: probe.naturalWidth, height: probe.naturalHeight, bytes });
probe.onerror = () => setImgInfo(null);
probe.src = dataUrl;
};
reader.readAsDataURL(f);
}
@@ -62,7 +153,11 @@ export function MemberSpaceForm({
setTitle("");
setText("");
setBadge("");
setStartsOn("");
setEndsOn("");
setPublishAt("");
setImgData("");
setImgInfo(null);
setShares([]);
setSubmitted(false);
if (fileRef.current) fileRef.current.value = "";
@@ -76,6 +171,9 @@ export function MemberSpaceForm({
fd.set("text", text);
fd.set("category", cat);
fd.set("badge", badge);
fd.set("startsOn", startsOn);
fd.set("endsOn", endsOn);
fd.set("publishAt", publishAt);
fd.set("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
@@ -89,6 +187,17 @@ export function MemberSpaceForm({
const hasImg = !!imgData;
const hasBadge = !!badge.trim();
const validity = formatValidity({ startsOn, endsOn });
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
const badRange = isRangeInvalid({ startsOn, endsOn });
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
// avec le même formateur que le backoffice pour éviter toute surprise.
let scheduleHint: string | null = null;
try {
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
} catch {
scheduleHint = null;
}
return (
<div className="grid grid-2" style={{ alignItems: "start" }}>
@@ -136,6 +245,8 @@ export function MemberSpaceForm({
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
</label>
{imgInfo && <ImageReport info={imgInfo} />}
<label className="field-label">Titre de la promotion</label>
<input
value={title}
@@ -169,6 +280,56 @@ export function MemberSpaceForm({
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label className="field-label">Durée de validité · facultatif</label>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<div style={{ flex: "1 1 150px" }}>
<input
type="date"
value={startsOn}
onChange={(e) => { setStartsOn(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Début de la promotion"
/>
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Début</div>
</div>
<div style={{ flex: "1 1 150px" }}>
<input
type="date"
value={endsOn}
min={startsOn || undefined}
onChange={(e) => { setEndsOn(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Fin de la promotion"
/>
<div style={{ fontSize: 11.5, color: "#a99c82", marginTop: 4 }}>Fin</div>
</div>
</div>
<div style={{ fontSize: 12, color: badRange ? "#d8472b" : "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
{badRange
? "La date de fin est antérieure à la date de début."
: validity
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label className="field-label">Programmer la publication · facultatif</label>
<input
type="datetime-local"
value={publishAt}
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Date et heure de publication souhaitées"
/>
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
{scheduleHint
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
</div>
</div>
<label className="field-label">Texte de la promotion</label>
<textarea
value={text}
@@ -230,7 +391,7 @@ export function MemberSpaceForm({
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={submit}
disabled={pending}
disabled={pending || badRange}
className="font-display"
style={{ flex: 1, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 15, padding: 14, borderRadius: 11, cursor: pending ? "default" : "pointer", opacity: pending ? 0.7 : 1 }}
>
@@ -255,10 +416,31 @@ export function MemberSpaceForm({
{/* LIVE PREVIEW */}
<div>
{/* Récapitulatif des dates : quand l'offre paraît, combien de temps
elle reste. Deux informations que la carte ne montre pas. */}
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
Dates de votre offre
</div>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "4px 16px", marginBottom: 26 }}>
<SummaryRow
label="Durée de validité"
value={badRange ? "Dates incohérentes" : validity ?? "Sans date limite"}
tone={badRange ? "error" : validity ? "set" : "muted"}
/>
<SummaryRow
label="Publication"
value={scheduleHint ? `Programmée ${scheduleHint}` : "Dès la validation par l'association"}
tone={scheduleHint ? "set" : "muted"}
last
/>
</div>
<div style={{ fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 700, marginBottom: 12 }}>
Aperçu en direct
</div>
<article style={{ background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
{/* Largeur bornée : la carte doit ressembler à celle du site, pas
occuper toute la colonne (le visuel carré la rendait démesurée). */}
<article style={{ maxWidth: 320, background: "#fff", border: "1px solid #ece3d0", borderRadius: 18, overflow: "hidden", boxShadow: "0 18px 40px -26px rgba(40,30,15,0.5)" }}>
<div
style={{
position: "relative",
@@ -293,8 +475,14 @@ export function MemberSpaceForm({
{text.trim() ||
"Le texte de votre offre apparaîtra ici au fur et à mesure que vous le rédigez."}
</p>
{validity && (
<div style={{ fontSize: 12, color: "#a99c82", marginBottom: 12 }}>{validity}</div>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</div>
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
<MemberAvatar name={memberName} logoUrl={memberLogoUrl} size={28} />
<span style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{memberName}</span>
</div>
<span style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>Voir l&apos;offre →</span>
</div>
</div>
@@ -0,0 +1,102 @@
import { and, eq, sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { memberPath } from "@/lib/seo";
import { getMemberFeed } from "@/lib/informations";
import { InformationCard } from "@/components/InformationCard";
import { MarkInformationsRead } from "@/components/MarkInformationsRead";
import { EspaceHeader } from "../EspaceHeader";
export const dynamic = "force-dynamic";
/**
* Espace adhérent, onglet Informations : ce que publie l'association.
*
* Les non-lues sont calculées **avant** le marquage, qui a lieu après le rendu
* (`MarkInformationsRead`) : les pastilles « Nouveau » sont donc visibles sur
* la visite où elles disparaissent.
*/
export default async function EspaceInformationsPage() {
const session = await getSession();
if (!session?.user) redirect("/login");
const userId = Number(session.user.id);
const memberId = session.user.memberId ?? null;
let memberName = session.user.name ?? "Adhérent";
let subtitle = "Espace adhérent";
let publicPath: string | null = null;
let livePromoCount = 0;
if (memberId) {
const [profile] = await db.select().from(members).where(eq(members.id, memberId));
if (profile) {
memberName = profile.name;
const [category] = profile.categoryId
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, profile.categoryId))
: [];
subtitle = ["Espace adhérent", category?.label, profile.city].filter(Boolean).join(" · ");
if (profile.status === "active") publicPath = memberPath(profile);
}
const [live] = await db
.select({ total: sql<number>`count(*)::int` })
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
livePromoCount = live?.total ?? 0;
}
const feed = await getMemberFeed(userId);
const unreadIds = feed.filter((item) => item.unread).map((item) => item.id);
return (
<div>
<EspaceHeader
memberName={memberName}
subtitle={subtitle}
active="informations"
publicPath={publicPath}
promoBadge={livePromoCount}
infoBadge={unreadIds.length}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 12, flexWrap: "wrap", marginBottom: 16 }}>
<div>
<h2 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 19, color: "#26201a" }}>
Informations de l&apos;association
</h2>
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
Annonces, convocations et rappels publiés par le bureau.
</div>
</div>
{unreadIds.length > 0 && (
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "6px 13px", fontSize: 12.5, fontWeight: 800 }}>
{unreadIds.length} non lue{unreadIds.length > 1 ? "s" : ""}
</span>
)}
</div>
{feed.length === 0 ? (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 28, color: "#a99c82", fontSize: 13.5 }}>
L&apos;association n&apos;a encore rien publié. Les annonces du bureau apparaîtront ici.
</div>
) : (
feed.map((item) => (
<InformationCard
key={item.id}
title={item.title}
body={item.body}
imageUrl={item.imageUrl}
publishedAt={item.publishedAt}
authorName={item.authorName}
pinned={item.pinned}
unread={item.unread}
/>
))
)}
<MarkInformationsRead ids={unreadIds} />
</div>
);
}
+11
View File
@@ -9,7 +9,9 @@ import { ImageField } from "@/components/ImageField";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { HoursEditor } from "@/components/HoursEditor";
import { TagsField } from "@/components/TagsField";
import { MemberContactFields } from "@/components/MemberContactFields";
import { communeOptions } from "@/lib/communes";
import { unreadInformationCount } from "@/lib/informations";
import { saveImageConsent, updateOwnProfile } from "../actions";
import { EspaceHeader } from "./EspaceHeader";
@@ -20,6 +22,8 @@ export default async function EspacePage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
const fallbackName = session?.user.name ?? "Adhérent";
const userId = Number(session?.user.id);
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let memberName = fallbackName;
let subtitle = "Espace adhérent";
@@ -87,6 +91,7 @@ export default async function EspacePage() {
active="profil"
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
promoBadge={livePromoCount}
infoBadge={infoBadge}
/>
{memberId && (
@@ -167,6 +172,12 @@ export default async function EspacePage() {
</div>
</div>
<MemberContactFields
firstName={profile.contactFirstName}
lastName={profile.contactLastName}
phone={profile.contactPhone}
/>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} fit="contain" />
+47 -4
View File
@@ -7,7 +7,10 @@ import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
import { scheduleLabel } from "@/lib/promo-schedule";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { unreadInformationCount } from "@/lib/informations";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
@@ -19,6 +22,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
type MyPromo = {
@@ -28,6 +32,9 @@ type MyPromo = {
category: string | null;
badge: string | null;
validUntil: string | null;
publishAt: Date | null;
startsOn: string | null;
endsOn: string | null;
createdAt: Date;
imageUrl: string | null;
suspendedBy: "member" | "staff" | null;
@@ -45,7 +52,12 @@ function fmtDate(d: Date) {
export default async function EspacePromotionsPage() {
const session = await getSession();
const memberId = session?.user.memberId ?? null;
// Le compteur suit l'adhérent d'onglet en onglet : sans cela il
// disparaîtrait en quittant le fil.
const userId = Number(session?.user.id);
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let memberName = session?.user.name ?? "Adhérent";
let memberLogoUrl: string | null = null;
let subtitle = "Espace adhérent · Promotions";
let publicPath: string | null = null;
let categorySlug: string | null = null;
@@ -59,6 +71,7 @@ export default async function EspacePromotionsPage() {
name: members.name,
city: members.city,
status: members.status,
logoUrl: members.logoUrl,
categorySlug: categories.slug,
categoryLabel: categories.label,
})
@@ -67,6 +80,7 @@ export default async function EspacePromotionsPage() {
.where(eq(members.id, memberId));
if (m) {
memberName = m.name;
memberLogoUrl = m.logoUrl;
categorySlug = m.categorySlug;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
publicPath = m.status === "active" ? memberPath(m) : null;
@@ -79,6 +93,9 @@ export default async function EspacePromotionsPage() {
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
publishAt: promotions.publishAt,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
createdAt: promotions.createdAt,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
@@ -111,7 +128,12 @@ export default async function EspacePromotionsPage() {
const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending");
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
// Validées mais pas encore à leur date : elles n'ont rien à faire dans
// l'historique des offres terminées.
const scheduled = myPromos.filter((p) => p.status === "scheduled");
const others = myPromos.filter(
(p) => p.status !== "live" && p.status !== "pending" && p.status !== "scheduled"
);
return (
<div>
@@ -122,7 +144,7 @@ export default async function EspacePromotionsPage() {
</div>
)}
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} />
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} infoBadge={infoBadge} />
{/* Résumé : ce qui est visible par le public aujourd'hui */}
<div className="grid grid-3" style={{ gap: 12, marginBottom: 24 }}>
@@ -146,11 +168,22 @@ export default async function EspacePromotionsPage() {
<MemberSpaceForm
memberName={memberName}
memberLogoUrl={memberLogoUrl}
categoryGroups={PROMO_CATEGORY_GROUPS}
defaultCategory={defaultPromoCategory(categorySlug)}
networks={networks}
/>
{scheduled.length > 0 && (
<PromoSection
title="Publications programmées"
intro="Validées par l'association : elles apparaîtront sur le site et sur les réseaux à la date prévue."
promos={scheduled}
networks={networks}
lastShare={lastShare}
/>
)}
{pending.length > 0 && (
<PromoSection
title="En attente de validation"
@@ -173,7 +206,7 @@ export default async function EspacePromotionsPage() {
{myPromos.length > 0 && (
<p style={{ fontSize: 13, color: "#8c8068", marginTop: 18 }}>
Les offres en ligne apparaissent sur <Link href="/#promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page d&apos;accueil</Link>
Les offres en ligne apparaissent sur <Link href="/promotions" style={{ color: "#9a6638", fontWeight: 700 }}>la page des promotions</Link>
{publicPath ? (
<>
{" "}et sur <Link href={publicPath} style={{ color: "#9a6638", fontWeight: 700 }}>votre fiche</Link>
@@ -237,7 +270,7 @@ function PromoSection({
function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks: SocialNetwork[]; lastShare: ShareState }) {
const st = PROMO_STATUS[mp.status] ?? PROMO_STATUS.expired;
const suspendedByStaff = mp.status === "suspended" && mp.suspendedBy === "staff";
const meta = [mp.category, mp.badge, mp.validUntil].filter(Boolean).join(" · ");
const meta = [mp.category, mp.badge, formatValidityShort(mp)].filter(Boolean).join(" · ");
return (
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 12, padding: "12px 14px", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 13, flexWrap: "wrap" }}>
@@ -281,6 +314,16 @@ function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks
</form>
)}
</div>
{mp.status === "live" && visibilityNote(mp) && (
<div style={{ marginTop: 10, background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
{visibilityNote(mp)}
</div>
)}
{mp.status === "scheduled" && (
<div style={{ marginTop: 10, background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5, fontWeight: 600 }}>
Validée par l&apos;association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps.
</div>
)}
{suspendedByStaff && (
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
Cette promotion a été suspendue par l&apos;association. Contactez-la pour la remettre en ligne.
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ImageField } from "@/components/ImageField";
import { RichTextEditor } from "@/components/RichTextEditor";
import { saveInformation } from "../actions";
/**
* Rédaction d'une information.
*
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
* d'association n'a pas à connaître Markdown.
*
* Ce qui est enregistré reste malgré tout le format balisé restreint : c'est
* `RichTextEditor` qui resérialise à chaque frappe. Aucun HTML tiers n'atteint
* la base, et l'éditeur est son propre aperçu — il n'y a plus deux rendus à
* comparer.
*/
type Draft = {
id: number | null;
title: string;
body: string;
imageUrl: string | null;
pinned: boolean;
};
export function InformationForm({ draft }: { draft: Draft }) {
const router = useRouter();
const [title, setTitle] = useState(draft.title);
const [body, setBody] = useState(draft.body);
const [pinned, setPinned] = useState(draft.pinned);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Remonte le formulaire après une création : `ImageField` n'est pas contrôlé,
// seul un changement de clé le remet à vide.
const [round, setRound] = useState(0);
async function submit(formData: FormData) {
setSaving(true);
setError(null);
const result = await saveInformation(formData);
setSaving(false);
if (result && "error" in result) {
// Refus de saisie : le formulaire reste ouvert avec ce qui a été écrit.
setError(result.error);
return;
}
if (draft.id) {
router.push("/backend/informations");
} else {
setTitle("");
setBody("");
setPinned(false);
setRound((value) => value + 1);
}
router.refresh();
}
return (
<form key={round} action={submit} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "20px 22px" }}>
{draft.id && <input type="hidden" name="id" value={draft.id} />}
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 14 }}>
{draft.id ? "Modifier l'information" : "Nouvelle information"}
</div>
<label className="field-label" htmlFor="information-title">Titre</label>
<input
id="information-title"
className="field"
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={200}
placeholder="Assemblée générale — mardi 14 octobre, 19 h"
style={{ marginBottom: 15, fontWeight: 700 }}
/>
<label className="field-label" id="information-body-label">Message</label>
<RichTextEditor
value={draft.body}
onChange={setBody}
ariaLabel="Message de l'information"
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
/>
{/* Le format balisé ne se montre pas : il voyage dans un champ caché. */}
<input type="hidden" name="body" value={body} />
<p className="field-hint" style={{ marginTop: 6 }}>
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
conserve la mise en forme reconnue et laisse tomber le reste.
</p>
<div style={{ marginTop: 16 }}>
<ImageField name="imageUrl" label="Image (facultative)" defaultValue={draft.imageUrl ?? ""} height={150} />
</div>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 16, cursor: "pointer" }}>
<input type="checkbox" name="pinned" checked={pinned} onChange={(event) => setPinned(event.target.checked)} style={{ marginTop: 3 }} />
<span>
<strong style={{ fontSize: 13.5, color: "#26201a" }}>Épingler en tête de l&apos;espace adhérent</strong>
<span className="field-hint" style={{ display: "block" }}>
Une seule information à la fois : celle déjà épinglée redescend dans le fil.
</span>
</span>
</label>
{error && (
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
{error}
</div>
)}
<div style={{ display: "flex", gap: 9, marginTop: 18, flexWrap: "wrap" }}>
<button
type="submit"
disabled={saving}
style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "12px 20px", borderRadius: 10, cursor: saving ? "progress" : "pointer", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Enregistrement…" : draft.id ? "Enregistrer les modifications" : "Enregistrer le brouillon"}
</button>
{draft.id && (
<Link
href="/backend/informations"
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 13.5, padding: "12px 18px", borderRadius: 10, textDecoration: "none" }}
>
Annuler
</Link>
)}
</div>
</form>
);
}
+191
View File
@@ -0,0 +1,191 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { countMemberAccounts, getAdminInformations, getInformation } from "@/lib/informations";
import { richTextExcerpt } from "@/lib/rich-text";
import { isMailConfigured } from "@/lib/mail-accounts";
import { mailCountsFor } from "@/lib/mail-outbox";
import { InformationForm } from "./InformationForm";
import {
deleteInformation,
publishInformation,
toggleInformationPin,
unpublishInformation,
} from "../actions";
export const dynamic = "force-dynamic";
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "17px 18px" };
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const rowButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 11px", borderRadius: 8, cursor: "pointer" };
const pill: CSSProperties = { borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
function formatDay(value: Date) {
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
}
/**
* Rédaction et suivi des informations diffusées dans l'espace adhérent.
*
* L'information en cours d'édition est désignée par `?modifier=<id>` plutôt
* que par un état client : la page reste un composant serveur, et un lien
* suffit à passer d'une information à l'autre.
*/
export default async function InformationsPage({
searchParams,
}: {
searchParams: Promise<{ modifier?: string; error?: string; ok?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageInformations")) redirect("/backend");
// Un modérateur publie ; écrire à tous les adhérents reste réservé à qui
// tient déjà le studio d'e-mails.
const canBroadcast = can(session?.user.role, "manageEmails");
const mailReady = canBroadcast && (await isMailConfigured());
const { modifier, error, ok } = await searchParams;
const editId = Number(modifier ?? 0) || null;
const editing = editId ? await getInformation(editId) : null;
const [items, memberCount] = await Promise.all([getAdminInformations(), countMemberAccounts()]);
const published = items.filter((item) => item.status === "published");
const drafts = items.filter((item) => item.status === "draft");
const mailCounts = Object.fromEntries(
await Promise.all(
published
.filter((item) => item.emailSentAt)
.map(async (item) => [item.id, await mailCountsFor(item.id)] as const)
)
);
return (
<div>
{error && (
<div role="status" style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{error}
</div>
)}
{ok && (
<div role="status" style={{ background: "#e6f4ec", border: "1px solid #c4e2d1", color: "#1f8a5b", borderRadius: 10, padding: "11px 15px", fontSize: 13.5, marginBottom: 14 }}>
{ok}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(0, 1fr)", gap: 18, alignItems: "start" }} className="informations-split">
<InformationForm
key={editing?.id ?? "nouvelle"}
draft={{
id: editing?.id ?? null,
title: editing?.title ?? "",
body: editing?.body ?? "",
imageUrl: editing?.imageUrl ?? null,
pinned: editing?.pinned ?? false,
}}
/>
<div style={{ display: "grid", gap: 16 }}>
<section style={panel}>
<div style={eyebrow}>Publiées · {published.length}</div>
{published.length === 0 && (
<div style={{ color: "#a99c82", fontSize: 13 }}>
Rien n&apos;est encore visible dans l&apos;espace adhérent.
</div>
)}
<div style={{ display: "grid", gap: 12 }}>
{published.map((item, index) => (
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
{item.pinned && <span style={{ ...pill, background: "#fbeede", color: "#9a6638" }}>Épinglée</span>}
</div>
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
{item.publishedAt ? formatDay(item.publishedAt) : "—"}
{" · "}
lue par {item.readCount} / {memberCount}
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
{item.emailSentAt && (
<div style={{ color: "#1f8a5b", fontSize: 12, marginTop: 5, fontWeight: 700 }}>
Diffusée par e-mail · {mailCounts[item.id]?.sent ?? 0} remis
{mailCounts[item.id]?.queued ? ` · ${mailCounts[item.id].queued} en file` : ""}
{mailCounts[item.id]?.failed ? ` · ${mailCounts[item.id].failed} en échec` : ""}
</div>
)}
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
</Link>
<form action={toggleInformationPin}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={rowButton}>{item.pinned ? "Décrocher" : "Épingler"}</button>
</form>
<form action={unpublishInformation}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={rowButton}>Retirer</button>
</form>
</div>
</div>
))}
</div>
</section>
<section style={panel}>
<div style={eyebrow}>Brouillons · {drafts.length}</div>
{drafts.length === 0 && <div style={{ color: "#a99c82", fontSize: 13 }}>Aucun brouillon en attente.</div>}
<div style={{ display: "grid", gap: 12 }}>
{drafts.map((item, index) => (
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
<span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Brouillon</span>
</div>
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
Modifié le {formatDay(item.updatedAt)}
{item.authorName ? ` · ${item.authorName}` : ""}
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
<form action={publishInformation} style={{ display: "grid", gap: 7 }}>
<input type="hidden" name="id" value={item.id} />
{canBroadcast && (
<label style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 12.5, color: "#6c6150", cursor: mailReady ? "pointer" : "not-allowed" }}>
<input type="checkbox" name="sendEmail" disabled={!mailReady} style={{ marginTop: 2 }} />
<span>
Envoyer aussi par e-mail <strong>aux {memberCount} adhérent(s)</strong>
{!mailReady && (
<span style={{ display: "block", color: "#a99c82", fontSize: 11.5 }}>
Aucune boîte mail configurée — voir Configuration › Boîte mail.
</span>
)}
</span>
</label>
)}
<button
type="submit"
style={{ ...rowButton, border: "none", background: "#13324F", color: "#fff", fontWeight: 800, justifySelf: "start" }}
>
Publier
</button>
</form>
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
Modifier
</Link>
<form action={deleteInformation}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" style={{ ...rowButton, color: "#d8472b", borderColor: "#e0c3bb" }}>
Supprimer
</button>
</form>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</div>
);
}
+7
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
import { ImageConsentForm } from "@/components/ImageConsentForm";
import { isStaff } from "@/lib/rbac";
import { unreadInformationCount } from "@/lib/informations";
import { saveImageConsent } from "./actions";
import { BackendShell } from "./BackendShell";
import { NOINDEX } from "@/lib/seo";
@@ -70,6 +71,11 @@ export default async function BackendLayout({
}
}
// Le compteur d'informations non lues suit le compte, pas le rôle : un
// membre du bureau a lui aussi un espace adhérent.
const userId = Number(session.user.id);
const infoCount = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
let pendingCount = 0;
let inboxCount = 0;
if (isStaff(session.user.role)) {
@@ -87,6 +93,7 @@ export default async function BackendLayout({
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
pendingCount={pendingCount}
inboxCount={inboxCount}
infoCount={infoCount}
>
{children}
</BackendShell>
+57 -15
View File
@@ -3,10 +3,17 @@ 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 { countVisiblePromotions } from "@/lib/queries";
import {
expiryStatus,
getSocialAccounts,
SOCIAL_LABELS,
tokenHealthCached,
type SocialNetwork,
} from "@/lib/social-accounts";
export const dynamic = "force-dynamic";
@@ -42,7 +49,7 @@ export default async function DashboardPage() {
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
countVisiblePromotions(),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
@@ -51,14 +58,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<string, (typeof attempts)[number]>();
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 +130,11 @@ export default async function DashboardPage() {
{expiringNetworks.map((n) => (
<div key={n.network}>
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
{n.status === "expired"
? "— le jeton a expiré, les publications échoueront."
: "— le jeton expire dans moins de 7 jours."}{" "}
{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 →
</div>
))}
@@ -101,7 +143,7 @@ export default async function DashboardPage() {
<div className="grid grid-4" style={{ marginBottom: 26 }}>
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
<StatCard label="Promotions en ligne" value={livePromos} hint="visibles sur le site" />
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
<StatCard
+26
View File
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Référencement & mesure d&apos;audience</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field
label="Identifiant Google Analytics (GA4)"
name="google_analytics_id"
value={settings.google_analytics_id}
placeholder="G-XXXXXXXXXX"
/>
<Field
label="Jeton Search Console (balise HTML)"
name="google_site_verification"
value={settings.google_site_verification}
placeholder="Valeur de l'attribut content"
/>
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Laissez vide pour ne rien charger : sans identifiant, aucune balise Google n&apos;est
posée sur le site. La mesure d&apos;audience ne couvre que les pages publiques — ni le
backoffice, ni l&apos;espace adhérent. Le plan du site est déjà publié sur{" "}
<code>/sitemap.xml</code> et annoncé dans <code>/robots.txt</code> : il suffit de le
déclarer dans Search Console. Pensez à mentionner Google Analytics dans la page
Confidentialité avant de l&apos;activer.
</div>
</section>
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
</form>
);
+72 -6
View File
@@ -7,7 +7,10 @@ import { can } from "@/lib/rbac";
import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { formatValidity, visibilityNote } from "@/lib/promo-validity";
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
export const dynamic = "force-dynamic";
@@ -20,6 +23,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
function fmtDate(d: Date) {
@@ -41,23 +45,30 @@ export default async function PromotionsPage() {
text: promotions.text,
category: promotions.category,
status: promotions.status,
publishAt: promotions.publishAt,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
suspendedBy: promotions.suspendedBy,
suspendedAt: promotions.suspendedAt,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
memberName: members.name,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(inArray(promotions.status, ["pending", "live", "suspended"]))
.where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
.orderBy(desc(promotions.createdAt));
const pending = rows.filter((r) => r.status === "pending");
const live = rows.filter((r) => r.status === "live");
const suspended = rows.filter((r) => r.status === "suspended");
// En attente d'abord, puis en ligne, puis suspendues.
const ordered = [...pending, ...live, ...suspended];
const scheduled = rows.filter((r) => r.status === "scheduled");
// En attente d'abord, puis les programmées (elles attendent une décision de
// calendrier), puis les offres en ligne et enfin les suspendues.
const ordered = [...pending, ...scheduled, ...live, ...suspended];
const shares = ordered.length
? await db
@@ -90,6 +101,9 @@ export default async function PromotionsPage() {
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
En attente · {pending.length}
</span>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
Programmées · {scheduled.length}
</span>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
En ligne · {live.length}
</span>
@@ -115,6 +129,7 @@ export default async function PromotionsPage() {
{ordered.map((p) => {
const isPending = p.status === "pending";
const isSuspended = p.status === "suspended";
const isScheduled = p.status === "scheduled";
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
const chosenNetworks = networks.filter((n) =>
n === "facebook" ? p.shareFacebook : p.shareLinkedin
@@ -169,13 +184,34 @@ export default async function PromotionsPage() {
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 15.5, margin: "0 0 4px", color: "#26201a" }}>
{p.title}
</h3>
<div style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600, marginBottom: 8 }}>
{p.memberName}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={24} />
<span style={{ fontSize: 12.5, color: "#9a6638", fontWeight: 600 }}>{p.memberName}</span>
</div>
<p style={{ margin: "0 0 12px", fontSize: 12.5, color: "#8c8068", lineHeight: 1.45, flex: 1 }}>
{p.text}
</p>
{/* Le modérateur voit la période de validité avant de valider :
c'est elle qui partira dans le message réseaux sociaux. */}
{formatValidity(p) && (
<div style={{ fontSize: 12, color: "#9a8d72", fontWeight: 600, margin: "0 0 12px" }}>
{formatValidity(p)}
</div>
)}
{p.status === "live" && visibilityNote(p) && (
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
{visibilityNote(p)}
</div>
)}
{isScheduled && (
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
{scheduleLabel(p.publishAt)}. Rien n&apos;est encore visible ni diffusé.
</div>
)}
{isSuspended && (
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
@@ -210,10 +246,30 @@ export default async function PromotionsPage() {
))}
</div>
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
Publié dès la validation. Ce choix n&apos;est plus modifiable ensuite.
Diffusé en même temps que la mise en ligne : tout de suite si le
champ Publication est vide, à la date indiquée sinon. Ce choix
n&apos;est plus modifiable ensuite.
</div>
</div>
)}
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
la promotion part immédiatement. */}
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
Publication
</label>
<input
type="datetime-local"
name="publishAt"
defaultValue={toScheduleInput(p.publishAt)}
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
/>
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
{p.publishAt
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
</div>
</div>
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
Valider
</button>
@@ -230,6 +286,16 @@ export default async function PromotionsPage() {
</div>
)}
{isScheduled && (
<form action={moderatePromo} style={{ marginBottom: 8 }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value="publish-now" />
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
Publier maintenant
</button>
</form>
)}
{!isPending && (
<div style={{ display: "flex", gap: 8 }}>
<form action={moderatePromo} style={{ flex: 1 }}>
+19 -10
View File
@@ -8,7 +8,10 @@ import { meetingRegistrations, meetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { MeetingEmailComposer } from "@/components/MeetingEmailComposer";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
import { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { audienceOptions } from "@/lib/mail-recipients";
import { isMailConfigured } from "@/lib/mail-accounts";
import { siteUrl } from "@/lib/social-accounts";
import { createMeeting, deleteMeeting, updateMeeting } from "../actions";
export const dynamic = "force-dynamic";
@@ -23,7 +26,8 @@ export default async function RencontresAdminPage() {
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const [rows, settings] = await Promise.all([
const canSend = can(session?.user.role, "manageEmails");
const [rows, settings, choices, mailReady, base] = await Promise.all([
db
.select({
id: meetings.id,
@@ -41,14 +45,11 @@ export default async function RencontresAdminPage() {
.groupBy(meetings.id)
.orderBy(desc(meetings.startsAt)),
getSiteSettings(),
audienceOptions(),
isMailConfigured(),
siteUrl(),
]);
const emailBrand = {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
const brand = emailBrand(settings);
return (
<div style={{ display: "grid", gap: 28 }}>
@@ -139,7 +140,15 @@ export default async function RencontresAdminPage() {
capacity: meeting.capacity,
registered: Number(meeting.registered),
}}
brand={emailBrand}
brand={brand}
choices={{
...choices,
meeting: { id: meeting.id, title: meeting.title, count: Number(meeting.registered) },
}}
// Un modérateur gère les rencontres mais n'écrit pas à tous les
// adhérents : l'envoi reste réservé au studio d'e-mails.
mailReady={canSend && mailReady}
siteUrl={base}
/>
<form action={deleteMeeting} style={{ marginTop: 8 }}>
<input type="hidden" name="id" value={meeting.id} />
+198 -28
View File
@@ -1,10 +1,12 @@
import { headers } from "next/headers";
import { originFromHeaders } from "@/lib/site-url";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import {
checkTokenHealth,
expiryStatus,
getSocialAccounts,
listStoredTargets,
@@ -13,6 +15,7 @@ import {
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialTarget,
type TokenHealth,
} from "@/lib/social-accounts";
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
@@ -20,7 +23,14 @@ 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 }[];
/** Les deux liens utiles au moment de renouveler un jeton. */
renew: { note: string; links: { label: string; hint: string; href: string }[] };
}
> = {
facebook: {
portal: "https://developers.facebook.com/apps",
@@ -29,6 +39,29 @@ 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.",
],
renew: {
note:
"Le bouton refait tout l'échange : jeton court, puis jeton de longue durée, puis jeton de page. " +
"Vous n'avez aucun jeton à copier à la main — les deux liens ci-dessous ne servent qu'à vérifier.",
links: [
{
label: "Débogueur de jeton",
hint: "coller un jeton pour voir sa validité et sa date d'expiration",
href: "https://developers.facebook.com/tools/debug/accesstoken/",
},
{
label: "Outil de jetons d'accès",
hint: "lister les jetons de vos pages et en générer un de longue durée",
href: "https://developers.facebook.com/tools/accesstoken/",
},
],
},
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",
@@ -38,7 +71,28 @@ const HELP: Record<
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
],
caution:
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur Reconnecter environ tous les deux mois.",
"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 « Renouveler le jeton » environ tous les deux mois.",
renew: {
note:
"Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " +
"repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.",
links: [
{
label: "Jetons de votre application",
hint: "voir la durée de validité restante des jetons émis",
href: "https://www.linkedin.com/developers/tools/oauth",
},
{
label: "Documentation : durée de vie des jetons",
hint: "règles de rafraîchissement et conditions d'accès",
href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens",
},
],
},
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" },
],
},
};
@@ -61,10 +115,7 @@ export default async function ReseauxPage({
// Adresse par laquelle l'administrateur consulte cette page : proposée par
// défaut pour lui éviter de la recopier à la main.
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const detected = host ? `${protocol}://${host}` : "";
const detected = originFromHeaders(await headers());
// Calculées ici : le rendu des cartes n'est pas asynchrone.
const redirectUris = new Map<string, string>();
@@ -72,6 +123,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<string, TokenHealth | null>();
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<string, SocialTarget[]>();
let targetError: string | null = null;
@@ -124,8 +185,9 @@ export default async function ReseauxPage({
</form>
{!base && detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
enregistrez.
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
utilisée par défaut. Facebook et LinkedIn n&apos;acceptent qu&apos;une adresse de retour
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
</div>
)}
{!base && !detected && (
@@ -141,6 +203,11 @@ 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;
// Le bloc de renouvellement passe en ambre quand il y a urgence :
// jeton mort, expiré, ou sur le point de l'être.
const needsRenewal = broken || status === "expired" || status === "soon";
return (
<section key={network} style={panel}>
@@ -163,7 +230,7 @@ export default async function ReseauxPage({
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
{SOCIAL_LABELS[network]}
</h2>
<StatusChip connected={isConnected} status={status} />
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
</div>
{isConnected && (
@@ -173,15 +240,30 @@ export default async function ReseauxPage({
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
<br />
Pour changer de page, relancez une connexion.
{tokenHealth?.ok && (
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
✓ Jeton vérifié à l&apos;instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
</span>
)}
Pour changer de page ou renouveler l&apos;accès, utilisez le bouton ci-dessous.
</div>
)}
{broken && (
<Banner tone="error">
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
tant que vous n&apos;aurez pas cliqué sur « Renouveler le jeton ».
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
</div>
</Banner>
)}
{(status === "soon" || status === "expired") && isConnected && (
<Banner tone={status === "expired" ? "error" : "warn"}>
{status === "expired"
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous n'aurez pas reconnecté le compte.`
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur Reconnecter suffit à le renouveler.`}
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.`
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
</Banner>
)}
@@ -234,24 +316,91 @@ export default async function ReseauxPage({
</button>
</form>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<a
href={`/api/social/${network}/connect`}
className="font-display"
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
sans avoir à déplier l'aide de configuration. */}
{isConnected && (
<div
style={{
textDecoration: "none",
border: "none",
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
color: "#fff",
fontWeight: 700,
fontSize: 14,
padding: "12px 20px",
borderRadius: 10,
pointerEvents: account?.appId ? undefined : "none",
background: needsRenewal ? "#fbeede" : "#faf7ef",
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
borderRadius: 12,
padding: 15,
marginBottom: 16,
}}
>
{isConnected ? "Reconnecter" : "Connecter"}
</a>
<div className="field-label" style={{ marginBottom: 4 }}>
Renouveler le jeton
</div>
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.6, marginBottom: 12 }}>
{help.renew.note}
</div>
<a
href={`/api/social/${network}/connect`}
className="font-display"
style={{
display: "inline-block",
textDecoration: "none",
background: SOCIAL_BRAND[network],
color: "#fff",
fontWeight: 700,
fontSize: 13.5,
padding: "11px 18px",
borderRadius: 10,
marginBottom: 12,
}}
>
Renouveler le jeton {SOCIAL_LABELS[network]}
</a>
<div style={{ display: "grid", gap: 8 }}>
{help.renew.links.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
textDecoration: "none",
fontSize: 13,
lineHeight: 1.5,
color: "#2C6FB3",
fontWeight: 700,
}}
>
{link.label} ↗
<span style={{ color: "#8c8068", fontWeight: 500, fontSize: 12.5 }}>
{link.hint}
</span>
</a>
))}
</div>
</div>
)}
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
{/* Une fois connecté, l'action vit dans le bloc de renouvellement
ci-dessus : deux boutons identiques côte à côte n'aidaient personne. */}
{!isConnected && (
<a
href={`/api/social/${network}/connect`}
className="font-display"
style={{
textDecoration: "none",
border: "none",
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
color: "#fff",
fontWeight: 700,
fontSize: 14,
padding: "12px 20px",
borderRadius: 10,
pointerEvents: account?.appId ? undefined : "none",
}}
>
Connecter
</a>
)}
{isConnected && (
<form action={disconnectSocial}>
<input type="hidden" name="network" value={network} />
@@ -277,7 +426,28 @@ export default async function ReseauxPage({
{help.portal}
</a>
</div>
<div style={{ marginTop: 10 }}>
{help.tools && (
<div style={{ marginTop: 14 }}>
<div className="field-label">Outils pour inspecter un jeton</div>
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
{help.tools.map((tool) => (
<li key={tool.href}>
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
{tool.label}
</a>
</li>
))}
</ul>
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
Ces outils servent à contrôler ou dépanner : le bouton
<strong> Connecter</strong> fait déjà l&apos;échange complet — jeton court,
puis jeton de longue durée, puis jeton de page — vous n&apos;avez aucun jeton à
copier à la main.
</div>
</div>
)}
<div style={{ marginTop: 14 }}>
<div className="field-label">URL de redirection à déclarer</div>
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
+251
View File
@@ -850,6 +850,81 @@ img {
min-width: 0;
}
/* ---- Backend sans barre latérale (espace adhérent) ----
Un adhérent n'a que les trois onglets de son espace : une barre latérale
les répéterait. `.backend` étant une rangée flex, la variante repasse en
bloc pour que l'entête et le contenu s'empilent simplement. */
.backend--plain {
display: block;
}
/* L'entête s'aligne sur la colonne de contenu : sans cela le logo collerait
au bord de l'écran pendant que le contenu reste centré. */
.backend-header--plain {
padding: 14px max(24px, calc((100% - 1040px) / 2));
}
.backend-brand {
display: flex;
align-items: center;
gap: 11px;
margin-right: auto;
min-width: 0;
text-decoration: none;
}
.backend-brand__mark {
display: inline-flex;
flex-shrink: 0;
padding: 5px;
border-radius: 11px;
background: #fff;
border: 1px solid #e6dcc6;
}
.backend-brand__text {
display: flex;
flex-direction: column;
line-height: 1.15;
min-width: 0;
}
.backend-brand__name {
font-size: 17px;
font-weight: 800;
color: var(--ink-strong);
}
.backend-brand__sub {
font-size: 11.5px;
color: var(--muted-3);
}
/* Sans barre latérale, il n'y a plus de tiroir où loger les actions sur
téléphone : elles restent dans l'entête à toutes les largeurs. */
.backend-header--plain .backend-header__actions {
display: flex;
}
.backend-content--plain {
width: 100%;
max-width: 1040px;
margin: 0 auto;
}
@media (max-width: 1024px) {
.backend-header--plain {
padding: 11px 16px;
}
.backend-content--plain {
max-width: none;
}
}
/* Le nom de marque s'efface avant les actions : le logo suffit à situer la
page, deux boutons tronqués ne servent à rien. */
@media (max-width: 560px) {
.backend-brand__text {
display: none;
}
.backend-header--plain .backend-action {
padding: 8px 11px;
font-size: 13px;
}
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
@@ -998,6 +1073,140 @@ img {
background: #33291D;
color: #fff;
}
/* Corps d'une information : rendu par `richTextNodes`, donc uniquement des
p / ul / ol / li / strong / em / a / br. Le rythme vertical vit ici plutôt
qu'en styles en ligne, parce qu'il porte sur des balises engendrées. */
.rich-text {
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.rich-text > *:first-child {
margin-top: 0;
}
.rich-text > *:last-child {
margin-bottom: 0;
}
.rich-text p {
margin: 0 0 11px;
}
.rich-text h4 {
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
font-size: 15.5px;
font-weight: 700;
color: var(--ink-strong);
margin: 16px 0 8px;
}
.rich-text ul,
.rich-text ol {
margin: 0 0 11px;
padding-left: 21px;
}
.rich-text li {
margin-bottom: 3px;
}
.rich-text strong {
color: var(--ink-strong);
}
.rich-text a {
color: var(--blue);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Éditeur visuel des informations : ce qu'on voit est ce qui sera publié. La
zone reprend `.rich-text` pour le rythme des paragraphes et des listes, de
sorte que l'éditeur est son propre aperçu. */
.rich-editor-toolbar {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
border-radius: 10px 10px 0 0;
background: #fff;
padding: 6px 7px;
}
.rich-editor-tool {
min-width: 30px;
height: 28px;
padding: 0 9px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.rich-editor-tool:hover {
background: var(--cream-3);
color: var(--ink-strong);
}
.rich-editor-tool.is-active {
background: var(--gold);
color: #33291d;
}
.rich-editor-separator {
width: 1px;
height: 18px;
margin: 0 5px;
background: var(--border-2);
}
.rich-editor-link {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
background: var(--cream);
padding: 8px 9px;
}
.rich-editor-link .field {
flex: 1;
min-width: 180px;
background: #fff;
}
.rich-editor {
position: relative;
min-height: 190px;
border: 1px solid var(--field-border);
border-radius: 0 0 10px 10px;
background: var(--field-bg);
padding: 13px 15px;
overflow-y: auto;
cursor: text;
}
.rich-editor:focus {
outline: 2px solid rgba(44, 111, 179, 0.35);
outline-offset: 1px;
}
/* Le texte d'invite s'efface dès la première frappe : `:empty` ne suffit pas,
le navigateur laisse un paragraphe vide dans la zone. Il est posé en
superposition et non à la place du paragraphe : masquer celui-ci ferait
tomber le curseur à côté, et le navigateur écrirait du texte nu hors de tout
bloc. */
.rich-editor[data-empty="true"]::before {
content: attr(data-placeholder);
position: absolute;
left: 15px;
right: 15px;
top: 13px;
color: var(--muted-4);
pointer-events: none;
}
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
que la colonne de droite n'a plus la place d'être lisible. */
@media (max-width: 1100px) {
.informations-split {
grid-template-columns: minmax(0, 1fr) !important;
}
}
/* Suggestions de tags : pastilles cliquables sous le champ. */
.tag-suggestions {
display: flex;
@@ -1403,6 +1612,48 @@ img {
color: var(--muted-4);
font-size: 13px;
}
/* Référent de l'adhérent : rendu seulement quand le visiteur est connecté —
le HTML public ne contient jamais ce bloc, rien n'est masqué en CSS. */
.contact-referent {
margin-top: 12px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 12px;
background: #fff;
}
.contact-referent__label {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted-2);
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.contact-referent__name {
margin-top: 7px;
color: var(--ink-strong);
font-size: 14px;
font-weight: 700;
}
.contact-referent__phone {
display: inline-block;
margin-top: 3px;
color: var(--blue);
font-size: 13.5px;
font-weight: 700;
text-decoration: none;
}
.contact-referent__phone:hover {
text-decoration: underline;
}
.contact-referent__hint {
margin-top: 7px;
color: var(--muted-4);
font-size: 11.5px;
line-height: 1.45;
}
/* ---- Éditeur hebdomadaire des horaires ---- */
.hours-editor {
+11 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Analytics } from "@/components/Analytics";
import { JsonLd } from "@/components/JsonLd";
import {
INDEX_FOLLOW,
@@ -11,6 +12,7 @@ import {
organizationJsonLd,
webSiteJsonLd,
} from "@/lib/seo";
import { normalizeSiteVerification } from "@/lib/analytics";
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import "./globals.css";
@@ -33,8 +35,14 @@ export const viewport: Viewport = {
* relatives (canonique, Open Graph, image de partage).
*/
export async function generateMetadata(): Promise<Metadata> {
const [base, settings] = await Promise.all([
metadataBase(),
getSiteSettings().catch(() => null),
]);
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
const google = normalizeSiteVerification(settings?.google_site_verification);
return {
metadataBase: await metadataBase(),
metadataBase: base,
title: {
default: SITE_TITLE,
template: `%s · ${SITE_NAME}`,
@@ -63,6 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
description: SITE_DESCRIPTION,
},
robots: INDEX_FOLLOW,
verification: google ? { google } : undefined,
icons: {
icon: [{ url: "/assets/logo.png", type: "image/png" }],
apple: [{ url: "/assets/logo.png", type: "image/png" }],
@@ -108,6 +117,7 @@ export default async function RootLayout({
<body>
{children}
<JsonLd data={structuredData} />
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
</body>
</html>
);
+10 -103
View File
@@ -7,11 +7,11 @@ import { SiteFooter } from "@/components/SiteFooter";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { MembersCarousel } from "@/components/MembersCarousel";
import { StarField } from "@/components/StarField";
import { PromoImage } from "@/components/PromoImage";
import { PromoCard } from "@/components/PromoCard";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { getActiveMemberCount, getLivePromotions, getRotatingActiveMembers } from "@/lib/queries";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import { SITE_DESCRIPTION, SITE_TITLE, memberPath, pageMetadata } from "@/lib/seo";
import { SITE_DESCRIPTION, SITE_TITLE, pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -26,14 +26,6 @@ export const metadata: Metadata = {
title: { absolute: SITE_TITLE },
};
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
function badgeColor(badge: string | null) {
if (!badge) return "#1f8a5b";
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
}
export default async function AccueilPage() {
const [promos, rotatingMembers, settings, memberCount] = await Promise.all([
getLivePromotions(6),
@@ -66,7 +58,7 @@ export default async function AccueilPage() {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/assets/logo.png"
alt="Plein R — Association des commerçants et entreprises du Bassin de Pompey"
alt="Plein R — Association des commerçants et entreprises de service du Bassin de Pompey"
className="floatlogo"
style={{
width: "100%",
@@ -115,10 +107,10 @@ export default async function AccueilPage() {
className="font-display"
style={{ fontWeight: 600, fontSize: 17, color: "#9a6638", margin: "0 0 16px" }}
>
Association des commerçants &amp; entreprises du Bassin de Pompey
Association des commerçants &amp; entreprises de service du Bassin de Pompey
</div>
<p style={{ fontSize: 17, lineHeight: 1.6, color: "#6c6150", margin: "0 0 24px", maxWidth: 520 }}>
Boulangers, artisans, restaurateurs, entreprises… retrouvez tous nos adhérents, profitez de
Boulangers, artisans, restaurateurs, entreprises de service… retrouvez tous nos adhérents, profitez de
leurs bons plans et faites vivre le commerce de proximité.
</p>
<form
@@ -226,99 +218,14 @@ export default async function AccueilPage() {
Offres, réductions et nouveautés publiées par les commerçants du réseau.
</p>
</div>
<a href="#promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
<Link href="/promotions" className="link-arrow" style={{ textDecoration: "none", color: "#9a6638", fontWeight: 700, fontSize: 14.5, whiteSpace: "nowrap" }}>
Toutes les promos →
</a>
</Link>
</div>
<Reveal className="grid grid-3 reveal-stagger" style={{ marginTop: 26 }}>
{promos.map((p) => (
<Link
key={p.id}
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
color: "inherit",
background: "#fff",
border: "1px solid #ece3d0",
borderRadius: 18,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
background: STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{!p.imageUrl && (
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
photo de l&apos;offre
</span>
)}
{p.badge && (
<span
className="font-display"
style={{
position: "absolute",
top: 0,
right: 0,
background: badgeColor(p.badge),
color: "#fff",
fontWeight: 800,
fontSize: 18,
padding: "8px 16px",
borderRadius: "0 0 0 16px",
}}
>
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
{p.title}
</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{p.text}
</p>
<div
style={{
borderTop: "1px solid #f0e8d6",
paddingTop: 12,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
<div style={{ fontSize: 11.5, color: "#a99c82" }}>{p.validUntil}</div>
</div>
</div>
</Link>
<PromoCard key={p.id} promo={p} />
))}
</Reveal>
</section>
@@ -452,8 +359,8 @@ export default async function AccueilPage() {
</h2>
<p style={{ margin: "0 auto 22px", fontSize: 15.5, color: "#f4e6d3", maxWidth: 480 }}>
{memberCount > 0
? `Rejoignez les ${memberCount} commerçants et entreprises adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises qui font réseau, se rencontrent et réussissent ensemble."}
? `Rejoignez les ${memberCount} commerçants et entreprises de service adhérents qui font réseau, se rencontrent et réussissent ensemble.`
: "Rejoignez les commerçants et entreprises de service qui font réseau, se rencontrent et réussissent ensemble."}
</p>
<MembershipModalButton
label="Devenir adhérent"
+143
View File
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkle } from "@/components/Sparkle";
import { SiteHeader } from "@/components/SiteHeader";
import { SiteFooter } from "@/components/SiteFooter";
import { MembershipModalButton } from "@/components/MembershipModalButton";
import { PromoCard } from "@/components/PromoCard";
import { JsonLd } from "@/components/JsonLd";
import { getLivePromotions } from "@/lib/queries";
import { breadcrumbJsonLd, memberListJsonLd, pageMetadata } from "@/lib/seo";
import { publicBaseUrl } from "@/lib/seo-server";
export const dynamic = "force-dynamic";
export const metadata: Metadata = pageMetadata({
title: "Bons plans et promotions du Bassin de Pompey",
description:
"Toutes les offres en cours chez les commerçants, artisans et entreprises adhérents Plein R : " +
"réductions, nouveautés et bons plans à Pompey, Frouard, Liverdun, Champigneulles…",
path: "/promotions",
});
export default async function PromotionsPage() {
// `null` : toutes les offres en cours, là où l'accueil n'en montre que six.
const [promos, baseUrl] = await Promise.all([getLivePromotions(null), publicBaseUrl()]);
// Une offre pointe vers la fiche de son commerçant : la liste structurée
// renvoie donc vers les adhérents, pas vers des URLs de promotion.
const listed = promos
.filter((p) => p.memberId != null)
.map((p) => ({ id: p.memberId as number, name: p.memberName ?? "", city: p.memberCity }));
return (
<div style={{ background: "#F6F2E8", minHeight: "100vh", fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<SiteHeader active="promotions" logo />
<JsonLd
data={[
breadcrumbJsonLd(baseUrl, [
{ name: "Accueil", path: "/" },
{ name: "Promotions", path: "/promotions" },
]),
memberListJsonLd(baseUrl, "Promotions en cours chez les adhérents Plein R", listed),
]}
/>
<main className="container" style={{ paddingBottom: 56 }}>
{/* title block */}
<section style={{ position: "relative", padding: "14px 0 22px", overflow: "hidden" }}>
<Sparkle color="#E0A63C" size={18} style={{ top: 22, right: 30 }} duration={3.2} />
<Sparkle color="#6FB0C6" size={12} style={{ top: 60, right: 90 }} duration={2.7} delay={0.5} />
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
background: "#fff",
border: "1px solid #e6dcc6",
color: "#9a6638",
fontSize: 12,
letterSpacing: "0.12em",
textTransform: "uppercase",
fontWeight: 800,
padding: "6px 13px",
borderRadius: 999,
marginBottom: 14,
}}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#E0A63C" }} />
{promos.length} offre{promos.length > 1 ? "s" : ""} en cours
</div>
<h1 className="font-display" style={{ fontWeight: 800, fontSize: "clamp(26px, 6vw, 40px)", letterSpacing: "-0.02em", margin: "0 0 8px", color: "#26201a" }}>
Les bons plans de nos adhérents
</h1>
<p style={{ margin: 0, fontSize: 16.5, color: "#6c6150", maxWidth: 560 }}>
Toutes les offres en cours chez les commerçants, artisans et entreprises du réseau.
Chaque promotion renvoie vers la fiche de son commerce.
</p>
</section>
{promos.length === 0 ? (
<section
style={{
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 18,
padding: "38px 24px",
textAlign: "center",
color: "#8c8068",
fontSize: 15,
lineHeight: 1.7,
}}
>
Aucune promotion en cours pour le moment.
<br />
<Link href="/annuaire" style={{ color: "#9a6638", fontWeight: 700 }}>
Parcourir l&apos;annuaire des adhérents →
</Link>
</section>
) : (
<div className="grid grid-3" style={{ gap: 20 }}>
{promos.map((p) => (
<PromoCard key={p.id} promo={p} />
))}
</div>
)}
{/* join CTA */}
<section
style={{
marginTop: 34,
position: "relative",
background: "linear-gradient(120deg,#13324F,#1d4a72)",
borderRadius: 20,
padding: "34px 40px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 28,
overflow: "hidden",
flexWrap: "wrap",
}}
>
<Sparkle color="#E0A63C" size={18} style={{ top: 20, right: 40 }} duration={3.2} />
<div>
<div style={{ fontSize: 12.5, letterSpacing: "0.12em", textTransform: "uppercase", color: "#9fc6e6", fontWeight: 700, marginBottom: 8 }}>
Vous êtes commerçant du Bassin de Pompey ?
</div>
<h2 className="font-display" style={{ fontWeight: 800, fontSize: 26, margin: 0, color: "#fff" }}>
Publiez vos offres sur Plein R
</h2>
</div>
<MembershipModalButton
label="Adhérer à l'association"
className="font-display"
style={{ border: "none", background: "#E0A63C", color: "#33291D", fontWeight: 700, fontSize: 15.5, padding: "14px 26px", borderRadius: 12, whiteSpace: "nowrap" }}
/>
</section>
</main>
<SiteFooter />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { headers } from "next/headers";
import { gtagConfigScript, gtagSrc, isTrackedPath, normalizeMeasurementId } from "@/lib/analytics";
/**
* Balises Google Analytics 4, posées uniquement si l'identifiant de mesure est
* renseigné dans Backend › Paramètres et si la page est publique.
*
* Les deux scripts portent le nonce de la CSP (`src/middleware.ts`) ; le
* chemin est lu dans l'en-tête `x-pathname` que le middleware recopie, un
* layout n'ayant pas accès à l'URL demandée.
*/
export async function Analytics({ measurementId }: { measurementId: string }) {
const id = normalizeMeasurementId(measurementId);
if (!id) return null;
const requestHeaders = await headers();
if (!isTrackedPath(requestHeaders.get("x-pathname") ?? "/")) return null;
const nonce = requestHeaders.get("x-nonce") ?? undefined;
return (
<>
<script async src={gtagSrc(id)} nonce={nonce} />
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: gtagConfigScript(id) }} />
</>
);
}
+14
View File
@@ -13,7 +13,9 @@ export type BackendIconName =
| "meetings"
| "registrations"
| "gallery"
| "news"
| "emails"
| "mailbox"
| "social"
| "categories"
| "settings"
@@ -70,6 +72,18 @@ const PATHS: Record<BackendIconName, ReactNode> = {
<path d="m4 17 4.8-4.4a1.8 1.8 0 0 1 2.4 0L16 17M14 14.4l1.6-1.5a1.8 1.8 0 0 1 2.4 0L21 15.6" />
</>
),
news: (
<>
<path d="M17.5 4.5H4.2A1.2 1.2 0 0 0 3 5.7v12a1.8 1.8 0 0 0 1.8 1.8h13.4a2.8 2.8 0 0 0 2.8-2.8V9.2h-3.5V4.5Z" />
<path d="M6.2 8.3h8.1M6.2 11.6h8.1M6.2 14.9h5.2" />
</>
),
mailbox: (
<>
<rect x="2.6" y="5" width="18.8" height="14" rx="2.4" />
<path d="m3.4 7.2 7.4 5.3a2 2 0 0 0 2.4 0l7.4-5.3" />
</>
),
emails: (
<>
<path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.3 21l-3.5-7.8L3 9.7l17.5-6.2Z" />
+80 -7
View File
@@ -3,9 +3,32 @@
import { useEffect, useMemo, useState } from "react";
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates";
import {
MailRecipientPicker,
audienceCount,
audienceFrom,
type AudienceState,
type RecipientChoices,
} from "@/components/MailRecipientPicker";
import { sendSelfTest, sendStudioEmail } from "@/app/backend/actions";
export function EmailCreator({ brand }: { brand: EmailBrand }) {
const [baseUrl, setBaseUrl] = useState("");
export function EmailCreator({
brand,
choices,
mailReady,
siteUrl,
}: {
brand: EmailBrand;
choices: RecipientChoices;
/** Faux si aucune boîte n'expédie : l'envoi réel est alors désactivé. */
mailReady: boolean;
/** Adresse publique enregistrée, utilisée par l'aperçu comme par l'envoi. */
siteUrl: string;
}) {
const [baseUrl, setBaseUrl] = useState(siteUrl);
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
const [sending, setSending] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const [form, setForm] = useState<GeneralEmailContent>({
subject: `Actualités de ${brand.associationName}`,
kicker: brand.associationName,
@@ -19,15 +42,35 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
const [error, setError] = useState("");
useEffect(() => {
setBaseUrl(window.location.origin);
setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` }));
}, []);
// L'adresse enregistrée prime : c'est elle que verront les destinataires,
// et non celle par laquelle l'administrateur est arrivé.
const origin = siteUrl || window.location.origin;
setBaseUrl(origin);
setForm((current) => ({ ...current, buttonUrl: `${origin}/association` }));
}, [siteUrl]);
const email = useMemo(
() => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null),
[baseUrl, brand, form],
);
const recipientCount = audienceCount(audience, choices);
async function deliver(task: () => Promise<{ queued: number; audience: string } | { error: string }>) {
setSending(true);
setError("");
setSent(null);
try {
const result = await task();
if ("error" in result) setError(result.error);
else setSent(`✓ ${result.queued} message(s) mis en file pour ${result.audience}.`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
} finally {
setSending(false);
}
}
function change(name: keyof GeneralEmailContent, value: string) {
setForm((current) => ({ ...current, [name]: value }));
}
@@ -69,8 +112,37 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
<EmailArea label="Signature" value={form.signature} rows={3} onChange={(value) => change("signature", value)} />
</div>
<div style={{ marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
<div style={{ display: "grid", gap: 9, marginTop: 14 }}>
<button
type="button"
className="email-primary-button"
disabled={!email || sending || !mailReady || recipientCount === 0}
onClick={() => email && deliver(() => sendStudioEmail({ content: form, audience: audienceFrom(audience) }))}
>
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} adhérent${recipientCount > 1 ? "s" : ""}`}
</button>
<button
type="button"
className="email-secondary-button"
disabled={!email || sending || !mailReady}
onClick={() => email && deliver(() => sendSelfTest({ subject: email.subject, html: email.html, text: form.body }))}
>
M&apos;envoyer un test
</button>
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
{!mailReady && (
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Aucune boîte mail n&apos;expédie pour l&apos;instant : branchez-la dans Configuration › Boîte mail.
Les boutons ci-dessous restent utilisables.
</p>
)}
</div>
</div>
{error && <div style={{ marginTop: 13, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
<div style={{ display: "grid", gap: 9, marginTop: 20 }}>
<div style={{ display: "grid", gap: 9, marginTop: 20, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html))}>
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger le brouillon Outlook"}
</button>
@@ -87,7 +159,8 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {
</div>
</div>
<p style={{ margin: "13px 0 0", color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires.
Ces trois boutons restent là pour les cas particuliers : le .eml donne un brouillon Outlook
à compléter à la main.
</p>
</section>
+92
View File
@@ -0,0 +1,92 @@
import { richTextNodes } from "@/lib/rich-text";
/**
* Une information de l'association. Rendu unique, partagé par le fil de
* l'espace adhérent et l'aperçu du back-office : une seule carte à maintenir.
*
* Le corps passe par `richTextNodes()` : des éléments React, jamais du HTML
* injecté.
*/
export type InformationCardProps = {
title: string;
body: string;
imageUrl?: string | null;
publishedAt?: Date | null;
authorName?: string | null;
pinned?: boolean;
unread?: boolean;
/** Mention d'état supplémentaire (« Brouillon », « Lue par 12 / 40 »…). */
note?: string | null;
};
function formatDay(value: Date) {
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
export function InformationCard({
title,
body,
imageUrl,
publishedAt,
authorName,
pinned,
unread,
note,
}: InformationCardProps) {
// Une seule accentuation à la fois : l'épinglage prime sur la nouveauté,
// sinon les deux liserés se disputeraient le même bord.
const accent = pinned ? "#E0A63C" : unread ? "#1F8A5B" : null;
return (
<article
style={{
background: unread || pinned ? "#fff" : "#fdfcf8",
border: `1px solid ${pinned ? "#e5cf9a" : "#e6dcc6"}`,
borderRadius: 14,
padding: "18px 20px",
marginBottom: 14,
boxShadow: accent ? `inset 4px 0 0 ${accent}` : undefined,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginBottom: 9 }}>
{pinned && (
<span style={{ background: "#fbeede", color: "#9a6638", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
Épinglée
</span>
)}
{unread && (
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
Nouveau
</span>
)}
<span style={{ color: "#9a8d72", fontSize: 12.5 }}>
{publishedAt ? `Publié le ${formatDay(publishedAt)}` : "Non publiée"}
{authorName ? ` par ${authorName}` : ""}
</span>
{note && (
<span style={{ background: "#f1efe7", color: "#a99c82", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
{note}
</span>
)}
</div>
<h3 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 18, color: "#26201a" }}>
{title}
</h3>
{imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt=""
style={{ display: "block", width: "100%", maxHeight: 280, objectFit: "cover", borderRadius: 10, border: "1px solid #e6dcc6", margin: "13px 0 4px" }}
/>
)}
<div className="rich-text" style={{ marginTop: 9 }}>
{richTextNodes(body)}
</div>
</article>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import type { Audience } from "@/lib/mail-recipients";
/**
* Choix des destinataires, partagé par le studio de composition et le
* composeur d'invitations.
*
* Les effectifs sont calculés côté serveur et passés en props : le navigateur
* n'a jamais besoin de la liste des adresses pour afficher un compteur.
*/
export type RecipientChoices = {
all: number;
categories: { value: string; label: string; count: number }[];
meeting?: { id: number; title: string; count: number } | null;
};
export type AudienceState = { kind: Audience["kind"]; categoryId: string };
export function audienceFrom(state: AudienceState, meetingId?: number | null): Audience {
if (state.kind === "category" && state.categoryId) {
return { kind: "category", categoryId: Number(state.categoryId) };
}
if (state.kind === "meeting" && meetingId) return { kind: "meeting", meetingId };
return { kind: "all" };
}
export function audienceCount(state: AudienceState, choices: RecipientChoices): number {
if (state.kind === "category") {
return choices.categories.find((option) => option.value === state.categoryId)?.count ?? 0;
}
if (state.kind === "meeting") return choices.meeting?.count ?? 0;
return choices.all;
}
export function MailRecipientPicker({
choices,
value,
onChange,
}: {
choices: RecipientChoices;
value: AudienceState;
onChange: (next: AudienceState) => void;
}) {
const options: { kind: Audience["kind"]; label: string; hint: string; count: number }[] = [
{ kind: "all", label: "Tous les adhérents actifs", hint: "", count: choices.all },
];
if (choices.categories.length > 0) {
options.push({ kind: "category", label: "Une catégorie de métier", hint: "", count: 0 });
}
if (choices.meeting) {
options.push({
kind: "meeting",
label: "Les inscrits à cette rencontre",
hint: choices.meeting.title,
count: choices.meeting.count,
});
}
return (
<fieldset style={{ border: "none", margin: 0, padding: 0 }}>
<legend className="field-label" style={{ marginBottom: 8 }}>Destinataires</legend>
<div style={{ display: "grid", gap: 9 }}>
{options.map((option) => (
<div key={option.kind}>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", cursor: "pointer", fontSize: 13.5, color: "#3c3322" }}>
<input
type="radio"
name="audience"
checked={value.kind === option.kind}
onChange={() => onChange({ ...value, kind: option.kind })}
style={{ marginTop: 3 }}
/>
<span>
<strong style={{ color: "#26201a" }}>{option.label}</strong>
{option.kind !== "category" && (
<span style={{ color: "#9a8d72" }}> · {option.count} adresse{option.count > 1 ? "s" : ""}</span>
)}
{option.hint && <span style={{ display: "block", color: "#9a8d72", fontSize: 12.5 }}>{option.hint}</span>}
</span>
</label>
{option.kind === "category" && value.kind === "category" && (
<select
className="field"
value={value.categoryId}
onChange={(event) => onChange({ ...value, categoryId: event.target.value })}
style={{ marginTop: 8, marginLeft: 27, width: "calc(100% - 27px)" }}
>
<option value="">Choisir un métier…</option>
{choices.categories.map((category) => (
<option key={category.value} value={category.value}>
{category.label} ({category.count})
</option>
))}
</select>
)}
</div>
))}
</div>
</fieldset>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { markInformationsRead } from "@/app/backend/actions";
/**
* Marque les informations affichées comme lues, une fois la page rendue.
*
* Le marquage ne peut pas se faire pendant le rendu de la page : le compteur
* de la barre latérale est calculé par le gabarit, un composant serveur
* distinct dont l'ordre de rendu n'est pas garanti — la pastille de l'onglet
* et celle du menu auraient pu se contredire sur la même page. Ici les deux
* retombent ensemble au rafraîchissement.
*/
export function MarkInformationsRead({ ids }: { ids: number[] }) {
const router = useRouter();
const handled = useRef("");
const key = ids.join(",");
useEffect(() => {
if (!key || handled.current === key) return;
handled.current = key;
void markInformationsRead(key.split(",").map(Number)).then(() => router.refresh());
}, [key, router]);
return null;
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Modal } from "@/components/Modal";
/**
* Carte d'une rencontre à venir, de hauteur identique à ses voisines.
*
* Le texte d'une rencontre est libre : une description longue étirait sa carte,
* donc toute la rangée de la grille, et laissait les autres avec un grand vide
* sous leur bouton. Titre et description occupent désormais une hauteur figée
* et sont tronqués en fin de ligne ; le texte complet reste lisible dans une
* fenêtre ouverte par « Voir le détail ».
*
* Les dates arrivent **déjà formatées** depuis la page serveur : les formater
* ici les rendrait dans le fuseau du visiteur, différent de celui du serveur,
* et provoquerait une erreur d'hydratation.
*/
export type UpcomingMeetingCardData = {
id: number;
title: string;
description: string | null;
location: string | null;
imageUrl: string | null;
/** « vendredi 3 octobre 2026 ». */
dateLabel: string;
/** « 18:30 ». */
timeLabel: string;
capacity: number;
registered: number;
participantsPerAccount: number;
isRegistered: boolean;
/** Libellé du bouton d'inscription, décidé côté serveur (session, places). */
ctaLabel: string;
};
const STRIPE =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
/**
* Hauteurs figées des deux blocs de texte, même principe que `MemberCard` :
* elles alignent les cartes quel que soit le texte saisi par le staff.
*/
const TITLE_FONT = 21;
const TITLE_LINE = 1.25;
const TITLE_LINES = 2;
const DESC_FONT = 13.5;
const DESC_LINE = 1.6;
const DESC_LINES = 3;
const clampLines = (lines: number) =>
({
display: "-webkit-box",
WebkitLineClamp: lines,
WebkitBoxOrient: "vertical" as const,
overflow: "hidden",
}) as const;
export function MeetingCard({ meeting }: { meeting: UpcomingMeetingCardData }) {
const [open, setOpen] = useState(false);
const remaining = Math.max(0, meeting.capacity - meeting.registered);
const full = remaining <= 0 && !meeting.isRegistered;
const perAccount = `Jusqu'à ${meeting.participantsPerAccount} participant${meeting.participantsPerAccount > 1 ? "s" : ""} par compte`;
const cover = meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : STRIPE;
return (
<article
id={`rencontre-${meeting.id}`}
className="lift"
style={{
background: "#fff",
border: meeting.isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6",
borderRadius: 12,
overflow: "hidden",
scrollMarginTop: 100,
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ position: "relative", aspectRatio: "16 / 10", background: cover, flexShrink: 0 }}>
{meeting.isRegistered && (
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
✓ Inscrit
</span>
)}
</div>
<div style={{ padding: 18, display: "flex", flexDirection: "column", flex: 1 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
{meeting.dateLabel} · {meeting.timeLabel}
</div>
<h3
className="font-display"
style={{
margin: "7px 0 7px",
color: "#26201a",
fontSize: TITLE_FONT,
lineHeight: TITLE_LINE,
height: TITLE_FONT * TITLE_LINE * TITLE_LINES,
...clampLines(TITLE_LINES),
}}
>
{meeting.title}
</h3>
{/* Le lieu est facultatif : sa ligne est réservée dans tous les cas,
sinon une carte sans lieu remonterait tout son bas. */}
<div
style={{
fontSize: 13,
color: "#6c6150",
fontWeight: 700,
height: 13 * 1.4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{meeting.location}
</div>
<p
style={{
color: "#8c8068",
fontSize: DESC_FONT,
lineHeight: DESC_LINE,
height: DESC_FONT * DESC_LINE * DESC_LINES,
margin: "8px 0 0",
...clampLines(DESC_LINES),
}}
>
{meeting.description}
</p>
{/* Espaceur : colle le bas de carte au bas du cadre, quelle que soit la
hauteur imposée par la plus haute carte de la rangée. */}
<div style={{ flex: 1, minHeight: 12 }} />
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 8 }}>
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s)</span>
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>{perAccount}</div>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => setOpen(true)}
style={{ flex: "0 0 auto", border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 14px", fontWeight: 800, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" }}
>
Voir le détail
</button>
<Link
href={`/inscription/${meeting.id}`}
style={{ flex: 1, textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
>
{meeting.ctaLabel}
</Link>
</div>
</div>
<Modal open={open} onClose={() => setOpen(false)} title={meeting.title} maxWidth={560}>
{meeting.imageUrl && (
<div style={{ aspectRatio: "16 / 10", background: cover, borderRadius: 12, marginBottom: 16 }} />
)}
<div style={{ color: "#9a6638", fontSize: 13, fontWeight: 800 }}>
{meeting.dateLabel} · {meeting.timeLabel}
</div>
{meeting.location && (
<div style={{ fontSize: 13.5, color: "#6c6150", fontWeight: 700, marginTop: 4 }}>{meeting.location}</div>
)}
{meeting.description && (
<p style={{ color: "#6c6150", fontSize: 14, lineHeight: 1.7, whiteSpace: "pre-line", margin: "14px 0 0" }}>
{meeting.description}
</p>
)}
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: "12px 14px", margin: "18px 0 6px", fontSize: 13, fontWeight: 800, color: "#6c6150" }}>
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
<span>{remaining} place(s) restante(s)</span>
</div>
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 16 }}>{perAccount}</div>
<Link
href={`/inscription/${meeting.id}`}
style={{ display: "block", textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 12, fontWeight: 800 }}
>
{meeting.ctaLabel}
</Link>
</Modal>
</article>
);
}
+66 -4
View File
@@ -4,20 +4,64 @@ import { useMemo, useState } from "react";
import { Modal } from "@/components/Modal";
import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client";
import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates";
import {
MailRecipientPicker,
audienceCount,
audienceFrom,
type AudienceState,
type RecipientChoices,
} from "@/components/MailRecipientPicker";
import { sendMeetingInvitations } from "@/app/backend/actions";
export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) {
export function MeetingEmailComposer({
meeting,
brand,
choices,
mailReady,
siteUrl,
}: {
meeting: MeetingEmailData;
brand: EmailBrand;
choices: RecipientChoices;
mailReady: boolean;
siteUrl: string;
}) {
const [open, setOpen] = useState(false);
const [baseUrl, setBaseUrl] = useState("");
const [baseUrl, setBaseUrl] = useState(siteUrl);
const [texts, setTexts] = useState<MeetingEmailTexts>(() => defaultMeetingEmailTexts(brand));
const [flash, setFlash] = useState("");
const [error, setError] = useState("");
const [audience, setAudience] = useState<AudienceState>({ kind: "all", categoryId: "" });
const [sending, setSending] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]);
const recipientCount = audienceCount(audience, choices);
function show() {
setBaseUrl(window.location.origin);
// L'adresse enregistrée prime : c'est celle que verront les destinataires.
setBaseUrl(siteUrl || window.location.origin);
setOpen(true);
}
async function deliver() {
setSending(true);
setError("");
setSent(null);
try {
const result = await sendMeetingInvitations({
meetingId: meeting.id,
texts,
audience: audienceFrom(audience, meeting.id),
});
if ("error" in result) setError(result.error);
else setSent(`✓ ${result.queued} invitation(s) mises en file pour ${result.audience}.`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Envoi impossible.");
} finally {
setSending(false);
}
}
function change(name: keyof MeetingEmailTexts, value: string) {
setTexts((current) => ({ ...current, [name]: value }));
}
@@ -46,8 +90,26 @@ export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmail
<p style={{ margin: 0, color: "#8c8068", fontSize: 12, lineHeight: 1.55 }}>
Date, lieu, description, places et lien d'inscription repris automatiquement.
</p>
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 13 }}>
<MailRecipientPicker choices={choices} value={audience} onChange={setAudience} />
</div>
{error && <div style={{ color: "#d8472b", fontSize: 13, fontWeight: 700 }}>{error}</div>}
<button type="button" className="email-primary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
{sent && <div style={{ color: "#1f8a5b", fontSize: 13, fontWeight: 700 }}>{sent}</div>}
<button
type="button"
className="email-primary-button"
disabled={!email || sending || !mailReady || recipientCount === 0}
onClick={deliver}
>
{sending ? "Envoi en cours…" : `Envoyer à ${recipientCount} destinataire${recipientCount > 1 ? "s" : ""}`}
</button>
{!mailReady && (
<p style={{ margin: 0, color: "#9a8d72", fontSize: 11.5, lineHeight: 1.55 }}>
Aucune boîte mail ne répond : branchez-la dans Configuration › Boîte mail. Les boutons
ci-dessous restent utilisables.
</p>
)}
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("outlook", () => downloadOutlookDraft(email.subject, email.html, "invitation"))}>
{flash === "outlook" ? "✓ Brouillon téléchargé" : "Télécharger brouillon Outlook"}
</button>
<button type="button" className="email-secondary-button" disabled={!email} onClick={() => email && action("copy", () => copyRichEmail(email.html, `${email.subject}\n${email.registrationUrl}`))}>
+73
View File
@@ -0,0 +1,73 @@
/**
* Pastille ronde d'un adhérent : son logo, sinon ses initiales.
*
* Sert à identifier le commerce d'un coup d'œil devant son nom, notamment sur
* les cartes de promotion. Le logo est rendu en `contain` sur fond blanc : la
* plupart des logos d'adhérents sont des marques sur fond clair, un `cover` les
* rognerait.
*/
export function memberInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export function MemberAvatar({
name,
logoUrl,
size = 26,
accent = "#E0A63C",
}: {
name: string;
logoUrl?: string | null;
size?: number;
accent?: string;
}) {
const common: React.CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
};
if (logoUrl) {
return (
<span
aria-hidden="true"
style={{ ...common, background: "#fff", border: "1px solid #eee3cf" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoUrl}
alt=""
style={{ width: "100%", height: "100%", objectFit: "contain", padding: 2, boxSizing: "border-box" }}
/>
</span>
);
}
return (
<span
aria-hidden="true"
style={{
...common,
background: accent,
color: "#fff",
fontWeight: 800,
fontSize: Math.round(size * 0.4),
letterSpacing: "0.02em",
}}
>
{memberInitials(name)}
</span>
);
}
+61 -5
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { VitrineImage } from "@/components/VitrineImage";
import { memberPath } from "@/lib/seo";
import type { MemberContact } from "@/lib/member-contact";
export type MemberCardData = {
id: number;
@@ -21,11 +22,45 @@ const STRIPE_WARM =
const STRIPE_COOL =
"repeating-linear-gradient(45deg,#eef0ec,#eef0ec 12px,#e2e8e6 12px,#e2e8e6 24px)";
/**
* Hauteurs figées des deux blocs de texte : une description longue ne doit pas
* étirer la carte (donc toute la rangée de la grille). Titre et description
* sont tronqués en fin de ligne et occupent toujours la même place, ce qui
* aligne les cartes entre elles quel que soit le texte saisi par l'adhérent.
*/
const TITLE_LINE = 1.25;
const TITLE_LINES = 2;
const DESC_FONT = 13;
const DESC_LINE = 1.5;
const DESC_LINES = 4;
const clampLines = (lines: number) =>
({
display: "-webkit-box",
WebkitLineClamp: lines,
WebkitBoxOrient: "vertical" as const,
overflow: "hidden",
}) as const;
/**
* Carte d'un adhérent dans une grille (annuaire, page métier). Composant sans
* état : il se rend aussi bien côté serveur que dans le filtre client.
*
* `contact` (nom du référent, ligne directe) n'est passé qu'aux visiteurs
* connectés : la page ne le rapatrie pas du tout pour un visiteur anonyme, il
* n'y a donc rien à masquer en CSS. Le numéro reste en texte simple — un
* `tel:` imbriqué dans le lien de la carte serait un lien dans un lien.
*/
export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number }) {
export function MemberCard({
m,
index = 0,
contact = null,
}: {
m: MemberCardData;
index?: number;
contact?: MemberContact | null;
}) {
const location = [m.address, m.city].filter(Boolean).join(" · ");
return (
<Link
href={memberPath(m)}
@@ -50,16 +85,37 @@ export function MemberCard({ m, index = 0 }: { m: MemberCardData; index?: number
)}
</VitrineImage>
<div style={{ padding: "16px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: 0, color: "#26201a" }}>
<h3
className="font-display"
style={{ fontWeight: 700, fontSize: 18, lineHeight: TITLE_LINE, margin: 0, color: "#26201a", minHeight: 18 * TITLE_LINE * TITLE_LINES, ...clampLines(TITLE_LINES) }}
>
{m.name}
</h3>
<p style={{ margin: "6px 0 12px", fontSize: 13, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
<p
style={{ margin: "6px 0 12px", fontSize: DESC_FONT, color: "#8c8068", lineHeight: DESC_LINE, height: DESC_FONT * DESC_LINE * DESC_LINES, ...clampLines(DESC_LINES) }}
>
{m.description}
</p>
<div style={{ flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12.5, color: "#6c6150", borderTop: "1px solid #f0e8d6", paddingTop: 11 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C" }} />
{[m.address, m.city].filter(Boolean).join(" · ")}
<span style={{ width: 7, height: 7, borderRadius: "50%", background: m.accent ?? "#E0A63C", flex: "0 0 auto" }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{location}</span>
</div>
{contact && (
<div style={{ marginTop: 10, borderTop: "1px dashed #e6dcc6", paddingTop: 10 }}>
<div style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 800, color: "#9a8d72", marginBottom: 4 }}>
Contact adhérent
</div>
{contact.name && (
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{contact.name}
</div>
)}
{contact.phone && (
<div style={{ fontSize: 12.5, color: "#6c6150", marginTop: 2 }}>{contact.phone}</div>
)}
</div>
)}
</div>
</Link>
);
+52
View File
@@ -0,0 +1,52 @@
/**
* Coordonnées du référent de l'adhérent (nom, prénom, ligne directe), dans les
* deux formulaires de fiche : l'espace adhérent et l'écran staff.
*
* Ces champs ne sont jamais publiés : l'annuaire et la fiche publique ne les
* affichent qu'à un visiteur connecté (`src/lib/member-contact.ts`).
*/
export function MemberContactFields({
firstName,
lastName,
phone,
}: {
firstName: string | null;
lastName: string | null;
phone: string | null;
}) {
return (
<fieldset
style={{
marginTop: 16,
border: "1px solid #f0e8d6",
borderRadius: 12,
background: "#faf7ef",
padding: 16,
minWidth: 0,
}}
>
<legend className="field-label" style={{ padding: "0 6px" }}>
Contact adhérent
</legend>
<p className="field-hint" style={{ margin: "0 0 12px" }}>
La personne à joindre et sa ligne directe. Affiché sur l&apos;annuaire et sur la fiche
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n&apos;apparaît sur
le site public ni dans les moteurs de recherche.
</p>
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Prénom</label>
<input name="contactFirstName" defaultValue={firstName ?? ""} className="field" />
</div>
<div>
<label className="field-label">Nom</label>
<input name="contactLastName" defaultValue={lastName ?? ""} className="field" />
</div>
<div>
<label className="field-label">Téléphone direct</label>
<input name="contactPhone" defaultValue={phone ?? ""} className="field" placeholder="06 12 ..." />
</div>
</div>
</fieldset>
);
}
+34 -3
View File
@@ -7,8 +7,34 @@
* l'état de ce composant, le temps que le staff le relève. Un rechargement de
* la page le fait disparaître définitivement (il reste possible de
* réinitialiser le mot de passe pour en obtenir un nouveau).
*
* L'envoi par e-mail est un **plus**, jamais un remplacement : le mot de passe
* reste affiché même quand le message est parti, et surtout quand il n'a pas
* pu partir. Boîte mail non configurée, le panneau se comporte exactement
* comme avant.
*/
export type IssuedCredentialsItem = { label?: string; email: string; tempPassword: string };
export type IssuedCredentialsItem = {
label?: string;
email: string;
tempPassword: string;
mailed?: boolean;
mailError?: string;
};
function Delivery({ item }: { item: IssuedCredentialsItem }) {
if (item.mailed === undefined) return null;
if (item.mailed) {
return (
<span style={{ color: "#1f8a5b", fontWeight: 700 }}>✓ Envoyé à {item.email}</span>
);
}
return (
<span style={{ color: "#a8503c", fontWeight: 700 }}>
Envoi impossible — transmettez-le à la main
{item.mailError ? ` (${item.mailError.slice(0, 120)})` : ""}
</span>
);
}
export function OneTimeCredentials({
items,
@@ -18,6 +44,8 @@ export function OneTimeCredentials({
title?: string;
}) {
if (items.length === 0) return null;
const allMailed = items.every((item) => item.mailed);
return (
<div
role="status"
@@ -26,7 +54,7 @@ export function OneTimeCredentials({
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
{title}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{items.map((item) => (
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
@@ -37,11 +65,14 @@ export function OneTimeCredentials({
Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
</span>
<Delivery item={item} />
</div>
))}
</div>
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
Notez-le maintenant : il n&apos;est affiché qu&apos;une seule fois et n&apos;est conservé nulle part.
{allMailed
? "Le message est parti, mais notez-le quand même : il n'est affiché qu'une seule fois et n'est conservé nulle part."
: "Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part."}{" "}
Un changement de mot de passe sera exigé à la première connexion.
</div>
</div>
+129
View File
@@ -0,0 +1,129 @@
/**
* Carte d'une promotion sur le site public.
*
* Un seul rendu pour l'accueil (les 6 dernières) et la page `/promotions`
* (toutes les offres en cours) : les deux listes doivent rester identiques.
*/
import Link from "next/link";
import { PromoImage } from "@/components/PromoImage";
import { MemberAvatar } from "@/components/MemberAvatar";
import { formatValidity } from "@/lib/promo-validity";
import { memberPath } from "@/lib/seo";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
/** Rouge pour une remise chiffrée, vert pour une offre en nature. */
function badgeColor(badge: string | null) {
if (!badge) return "#1f8a5b";
return badge.includes("%") ? "#d8472b" : "#1f8a5b";
}
export type PublicPromo = {
id: number;
title: string;
text: string | null;
category: string | null;
badge: string | null;
imageUrl: string | null;
validUntil: string | null;
startsOn: string | null;
endsOn: string | null;
memberId: number | null;
memberName: string | null;
memberCity: string | null;
memberLogoUrl: string | null;
};
export function PromoCard({ promo: p }: { promo: PublicPromo }) {
const validity = formatValidity(p);
return (
<Link
href={p.memberId ? memberPath({ id: p.memberId, name: p.memberName ?? "", city: p.memberCity }) : "#"}
className="lift-card"
style={{
textDecoration: "none",
color: "inherit",
background: "#fff",
border: "1px solid #ece3d0",
borderRadius: 18,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
position: "relative",
aspectRatio: "1 / 1",
background: STRIPE_WARM,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{p.imageUrl && <PromoImage src={p.imageUrl} alt={p.title ?? ""} />}
{!p.imageUrl && (
<span style={{ fontSize: 10.5, letterSpacing: "0.12em", color: "#a99c82", textTransform: "uppercase" }}>
photo de l&apos;offre
</span>
)}
{p.badge && (
<span
className="font-display"
style={{
position: "absolute",
top: 0,
right: 0,
background: badgeColor(p.badge),
color: "#fff",
fontWeight: 800,
fontSize: 18,
padding: "8px 16px",
borderRadius: "0 0 0 16px",
}}
>
{p.badge}
</span>
)}
{p.category && (
<span
style={{
position: "absolute",
top: 12,
left: 12,
background: "#9a6638",
color: "#fff",
borderRadius: 999,
padding: "5px 12px",
fontSize: 11,
fontWeight: 700,
}}
>
{p.category}
</span>
)}
</div>
<div style={{ padding: "17px 18px 18px", display: "flex", flexDirection: "column", flex: 1 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 5px", color: "#26201a" }}>
{p.title}
</h3>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#8c8068", lineHeight: 1.5, flex: 1 }}>
{p.text}
</p>
<div style={{ borderTop: "1px solid #f0e8d6", paddingTop: 12 }}>
{/* Le logo du commerce juste à gauche de son nom. */}
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<MemberAvatar name={p.memberName ?? ""} logoUrl={p.memberLogoUrl} size={28} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#3c3322" }}>{p.memberName}</div>
{validity && <div style={{ fontSize: 11.5, color: "#a99c82" }}>{validity}</div>}
</div>
</div>
</div>
</div>
</Link>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { richTextToEditorHtml } from "@/lib/rich-text";
import { serializeToRichText } from "@/lib/rich-text-dom";
/**
* Éditeur visuel : on écrit, on sélectionne, on clique sur **G**, et le texte
* devient gras à l'écran. Aucune syntaxe à connaître.
*
* Ce qui part au serveur reste pourtant le format balisé restreint : à chaque
* frappe, `serializeToRichText` retraverse le contenu édité et n'en garde que
* le gras, l'italique, les listes, les sous-titres et les liens. Le HTML de
* l'éditeur ne quitte jamais la page — c'était là, et non dans l'édition
* visuelle, que se trouvait le risque.
*
* `document.execCommand` est officiellement déprécié mais reste implémenté par
* tous les navigateurs, et c'est la seule façon d'obtenir une édition riche
* sans embarquer un moteur d'édition complet. Le jour où il faudrait en
* changer, seul ce fichier bougerait : le contrat de stockage est le
* sérialiseur, pas l'éditeur.
*/
type Tool = {
key: string;
label: string;
title: string;
command: string;
argument?: string;
/** Nom d'état interrogé pour allumer le bouton quand le curseur est dedans. */
state?: string;
render?: "bold" | "italic";
};
const TOOLS: Tool[] = [
{ key: "bold", label: "G", title: "Gras (Ctrl+B)", command: "bold", state: "bold", render: "bold" },
{ key: "italic", label: "I", title: "Italique (Ctrl+I)", command: "italic", state: "italic", render: "italic" },
{ key: "h4", label: "Sous-titre", title: "Transformer la ligne en sous-titre", command: "formatBlock", argument: "<h4>" },
{ key: "ul", label: "Liste à puces", title: "Liste à puces", command: "insertUnorderedList", state: "insertUnorderedList" },
{ key: "ol", label: "Liste numérotée", title: "Liste numérotée", command: "insertOrderedList", state: "insertOrderedList" },
];
export function RichTextEditor({
value,
onChange,
placeholder = "Rédigez votre message…",
ariaLabel = "Message",
}: {
value: string;
onChange: (markup: string) => void;
placeholder?: string;
ariaLabel?: string;
}) {
const editorRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [linkUrl, setLinkUrl] = useState<string | null>(null);
const savedRange = useRef<Range | null>(null);
const sync = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
onChange(serializeToRichText(editor));
}, [onChange]);
// Contenu initial posé une seule fois : réécrire le HTML à chaque frappe
// replacerait le curseur au début de la zone.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = richTextToEditorHtml(value);
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
try {
// Sans cela, Chrome écrit `<span style="font-weight:bold">` au lieu de
// `<b>` — le sérialiseur, qui ne lit pas les styles, perdrait le gras.
document.execCommand("styleWithCSS", false, "false");
document.execCommand("defaultParagraphSeparator", false, "p");
} catch {
/* Navigateur sans execCommand : la saisie reste possible, sans mise en forme. */
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Les boutons s'allument selon l'endroit où se trouve le curseur.
useEffect(() => {
function refresh() {
const editor = editorRef.current;
if (!editor || !editor.contains(document.getSelection()?.anchorNode ?? null)) return;
const next: Record<string, boolean> = {};
for (const tool of TOOLS) {
if (!tool.state) continue;
try {
next[tool.key] = document.queryCommandState(tool.state);
} catch {
next[tool.key] = false;
}
}
setActive(next);
}
document.addEventListener("selectionchange", refresh);
return () => document.removeEventListener("selectionchange", refresh);
}, []);
function run(command: string, argument?: string) {
editorRef.current?.focus();
try {
document.execCommand(command, false, argument);
} catch {
/* Commande refusée : on laisse le texte tel quel. */
}
sync();
}
function applyTool(tool: Tool) {
// Re-cliquer sur « Sous-titre » revient à un paragraphe ordinaire.
if (tool.command === "formatBlock" && currentBlock() === "H4") {
run("formatBlock", "<p>");
return;
}
run(tool.command, tool.argument);
}
function currentBlock(): string {
try {
return String(document.queryCommandValue("formatBlock") ?? "").toUpperCase();
} catch {
return "";
}
}
function openLink() {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
setLinkUrl("");
savedRange.current = null;
return;
}
// La sélection est perdue dès que le champ d'adresse prend le focus :
// on la met de côté pour la retrouver à la validation.
savedRange.current = selection.getRangeAt(0).cloneRange();
setLinkUrl("https://");
}
function confirmLink() {
const url = (linkUrl ?? "").trim();
setLinkUrl(null);
if (!url || !savedRange.current) return;
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(savedRange.current);
savedRange.current = null;
// Une adresse non http(s) est écartée par le sérialiseur : le libellé
// reste, le lien disparaît.
run("createLink", url);
}
function onPaste(event: React.ClipboardEvent<HTMLDivElement>) {
event.preventDefault();
const html = event.clipboardData.getData("text/html");
const text = event.clipboardData.getData("text/plain");
if (html) {
// Le HTML collé — d'un Word, d'une page web — est analysé dans un
// document **inerte** : `DOMParser` n'exécute rien et ne charge aucune
// ressource, contrairement à une affectation d'`innerHTML`. On le
// repasse ensuite par notre format, donc seule la mise en forme connue
// survit ; on ne réinjecte que du HTML produit par nous.
const parsed = new DOMParser().parseFromString(html, "text/html");
run("insertHTML", richTextToEditorHtml(serializeToRichText(parsed.body)));
return;
}
run("insertText", text);
}
return (
<div>
<div className="rich-editor-toolbar" role="toolbar" aria-label="Mise en forme">
{TOOLS.map((tool) => (
<button
key={tool.key}
type="button"
title={tool.title}
aria-pressed={active[tool.key] ?? false}
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyTool(tool)}
className={`rich-editor-tool${active[tool.key] ? " is-active" : ""}`}
style={{
fontWeight: tool.render === "bold" ? 800 : undefined,
fontStyle: tool.render === "italic" ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
<span className="rich-editor-separator" />
<button
type="button"
title="Insérer un lien sur le texte sélectionné"
onMouseDown={(event) => event.preventDefault()}
onClick={openLink}
className="rich-editor-tool"
>
Lien
</button>
<button
type="button"
title="Retirer la mise en forme de la sélection"
onMouseDown={(event) => event.preventDefault()}
onClick={() => run("removeFormat")}
className="rich-editor-tool"
>
Effacer la mise en forme
</button>
</div>
{linkUrl !== null && (
<div className="rich-editor-link">
{savedRange.current ? (
<>
<input
className="field"
value={linkUrl}
autoFocus
placeholder="https://…"
onChange={(event) => setLinkUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
confirmLink();
}
if (event.key === "Escape") setLinkUrl(null);
}}
/>
<button type="button" className="rich-editor-tool" onClick={confirmLink}>Ajouter</button>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Annuler</button>
</>
) : (
<>
<span className="field-hint">Sélectionnez d&apos;abord le texte à transformer en lien.</span>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Fermer</button>
</>
)}
</div>
)}
<div
ref={editorRef}
className="rich-editor rich-text"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
data-placeholder={placeholder}
onInput={sync}
onBlur={sync}
onPaste={onPaste}
/>
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export async function SiteFooter() {
<div className="footer-title">Accès rapide</div>
<div className="footer-links">
<Link href="/annuaire">Annuaire</Link>
<Link href="/#promotions">Promotions</Link>
<Link href="/promotions">Promotions</Link>
<Link href="/association">L&apos;association</Link>
<Link href="/rencontres-passees">Rencontres passées</Link>
<Link href="/login">Espace adhérent</Link>
+1 -1
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
const NAV = [
{ key: "accueil", label: "Accueil", href: "/" },
{ key: "annuaire", label: "Annuaire", href: "/annuaire" },
{ key: "promotions", label: "Promotions", href: "/#promotions" },
{ key: "promotions", label: "Promotions", href: "/promotions" },
{ key: "association", label: "L'association", href: "/association" },
];
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useState, type CSSProperties } from "react";
import { IMPLICIT_TLS_PORT, impliedSecure, tlsMismatch } from "@/lib/smtp-config";
/**
* Réglages d'un serveur SMTP générique.
*
* Client parce que le port et le chiffrement sont **un seul choix** présenté
* en deux champs : changer le port réaligne la case, et un couple incohérent
* s'annonce à l'écran plutôt qu'en échec de connexion illisible. Voir
* `src/lib/smtp-config.ts` pour le pourquoi des deux ports.
*
* Aucun secret ne descend ici : le mot de passe enregistré n'est pas passé en
* prop, seul `hasPassword` dit s'il faut proposer de le conserver.
*/
const PRESETS = [
{ port: IMPLICIT_TLS_PORT, label: "465 · chiffré dès l'ouverture" },
{ port: 587, label: "587 · STARTTLS" },
];
export function SmtpAccountForm({
saveAction,
host,
port,
secure,
user,
hasPassword,
fromAddress,
fromName,
submitStyle,
}: {
saveAction: (formData: FormData) => void | Promise<void>;
host: string;
port: number;
secure: boolean;
user: string;
hasPassword: boolean;
fromAddress: string;
fromName: string;
submitStyle: CSSProperties;
}) {
const [currentHost, setHost] = useState(host);
const [currentPort, setPort] = useState(String(port));
const [currentSecure, setSecure] = useState(secure);
const numericPort = Number(currentPort);
const knownPort = Number.isFinite(numericPort) && numericPort > 0;
const warning = knownPort ? tlsMismatch(numericPort, currentSecure) : null;
/** Le port choisi vaut choix du mode : la case suit, et reste reprenable à la main. */
function applyPort(value: string) {
setPort(value);
const next = Number(value);
if (Number.isFinite(next) && next > 0) setSecure(impliedSecure(next));
}
return (
<form action={saveAction}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
<label className="field-label">
Serveur
<input
className="field"
name="smtpHost"
value={currentHost}
onChange={(event) => setHost(event.target.value)}
placeholder="smtp.ionos.fr"
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Port
<input
className="field"
name="smtpPort"
type="number"
min={1}
max={65535}
value={currentPort}
onChange={(event) => applyPort(event.target.value)}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Identifiant
<input className="field" name="smtpUser" defaultValue={user} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
</label>
</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#9a8d72", fontWeight: 700 }}>Ports courants</span>
{PRESETS.map((preset) => {
const on = numericPort === preset.port;
return (
<button
key={preset.port}
type="button"
onClick={() => applyPort(String(preset.port))}
style={{
border: `1px solid ${on ? "#13324F" : "#d8cdb4"}`,
background: on ? "#13324F" : "#fff",
color: on ? "#fff" : "#6c6150",
fontWeight: 700,
fontSize: 12,
padding: "6px 11px",
borderRadius: 999,
cursor: "pointer",
}}
>
{preset.label}
</button>
);
})}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
<label className="field-label">
Mot de passe
<input
className="field"
name="smtpPassword"
type="password"
autoComplete="new-password"
placeholder={hasPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Adresse d&apos;expédition
<input className="field" name="fromAddress" defaultValue={fromAddress} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Nom affiché
<input className="field" name="fromName" defaultValue={fromName} style={{ marginTop: 6 }} />
</label>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
<input
type="checkbox"
name="smtpSecure"
checked={currentSecure}
onChange={(event) => setSecure(event.target.checked)}
/>
Connexion chiffrée dès l&apos;ouverture (port 465). Décochez pour STARTTLS (port 587).
</label>
{warning && (
<p style={{ marginTop: 8, fontSize: 12.5, color: "#a4571f", background: "#fdf3e3", border: "1px solid #ecd9b4", borderRadius: 9, padding: "9px 11px" }}>
{warning}
</p>
)}
<p className="field-hint" style={{ marginTop: 8 }}>
L&apos;adresse d&apos;expédition doit appartenir au domaine qui authentifie la connexion, sinon
SPF et DKIM échouent et les messages partent en indésirable.
</p>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
<button type="submit" style={submitStyle}>Enregistrer</button>
</div>
</form>
);
}
+9
View File
@@ -19,6 +19,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
address: "12 rue de la République",
postalCode: "54390",
phone: "03 83 49 00 00",
contactFirstName: "Julien",
contactLastName: "Marchal",
contactPhone: "06 12 34 56 78",
website: "https://www.aubonpain-frouard.fr",
memberSince: 2021,
tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur",
@@ -36,6 +39,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
address: "3 place Stanislas",
postalCode: "54340",
phone: "03 83 24 00 00",
contactFirstName: "Sophie",
contactLastName: "Renard",
contactPhone: "06 98 76 54 32",
memberSince: 2019,
tags: "Cuisine de saison, Brunch, Terrasse, Produits frais",
hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h",
@@ -49,6 +55,9 @@ export function demoMembers(catId: (slug: string) => number | null) {
categoryId: catId("mode-beaute"),
city: "Champigneulles",
address: "7 av. des Tilleuls",
contactFirstName: "Émilie",
contactLastName: "Bertrand",
contactPhone: "06 45 67 89 01",
description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.",
status: "active" as const,
highlighted: true,
+169
View File
@@ -6,6 +6,7 @@ import {
text,
varchar,
boolean,
date,
timestamp,
index,
uniqueIndex,
@@ -21,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
"expired",
"rejected",
"suspended",
// Validée par l'association mais en attente de sa date de publication
// (`publish_at`). Invisible du site public tant que l'échéance n'est pas
// atteinte, et non encore diffusée sur les réseaux.
"scheduled",
]);
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
// Une promotion suspendue par l'association ne peut être réactivée que par elle.
@@ -37,6 +42,22 @@ export const contactStatusEnum = pgEnum("contact_status", [
"read",
"archived",
]);
export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]);
export const mailProviderEnum = pgEnum("mail_provider", ["google", "microsoft", "smtp"]);
export const mailStatusEnum = pgEnum("mail_status", [
"queued",
"sending",
"sent",
"failed",
"cancelled",
]);
export const mailKindEnum = pgEnum("mail_kind", [
"credentials",
"information",
"meeting",
"studio",
"test",
]);
// ---- Categories (métiers) ----
export const categories = pgTable("categories", {
@@ -57,6 +78,13 @@ export const members = pgTable("members", {
email: varchar("email", { length: 200 }).notNull(),
// E-mail public de la fiche ; à vide, la fiche retombe sur `email`.
contactEmail: varchar("contact_email", { length: 200 }),
// Référent de l'adhérent : la personne à joindre au sein du commerce, avec
// sa ligne directe. Contrairement aux coordonnées de l'établissement, ces
// trois champs ne sont montrés qu'aux visiteurs connectés (cf.
// `src/lib/member-contact.ts`), jamais dans le HTML public ni le JSON-LD.
contactFirstName: varchar("contact_first_name", { length: 120 }),
contactLastName: varchar("contact_last_name", { length: 120 }),
contactPhone: varchar("contact_phone", { length: 40 }),
categoryId: integer("category_id").references(() => categories.id),
city: varchar("city", { length: 120 }),
address: varchar("address", { length: 240 }),
@@ -107,11 +135,18 @@ export const promotions = pgTable("promotions", {
imageUrl: text("image_url"),
memberId: integer("member_id").references(() => members.id),
status: promoStatusEnum("status").notNull().default("pending"),
// Période de validité. Les deux bornes sont facultatives et indépendantes :
// une offre peut n'avoir qu'une date de fin, qu'une date de début, ou aucune.
startsOn: date("starts_on"),
endsOn: date("ends_on"),
/** @deprecated Texte libre des anciennes promos ; sert de repli à l'affichage. */
validUntil: varchar("valid_until", { length: 120 }),
// Réseaux demandés à la soumission. Le choix reste modifiable tant que la
// promo est « pending » ; la validation le fige ET déclenche la publication.
shareFacebook: boolean("share_facebook").notNull().default(false),
shareLinkedin: boolean("share_linkedin").notNull().default(false),
// Programmation de la publication. Vide = mise en ligne dès la validation.
publishAt: timestamp("publish_at", { withTimezone: true }),
suspendedBy: promoSuspendedByEnum("suspended_by"),
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
suspendedAt: timestamp("suspended_at", { withTimezone: true }),
@@ -155,6 +190,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(),
});
@@ -268,6 +308,114 @@ export const imageConsents = pgTable("image_consents", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// ---- Informations de l'association (fil de l'espace adhérent) ----
// Le corps est du texte balisé (`src/lib/rich-text.ts`), jamais du HTML :
// il est analysé puis rendu en éléments React.
export const informations = pgTable(
"informations",
{
id: serial("id").primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
body: text("body").notNull(),
imageUrl: text("image_url"),
status: infoStatusEnum("status").notNull().default("draft"),
pinned: boolean("pinned").notNull().default(false),
publishedAt: timestamp("published_at", { withTimezone: true }),
authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }),
// Garde anti-double-diffusion, sur le modèle de `publishPromoShares` :
// une information déjà envoyée ne repart pas à la republication.
emailSentAt: timestamp("email_sent_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
feedIdx: index("informations_feed_idx").on(table.status, table.publishedAt),
})
);
// Une ligne par lecture. Une simple date « vu jusqu'ici » sur l'utilisateur ne
// permettrait ni la pastille « nouveau » par information, ni le « lue par
// 12 / 40 » du back-office.
export const informationReads = pgTable(
"information_reads",
{
id: serial("id").primaryKey(),
informationId: integer("information_id")
.notNull()
.references(() => informations.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
readAt: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
uniq: uniqueIndex("information_reads_user_info_idx").on(table.userId, table.informationId),
})
);
// ---- Boîte mail de l'association ----
// Une ligne par fournisseur, `is_active` désigne celui qui expédie. Les secrets
// sont chiffrés (`src/lib/crypto.ts`) et ne ressortent jamais vers le
// navigateur — même règle que `social_accounts`.
export const mailAccounts = pgTable("mail_accounts", {
id: serial("id").primaryKey(),
provider: mailProviderEnum("provider").notNull().unique(),
// Lue chez le fournisseur pour Google et Microsoft : on expédie comme la
// boîte authentifiée, sinon SPF et DKIM tombent.
fromAddress: varchar("from_address", { length: 200 }),
fromName: varchar("from_name", { length: 200 }),
isActive: boolean("is_active").notNull().default(false),
appId: varchar("app_id", { length: 200 }),
appSecret: text("app_secret"),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
smtpHost: varchar("smtp_host", { length: 200 }),
smtpPort: integer("smtp_port"),
smtpSecure: boolean("smtp_secure").notNull().default(true),
smtpUser: varchar("smtp_user", { length: 200 }),
smtpPassword: text("smtp_password"),
connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }),
connectedAt: timestamp("connected_at", { withTimezone: true }),
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(),
});
// File d'attente d'envoi. `to_address` est un destinataire **unique** : une
// diffusion produit autant de lignes que d'adhérents, et aucune adresse ne peut
// donc apparaître aux yeux des autres.
export const mailMessages = pgTable(
"mail_messages",
{
id: serial("id").primaryKey(),
kind: mailKindEnum("kind").notNull(),
status: mailStatusEnum("status").notNull().default("queued"),
toAddress: varchar("to_address", { length: 200 }).notNull(),
toName: varchar("to_name", { length: 200 }),
subject: varchar("subject", { length: 300 }).notNull(),
html: text("html").notNull(),
text: text("text"),
replyTo: varchar("reply_to", { length: 200 }),
informationId: integer("information_id").references(() => informations.id, { onDelete: "set null" }),
meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }),
memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }),
createdById: integer("created_by_id").references(() => users.id, { onDelete: "set null" }),
provider: mailProviderEnum("provider"),
attempts: integer("attempts").notNull().default(0),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
lockedAt: timestamp("locked_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
dueIdx: index("mail_messages_due_idx").on(table.status, table.nextAttemptAt),
informationIdx: index("mail_messages_information_idx").on(table.informationId),
})
);
// ---- Relations ----
export const membersRelations = relations(members, ({ one, many }) => ({
category: one(categories, {
@@ -338,6 +486,21 @@ export const imageConsentsRelations = relations(imageConsents, ({ one }) => ({
}),
}));
export const informationsRelations = relations(informations, ({ one, many }) => ({
author: one(users, {
fields: [informations.authorId],
references: [users.id],
}),
reads: many(informationReads),
}));
export const informationReadsRelations = relations(informationReads, ({ one }) => ({
information: one(informations, {
fields: [informationReads.informationId],
references: [informations.id],
}),
}));
export type Category = typeof categories.$inferSelect;
export type Member = typeof members.$inferSelect;
export type User = typeof users.$inferSelect;
@@ -354,3 +517,9 @@ export type MeetingRegistration = typeof meetingRegistrations.$inferSelect;
export type PastMeeting = typeof pastMeetings.$inferSelect;
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
export type ImageConsent = typeof imageConsents.$inferSelect;
export type Information = typeof informations.$inferSelect;
export type MailAccount = typeof mailAccounts.$inferSelect;
export type MailProvider = (typeof mailProviderEnum.enumValues)[number];
export type MailMessage = typeof mailMessages.$inferSelect;
export type MailKind = (typeof mailKindEnum.enumValues)[number];
export type InformationRead = typeof informationReads.$inferSelect;
+46
View File
@@ -0,0 +1,46 @@
/**
* Boucles de fond : libération des publications programmées et vidage de la
* file d'envoi. Fichier séparé de `instrumentation.ts` : il touche la base et
* `nodemailer`, et ne doit donc jamais entrer dans le bundle edge du
* middleware — c'est cet import-là que l'`IgnorePlugin` de `next.config.mjs`
* coupe.
*/
import { releaseDuePromotions } from "@/lib/promo-publish";
import { processOutbox } from "@/lib/mail-outbox";
const PROMO_INTERVAL_MS = 60_000;
// Plus court que la minute des promotions : un mot de passe ou une invitation
// mis en file ne doit pas attendre une minute pleine.
const MAIL_INTERVAL_MS = 20_000;
async function promoTick() {
try {
const n = await releaseDuePromotions();
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
} catch (error) {
// `releaseDuePromotions` avale déjà ses erreurs ; ce filet couvre le cas
// d'une base injoignable au démarrage, qui ne doit pas tuer le serveur.
console.error("[promotions] libérateur :", error);
}
}
async function mailTick() {
try {
const { sent, failed } = await processOutbox();
if (sent || failed) console.log(`[mail] ${sent} envoyé(s), ${failed} en échec`);
} catch (error) {
console.error("[mail] file d'attente :", error);
}
}
// Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure.
if (process.env.PROMO_SCHEDULER !== "off") {
void promoTick();
setInterval(promoTick, PROMO_INTERVAL_MS).unref();
}
if (process.env.MAIL_WORKER !== "off") {
void mailTick();
setInterval(mailTick, MAIL_INTERVAL_MS).unref();
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Démarrage du serveur : boucles de fond.
*
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
* les promotions `scheduled` dont l'échéance est passée, les met en ligne et
* lance la diffusion réseaux. La minute est aussi la précision annoncée dans
* le formulaire.
*
* Sûr à plusieurs instances : la bascule passe par un `UPDATE … RETURNING`
* filtré sur le statut (voir `releaseDuePromotions`), une seule instance
* récupère chaque promotion.
*/
export async function register() {
// `register()` est aussi appelé pour le runtime edge, où il n'y a ni base
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
// pas entrer dans ce bundle.
if (process.env.NEXT_RUNTIME !== "nodejs") return;
// Chaque boucle a son propre interrupteur, appliqué à l'intérieur du module :
// couper le libérateur de promotions ici couperait aussi l'envoi des
// e-mails, qui n'a rien à voir avec lui.
if (process.env.PROMO_SCHEDULER === "off" && process.env.MAIL_WORKER === "off") return;
await import("./instrumentation-node");
}
+13
View File
@@ -0,0 +1,13 @@
import { db } from "@/db";
import { activityLog } from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
/**
* Écrit une ligne du journal d'activité.
*
* Le message agrège des saisies de tiers (jusqu'au formulaire de contact
* public) : il ne laisse passer que `<strong>`.
*/
export async function logActivity(message: string, dot = "#2C6FB3") {
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
}
+70
View File
@@ -0,0 +1,70 @@
import { PRIVATE_PATHS } from "@/lib/seo";
/**
* Mesure d'audience Google Analytics 4.
*
* Module **pur** (verrouillé par `tests/analytics.test.ts`) : il ne sait ni lire
* la base ni rendre du HTML, il décide seulement ce qui part dans la page.
* L'identifiant vient du réglage `google_analytics_id` (Backend › Paramètres) :
* tant qu'il est vide, aucune balise n'est posée et aucun appel n'est fait.
*/
/** Identifiant de mesure GA4 : « G- » suivi de l'identifiant du flux. */
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,24}$/;
/**
* Valide la saisie de l'administrateur avant qu'elle n'atteigne la page.
*
* Renvoie `""` pour tout ce qui n'est pas un identifiant GA4 : c'est la garde
* qui interdit d'injecter une chaîne arbitraire dans le script en ligne — un
* identifiant validé ne contient ni guillemet, ni `<`, ni `</script>`.
* Les identifiants Universal Analytics (`UA-…`), arrêtés depuis 2023, sont
* refusés : ils ne collectent plus rien.
*/
export function normalizeMeasurementId(raw: string | null | undefined): string {
const value = (raw ?? "").trim().toUpperCase();
return MEASUREMENT_ID.test(value) ? value : "";
}
/** URL du chargeur gtag.js pour un identifiant **déjà normalisé**. */
export function gtagSrc(measurementId: string): string {
const id = normalizeMeasurementId(measurementId);
return id ? `https://www.googletagmanager.com/gtag/js?id=${id}` : "";
}
/**
* Script d'amorçage de gtag.js. Il porte le nonce de la CSP comme le reste des
* scripts du site ; les scripts que gtag.js charge ensuite passent grâce à
* `'strict-dynamic'`.
*/
export function gtagConfigScript(measurementId: string): string {
const id = normalizeMeasurementId(measurementId);
if (!id) return "";
return [
"window.dataLayer=window.dataLayer||[];",
"function gtag(){dataLayer.push(arguments);}",
"gtag('js',new Date());",
`gtag('config','${id}');`,
].join("");
}
/**
* Seules les pages publiques sont mesurées.
*
* L'espace adhérent et le backoffice portent des identifiants de membres et de
* promotions dans leurs URLs : les envoyer à un tiers n'apporterait rien et
* ferait sortir des données personnelles du site.
*/
export function isTrackedPath(pathname: string): boolean {
const path = pathname.split("?")[0] ?? "/";
return !PRIVATE_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
/** Jeton de validation Search Console (balise `google-site-verification`). */
export function normalizeSiteVerification(raw: string | null | undefined): string {
const value = (raw ?? "").trim();
// Google livre un jeton alphanumérique d'une quarantaine de caractères ; on
// refuse le reste — dont la balise <meta> complète, collée telle quelle —
// plutôt que de recopier dans le <head> une chaîne venue de n'importe où.
return /^[A-Za-z0-9_-]{20,128}$/.test(value) ? value : "";
}
+25 -23
View File
@@ -1,23 +1,20 @@
function bytesBase64(bytes: Uint8Array) {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return globalThis.btoa(binary);
}
/**
* Sorties navigateur du studio de composition : copie enrichie, brouillon
* Outlook, export HTML. Rien ici ne part sur le réseau — l'envoi réel vit
* côté serveur dans `src/lib/mailer.ts`.
*
* L'assemblage du message est délégué à `src/lib/mime.ts`, partagé avec le
* transport Gmail : un seul constructeur RFC 822, verrouillé par ses tests.
*/
import { buildMimeMessage, bytesToBase64, type MimeAttachment } from "@/lib/mime";
function utf8Base64(value: string) {
return bytesBase64(new TextEncoder().encode(value));
}
function wrapBase64(value: string) {
return value.match(/.{1,76}/g)?.join("\r\n") ?? "";
}
const LOGO_CONTENT_ID = "plein-r-logo";
const LOGO_SRC = /(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi;
function slug(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "email-plein-r";
@@ -59,20 +56,25 @@ export async function copyRichEmail(html: string, plainText: string) {
export async function downloadOutlookDraft(subject: string, html: string, prefix = "email") {
const logoUrl = html.match(/\bsrc=["']([^"']*\/assets\/logo\.png(?:\?[^"']*)?)["']/i)?.[1];
let renderedHtml = html;
let logoPart = "";
const boundary = "----=_PleinR_Outlook_Draft";
const inline: MimeAttachment[] = [];
// Le logo est embarqué plutôt que laissé en lien : un brouillon ouvert hors
// ligne, ou avant la mise en ligne du site, afficherait sinon un cadre vide.
if (logoUrl) {
const response = await fetch(logoUrl, { cache: "force-cache" });
if (!response.ok) throw new Error("Logo Plein R introuvable.");
const base64 = bytesBase64(new Uint8Array(await response.arrayBuffer()));
renderedHtml = html.replace(/(\bsrc\s*=\s*)(["'])[^"']*\/assets\/logo\.png(?:\?[^"']*)?\2/gi, "$1$2cid:plein-r-logo$2");
logoPart = [`--${boundary}`, "Content-Type: image/png; name=\"logo.png\"", "Content-Transfer-Encoding: base64", "Content-ID: <plein-r-logo>", "Content-Disposition: inline; filename=\"logo.png\"", "", wrapBase64(base64)].join("\r\n");
renderedHtml = html.replace(LOGO_SRC, `$1$2cid:${LOGO_CONTENT_ID}$2`);
inline.push({
filename: "logo.png",
contentType: "image/png",
contentId: LOGO_CONTENT_ID,
base64: bytesToBase64(new Uint8Array(await response.arrayBuffer())),
});
}
const mime = logoPart
? ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", `Content-Type: multipart/related; type=\"text/html\"; boundary=\"${boundary}\"`, "", `--${boundary}`, "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(renderedHtml)), logoPart, `--${boundary}--`, ""].join("\r\n")
: ["X-Unsent: 1", `Subject: =?UTF-8?B?${utf8Base64(subject)}?=`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", "Content-Transfer-Encoding: base64", "", wrapBase64(utf8Base64(html)), ""].join("\r\n");
// `X-Unsent: 1` : Outlook ouvre le fichier comme un brouillon modifiable et
// non comme un message reçu.
const mime = buildMimeMessage({ subject, html: renderedHtml, inline, headers: { "X-Unsent": "1" } });
downloadBlob(new Blob([mime], { type: "message/rfc822" }), `${prefix}-${slug(subject)}-outlook.eml`);
}
+88 -10
View File
@@ -1,3 +1,5 @@
import { richTextToEmailHtml, safeHttpUrl } from "@/lib/rich-text";
export type EmailBrand = {
associationName: string;
address?: string;
@@ -45,15 +47,6 @@ function lines(value: unknown) {
return esc(value).replace(/\r?\n/g, "<br>");
}
function webUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
} catch {
return "";
}
}
function paragraphs(value: string) {
return value
.split(/\r?\n\s*\r?\n/)
@@ -100,7 +93,7 @@ function emailShell({ title, content, baseUrl, brand }: { title: string; content
}
function cta(url: string, label: string) {
const safe = webUrl(url);
const safe = safeHttpUrl(url);
if (!safe || !label) return "";
return `<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;"><tr><td align="center" bgcolor="#2C6FB3" style="background:#2C6FB3;padding:16px 0;border-radius:8px;font-family:Arial,Helvetica,sans-serif;font-size:17px;font-weight:bold;"><a href="${esc(safe)}" target="_blank" style="display:block;color:#FFFFFF;text-decoration:none;">${esc(label)}</a></td></tr></table>`;
}
@@ -168,3 +161,88 @@ export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmail
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si bouton bloqué, copiez ce lien :<br><a href="${esc(registrationUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(registrationUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }), registrationUrl };
}
export type CredentialsEmailData = {
name: string;
email: string;
tempPassword: string;
/** « Votre compte adhérent », « Votre accès à l'administration »… */
intro: string;
};
/**
* Identifiants d'un compte qui vient d'être créé ou réinitialisé.
*
* Construit dans la portée de l'action qui émet le mot de passe et expédié
* aussitôt : ce message ne passe **jamais** par la file d'attente, dont le
* corps est stocké en base.
*/
export function buildCredentialsEmail(data: CredentialsEmailData, baseUrl: string, brand: EmailBrand) {
const loginUrl = `${baseUrl.replace(/\/$/, "")}/login`;
const subject = `Vos identifiants ${brand.associationName}`;
const body = `
<tr><td style="padding:42px 40px 16px;">
<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">${esc(brand.associationName)}</div>
<div style="padding-bottom:23px;font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:36px;color:#26201A;">Bonjour ${esc(data.name)},</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(data.intro)}</div>
</td></tr>
<tr><td style="padding:8px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FAF7EF" style="width:100%;background:#FAF7EF;border:1px solid #E6DCC6;border-left:5px solid #E0A63C;border-collapse:collapse;">
<tr><td style="padding:24px 28px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:24px;color:#5D5447;">
Identifiant<br><strong style="font-size:16px;color:#26201A;">${esc(data.email)}</strong>
<div style="padding-top:14px;">Mot de passe temporaire<br>
<strong style="font-family:'Courier New',Courier,monospace;font-size:19px;letter-spacing:1.5px;color:#26201A;">${esc(data.tempPassword)}</strong>
</div>
</td></tr>
</table>
</td></tr>
<tr><td align="center" style="padding:26px 40px 10px;">${cta(loginUrl, "Me connecter")}</td></tr>
<tr><td style="padding:12px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:23px;color:#5D5447;">
Ce mot de passe est <strong style="color:#26201A;">provisoire</strong> : il vous sera demandé d'en choisir un
nouveau dès votre première connexion. Si vous n'êtes pas à l'origine de cette demande, prévenez
l'association.
</td></tr>
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si le bouton ne fonctionne pas, copiez ce lien :<br><a href="${esc(loginUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(loginUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}
export type InformationEmailData = {
id: number;
title: string;
/** Texte balisé, analysé par `src/lib/rich-text.ts`. */
body: string;
hasImage: boolean;
};
/**
* Une information de l'association, mise en e-mail.
*
* Un constructeur à part plutôt qu'un élargissement de `GeneralEmailContent` :
* le contrat du studio de composition reste intact.
*
* L'image de couverture part par **URL** (`/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 d'attente à plusieurs méga-octets.
*/
export function buildInformationEmail(data: InformationEmailData, baseUrl: string, brand: EmailBrand) {
const base = baseUrl.replace(/\/$/, "");
const spaceUrl = `${base}/backend/espace/informations`;
const subject = `${brand.associationName} — ${data.title}`;
const body = `
<tr><td style="padding:42px 40px 8px;">
<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">Information &middot; ${esc(brand.associationName)}</div>
<div style="padding-bottom:20px;font-family:Georgia,'Times New Roman',serif;font-size:29px;line-height:36px;color:#26201A;">${esc(data.title)}</div>
</td></tr>
${
data.hasImage
? `<tr><td style="padding:0 40px 18px;"><img src="${esc(`${base}/api/informations/${data.id}/image`)}" alt="" width="540" style="display:block;width:100%;max-width:540px;height:auto;border:1px solid #E6DCC6;"></td></tr>`
: ""
}
<tr><td style="padding:0 40px 10px;">${richTextToEmailHtml(data.body)}</td></tr>
<tr><td align="center" style="padding:18px 40px 10px;">${cta(spaceUrl, "Lire dans mon espace")}</td></tr>
<tr><td style="padding:14px 40px 34px;font-family:Arial,Helvetica,sans-serif;font-size:11.5px;line-height:18px;color:#8C8068;">
Vous recevez ce message en tant qu'adhérent de ${esc(brand.associationName)}.<br>
Si le bouton ne fonctionne pas, copiez ce lien : <a href="${esc(spaceUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(spaceUrl)}</a>
</td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Description d'une image déposée dans un formulaire : dimensions, format et
* poids, plus les remarques à montrer à l'adhérent avant l'envoi.
*
* Module **pur** (aucun accès DOM, base ou réseau) : il est appelé depuis un
* composant client, et verrouillé par `tests/image-info.test.ts`.
*
* Le besoin vient des réseaux sociaux. L'application publie le fichier tel
* quel — `loadPromoImage()` décode la data-URI et envoie les octets sans y
* toucher — donc un visuel qui n'est pas au format attendu par Facebook ou
* LinkedIn est recadré ou entouré de bandes de couleur *par la plateforme*,
* une couleur prise dans l'image. Rien ne se voit côté site, qui recadre en
* `cover` : d'où l'information au moment du dépôt, seul endroit où l'adhérent
* peut encore changer de fichier.
*/
export type ImageInfo = {
/** Largeur en pixels. */
width: number;
/** Hauteur en pixels. */
height: number;
/** Poids du fichier d'origine, en octets. */
bytes: number;
};
/** Écart toléré autour du carré parfait (2 %) : 1080×1078 reste « carré ». */
export const SQUARE_TOLERANCE = 0.02;
/** En dessous, l'image sera floue une fois agrandie dans un fil d'actualité. */
export const MIN_SIDE = 800;
/** Côté conseillé, celui qu'attendent les deux réseaux. */
export const IDEAL_SIDE = 1080;
/**
* Au delà, la data-URI (base64, ~+33 %) approche la limite de 4 Mo des server
* actions : l'envoi peut être refusé.
*/
export const MAX_BYTES = 2_500_000;
/** Formats courants reconnus, du plus au moins fréquent. */
const KNOWN_RATIOS: { ratio: number; label: string }[] = [
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 5, label: "4:5" },
{ ratio: 5 / 4, label: "5:4" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 2 / 3, label: "2:3" },
{ ratio: 3 / 2, label: "3:2" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 1.91, label: "1,91:1" },
];
function isValid(info: ImageInfo): boolean {
return (
Number.isFinite(info.width) &&
Number.isFinite(info.height) &&
info.width > 0 &&
info.height > 0
);
}
/** Poids lisible : « 840 Ko », « 1,2 Mo ». */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
if (bytes < 1000) return `${Math.round(bytes)} o`;
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} Ko`;
const mo = bytes / 1_000_000;
// Une décimale jusqu'à 10 Mo, virgule française.
const value = mo < 10 ? mo.toFixed(1).replace(".", ",") : String(Math.round(mo));
return `${value} Mo`;
}
/**
* Libellé du rapport largeur/hauteur : un format courant quand il en approche
* un à 2 % près, sinon une approximation décimale plutôt qu'un « 1083:721 »
* illisible.
*/
export function aspectLabel(width: number, height: number): string {
if (!isValid({ width, height, bytes: 0 })) return "";
const ratio = width / height;
for (const known of KNOWN_RATIOS) {
if (Math.abs(ratio - known.ratio) / known.ratio <= SQUARE_TOLERANCE) return known.label;
}
return ratio >= 1
? `${ratio.toFixed(2).replace(".", ",")}:1`
: `1:${(1 / ratio).toFixed(2).replace(".", ",")}`;
}
/** `true` si l'image est carrée à la tolérance près. */
export function isSquare(info: ImageInfo): boolean {
if (!isValid(info)) return false;
return Math.abs(info.width / info.height - 1) <= SQUARE_TOLERANCE;
}
/** Orientation en toutes lettres, pour accompagner le ratio. */
export function orientationLabel(info: ImageInfo): string {
if (!isValid(info)) return "";
if (isSquare(info)) return "carré";
return info.width > info.height ? "paysage" : "portrait";
}
/** « 1080 × 1350 px · portrait 4:5 · 420 Ko ». */
export function imageSummary(info: ImageInfo): string {
if (!isValid(info)) return "";
const parts = [
`${Math.round(info.width)} × ${Math.round(info.height)} px`,
`${orientationLabel(info)} ${aspectLabel(info.width, info.height)}`.trim(),
];
const weight = formatBytes(info.bytes);
if (weight) parts.push(weight);
return parts.join(" · ");
}
export type ImageNote = { tone: "warn" | "info"; text: string };
/**
* Remarques à afficher sous l'aperçu. Elles **informent**, elles ne bloquent
* pas : un visuel non carré reste publiable, l'adhérent doit seulement savoir
* ce que les réseaux en feront.
*/
export function imageNotes(info: ImageInfo): ImageNote[] {
if (!isValid(info)) return [];
const notes: ImageNote[] = [];
if (!isSquare(info)) {
notes.push({
tone: "warn",
text:
`Format ${aspectLabel(info.width, info.height)} : sur Facebook et LinkedIn, l'image sera ` +
`recadrée ou entourée de bandes de couleur. Une image carrée (${IDEAL_SIDE} × ${IDEAL_SIDE} px) ` +
`s'affiche entière.`,
});
}
if (Math.min(info.width, info.height) < MIN_SIDE) {
notes.push({
tone: "warn",
text: `Image de petite taille : elle paraîtra floue une fois agrandie. Visez ${IDEAL_SIDE} px de côté.`,
});
}
if (info.bytes > MAX_BYTES) {
notes.push({
tone: "warn",
text: `Fichier lourd (${formatBytes(info.bytes)}) : l'envoi peut être refusé. Compressez l'image avant de la déposer.`,
});
}
if (notes.length === 0) {
notes.push({ tone: "info", text: "Format idéal : l'image s'affichera entière sur le site et sur les réseaux." });
}
return notes;
}
+142
View File
@@ -0,0 +1,142 @@
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { db } from "@/db";
import { informationReads, informations, users } from "@/db/schema";
/**
* Lectures du fil d'informations.
*
* Ce module vit hors de `backend/actions.ts` parce qu'il a plusieurs appelants
* qui ne sont pas des actions : le gabarit du back-office (pour le compteur de
* non-lues de la barre latérale) et les trois pages de l'espace adhérent.
*/
export type FeedItem = {
id: number;
title: string;
body: string;
imageUrl: string | null;
pinned: boolean;
publishedAt: Date | null;
authorName: string | null;
unread: boolean;
};
export type AdminItem = {
id: number;
title: string;
body: string;
imageUrl: string | null;
status: "draft" | "published";
pinned: boolean;
publishedAt: Date | null;
emailSentAt: Date | null;
updatedAt: Date;
authorName: string | null;
readCount: number;
};
/** Épinglée d'abord, puis la plus récente : l'ordre est le même partout. */
const FEED_ORDER = [desc(informations.pinned), desc(informations.publishedAt), desc(informations.id)];
/**
* Le fil tel que le voit un adhérent, avec l'état de lecture **avant** la
* visite : c'est ce qui permet d'afficher les pastilles « Nouveau » sur le
* rendu même où elles sont marquées comme lues.
*/
export async function getMemberFeed(userId: number, limit?: number): Promise<FeedItem[]> {
const query = db
.select({
id: informations.id,
title: informations.title,
body: informations.body,
imageUrl: informations.imageUrl,
pinned: informations.pinned,
publishedAt: informations.publishedAt,
authorName: users.name,
readId: informationReads.id,
})
.from(informations)
.leftJoin(users, eq(users.id, informations.authorId))
.leftJoin(
informationReads,
and(eq(informationReads.informationId, informations.id), eq(informationReads.userId, userId)),
)
.where(eq(informations.status, "published"))
.orderBy(...FEED_ORDER);
const rows = await (limit ? query.limit(limit) : query);
return rows.map(({ readId, ...row }) => ({ ...row, unread: readId === null }));
}
export async function countPublishedInformations(): Promise<number> {
const [row] = await db
.select({ total: sql<number>`count(*)::int` })
.from(informations)
.where(eq(informations.status, "published"));
return row?.total ?? 0;
}
/** Compteur du badge : publiées moins déjà lues par cet utilisateur. */
export async function unreadInformationCount(userId: number): Promise<number> {
const [row] = await db
.select({ total: sql<number>`count(*)::int` })
.from(informations)
.leftJoin(
informationReads,
and(eq(informationReads.informationId, informations.id), eq(informationReads.userId, userId)),
)
.where(and(eq(informations.status, "published"), isNull(informationReads.id)));
return row?.total ?? 0;
}
/**
* Marque comme lues les informations passées. Idempotent : la contrainte
* d'unicité `(user_id, information_id)` absorbe les visites répétées, un
* double clic ou deux onglets ouverts.
*/
export async function markInformationsRead(userId: number, ids: number[]): Promise<void> {
if (ids.length === 0) return;
await db
.insert(informationReads)
.values(ids.map((informationId) => ({ informationId, userId })))
.onConflictDoNothing();
}
/** Liste du back-office : brouillons compris, avec le nombre de lectures. */
export async function getAdminInformations(): Promise<AdminItem[]> {
const rows = await db
.select({
id: informations.id,
title: informations.title,
body: informations.body,
imageUrl: informations.imageUrl,
status: informations.status,
pinned: informations.pinned,
publishedAt: informations.publishedAt,
emailSentAt: informations.emailSentAt,
updatedAt: informations.updatedAt,
authorName: users.name,
readCount: sql<number>`(
select count(*)::int from ${informationReads}
where ${informationReads.informationId} = ${informations.id}
)`,
})
.from(informations)
.leftJoin(users, eq(users.id, informations.authorId))
.orderBy(desc(informations.status), ...FEED_ORDER, desc(informations.updatedAt));
return rows;
}
export async function getInformation(id: number) {
const [row] = await db.select().from(informations).where(eq(informations.id, id));
return row ?? null;
}
/** Nombre de comptes adhérents destinataires : le dénominateur du « lue par ». */
export async function countMemberAccounts(): Promise<number> {
const [row] = await db
.select({ total: sql<number>`count(*)::int` })
.from(users)
.where(eq(users.role, "member"));
return row?.total ?? 0;
}
+532
View File
@@ -0,0 +1,532 @@
import { eq, ne } from "drizzle-orm";
import { db } from "@/db";
import { mailAccounts } from "@/db/schema";
import type { MailAccount, MailProvider } from "@/db/schema";
import { decryptSecret, encryptSecret } from "@/lib/crypto";
/**
* Boîte mail de l'association : configuration, connexion, santé.
*
* Décalque volontaire de `src/lib/social-accounts.ts` — mêmes règles, mêmes
* garanties : les secrets sont chiffrés en base, ne repartent jamais vers le
* navigateur, un champ laissé vide conserve celui déjà enregistré, et le
* contrôle de santé ne lève jamais.
*
* Trois transports, un seul actif à la fois. Pas de cascade automatique : si
* Google se bloque, c'est l'administrateur qui bascule, et il le voit.
*/
export const MAIL_PROVIDERS: MailProvider[] = ["google", "microsoft", "smtp"];
export const MAIL_LABELS: Record<MailProvider, string> = {
google: "Google — Gmail / Workspace",
microsoft: "Microsoft — Outlook / Hotmail / 365",
smtp: "Autre serveur (SMTP)",
};
/** Le jeton d'accès est renouvelé un peu avant l'échéance annoncée. */
const REFRESH_MARGIN_MS = 5 * 60_000;
function env(key: string): string {
return (process.env[key] ?? "").trim();
}
// ---- Lecture ----
export async function getMailAccount(provider: MailProvider): Promise<MailAccount | null> {
const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.provider, provider));
return row ?? null;
}
export async function getMailAccounts(): Promise<MailAccount[]> {
return db.select().from(mailAccounts);
}
export async function getActiveMailAccount(): Promise<MailAccount | null> {
const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.isActive, true));
return row ?? null;
}
export type MailSender = {
provider: MailProvider;
fromAddress: string;
fromName: string;
/** Google / Microsoft : jeton valide. SMTP : chaîne vide. */
accessToken: string;
smtp?: { host: string; port: number; secure: boolean; user: string; password: string };
};
/**
* Le transport prêt à expédier, ou `null` si rien n'est configuré.
*
* Aucun repli sur l'environnement pour les jetons OAuth — ils ne peuvent
* venir que du parcours de connexion — mais les réglages SMTP restent lisibles
* depuis l'environnement, ce qui permet un premier déploiement sans passer par
* l'écran.
*/
export async function resolveMailSender(): Promise<MailSender | null> {
const account = await getActiveMailAccount();
if (account?.provider === "smtp") {
const password = safeDecrypt(account.smtpPassword);
if (account.smtpHost && account.smtpUser && password) {
return {
provider: "smtp",
fromAddress: account.fromAddress || account.smtpUser,
fromName: account.fromName ?? "",
accessToken: "",
smtp: {
host: account.smtpHost,
port: account.smtpPort ?? 465,
secure: account.smtpSecure,
user: account.smtpUser,
password,
},
};
}
}
if (account && (account.provider === "google" || account.provider === "microsoft")) {
const accessToken = await ensureAccessToken(account.provider);
if (accessToken && account.fromAddress) {
return {
provider: account.provider,
fromAddress: account.fromAddress,
fromName: account.fromName ?? "",
accessToken,
};
}
}
// Repli d'environnement : utile avant toute configuration depuis l'écran.
const host = env("SMTP_HOST");
const user = env("SMTP_USER");
const password = env("SMTP_PASSWORD");
if (host && user && password) {
return {
provider: "smtp",
fromAddress: env("SMTP_FROM") || user,
fromName: env("SMTP_FROM_NAME"),
accessToken: "",
smtp: { host, port: Number(env("SMTP_PORT") || 465), secure: env("SMTP_SECURE") !== "false", user, password },
};
}
return null;
}
export async function isMailConfigured(): Promise<boolean> {
return (await resolveMailSender()) !== null;
}
function safeDecrypt(value: string | null): string | null {
if (!value) return null;
try {
return decryptSecret(value);
} catch {
// Clé de chiffrement changée : on préfère « non configuré » à une erreur.
return null;
}
}
// ---- Écriture ----
export async function saveOAuthApp(provider: MailProvider, appId: string, appSecret: string | null) {
const existing = await getMailAccount(provider);
if (!existing) {
if (!appSecret) throw new Error("Le secret de l'application est requis à la première saisie.");
await db.insert(mailAccounts).values({ provider, appId, appSecret: encryptSecret(appSecret), updatedAt: new Date() });
return;
}
await db
.update(mailAccounts)
.set({
appId,
// Champ laissé vide = on conserve le secret déjà enregistré.
...(appSecret ? { appSecret: encryptSecret(appSecret) } : {}),
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
}
export type SmtpSettings = {
host: string;
port: number;
secure: boolean;
user: string;
password: string | null;
fromAddress: string;
fromName: string;
};
export async function saveSmtpAccount(settings: SmtpSettings) {
const existing = await getMailAccount("smtp");
const shared = {
smtpHost: settings.host,
smtpPort: settings.port,
smtpSecure: settings.secure,
smtpUser: settings.user,
fromAddress: settings.fromAddress || settings.user,
fromName: settings.fromName,
updatedAt: new Date(),
// Les réglages changent : le dernier verdict ne vaut plus rien.
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
};
if (!existing) {
if (!settings.password) throw new Error("Le mot de passe est requis à la première saisie.");
await db.insert(mailAccounts).values({ provider: "smtp", ...shared, smtpPassword: encryptSecret(settings.password) });
return;
}
await db
.update(mailAccounts)
.set({ ...shared, ...(settings.password ? { smtpPassword: encryptSecret(settings.password) } : {}) })
.where(eq(mailAccounts.provider, "smtp"));
}
export async function saveMailConnection(input: {
provider: MailProvider;
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
fromAddress: string;
fromName?: string | null;
connectedById: number | null;
}) {
const existing = await getMailAccount(input.provider);
const values = {
accessToken: encryptSecret(input.accessToken),
// Microsoft fait tourner le jeton de rafraîchissement à chaque
// renouvellement ; un `null` ici ne doit pas effacer celui en place.
...(input.refreshToken ? { refreshToken: encryptSecret(input.refreshToken) } : {}),
expiresAt: input.expiresAt,
fromAddress: input.fromAddress,
...(input.fromName ? { fromName: input.fromName } : {}),
connectedById: input.connectedById,
connectedAt: new Date(),
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
updatedAt: new Date(),
};
if (!existing) {
await db.insert(mailAccounts).values({ provider: input.provider, ...values });
return;
}
await db.update(mailAccounts).set(values).where(eq(mailAccounts.provider, input.provider));
}
/** Désigne le fournisseur expéditeur ; les autres restent configurés. */
export async function setActiveMailProvider(provider: MailProvider) {
await db.update(mailAccounts).set({ isActive: false }).where(ne(mailAccounts.provider, provider));
await db.update(mailAccounts).set({ isActive: true, updatedAt: new Date() }).where(eq(mailAccounts.provider, provider));
}
/**
* Coupe la connexion sans effacer les identifiants d'application : une
* reconnexion ne redemande pas de recopier l'identifiant et le secret.
*/
export async function disconnectMailAccount(provider: MailProvider) {
await db
.update(mailAccounts)
.set({
accessToken: null,
refreshToken: null,
expiresAt: null,
fromAddress: null,
isActive: false,
lastCheckAt: null,
lastCheckOk: null,
lastCheckError: null,
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
}
export async function getDecryptedAppSecret(provider: MailProvider): Promise<string | null> {
const account = await getMailAccount(provider);
return safeDecrypt(account?.appSecret ?? null);
}
// ---- Jetons OAuth ----
const TOKEN_ENDPOINT: Record<"google" | "microsoft", string> = {
google: "https://oauth2.googleapis.com/token",
microsoft: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
};
/**
* Jeton d'accès valide, renouvelé si besoin. Appelée par l'expéditeur, jamais
* par le rendu d'une page : un renouvellement est une écriture.
*/
export async function ensureAccessToken(provider: "google" | "microsoft"): Promise<string | null> {
const account = await getMailAccount(provider);
if (!account) return null;
const current = safeDecrypt(account.accessToken);
const stillValid =
current && account.expiresAt && new Date(account.expiresAt).getTime() - Date.now() > REFRESH_MARGIN_MS;
if (stillValid) return current;
const refreshToken = safeDecrypt(account.refreshToken);
const appSecret = safeDecrypt(account.appSecret);
if (!refreshToken || !account.appId || !appSecret) return current;
try {
const res = await fetch(TOKEN_ENDPOINT[provider], {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: account.appId,
client_secret: appSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) return current;
const payload = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!payload.access_token) return current;
await db
.update(mailAccounts)
.set({
accessToken: encryptSecret(payload.access_token),
// Microsoft renvoie un nouveau jeton de rafraîchissement à chaque
// appel et invalide l'ancien : ne pas l'enregistrer condamnerait
// l'envoi au bout d'une heure.
...(payload.refresh_token ? { refreshToken: encryptSecret(payload.refresh_token) } : {}),
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null,
updatedAt: new Date(),
})
.where(eq(mailAccounts.provider, provider));
return payload.access_token;
} catch {
return current;
}
}
// ---- Santé ----
export type MailHealth = { ok: true; detail: string } | { ok: false; reason: string };
async function rememberCheck(provider: MailProvider, health: MailHealth) {
await db
.update(mailAccounts)
.set({
lastCheckAt: new Date(),
lastCheckOk: health.ok,
lastCheckError: health.ok ? null : health.reason.slice(0, 2000),
})
.where(eq(mailAccounts.provider, provider));
}
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
/**
* Interroge le fournisseur. Ne lève jamais : un échec est un verdict, pas une
* exception — l'écran doit pouvoir l'afficher.
*/
export async function checkMailHealth(provider: MailProvider): Promise<MailHealth | null> {
const account = await getMailAccount(provider);
if (!account) return null;
try {
if (provider === "smtp") {
const password = safeDecrypt(account.smtpPassword);
if (!account.smtpHost || !account.smtpUser || !password) return null;
const { verifySmtp } = await import("@/lib/mailer");
const health = await verifySmtp({
host: account.smtpHost,
port: account.smtpPort ?? 465,
secure: account.smtpSecure,
user: account.smtpUser,
password,
});
await rememberCheck(provider, health);
return health;
}
const token = await ensureAccessToken(provider);
if (!token) return null;
const url =
provider === "google"
? "https://gmail.googleapis.com/gmail/v1/users/me/profile"
: "https://graph.microsoft.com/v1.0/me";
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const health: MailHealth = res.ok
? { ok: true, detail: `Boîte accessible (${account.fromAddress ?? "compte connecté"})` }
: { ok: false, reason: await readError(res) };
await rememberCheck(provider, health);
return health;
} catch (error) {
const health: MailHealth = {
ok: false,
reason: error instanceof Error ? error.message : "Fournisseur injoignable",
};
await rememberCheck(provider, health);
return health;
}
}
/** Verdict mis en cache : évite un appel réseau à chaque affichage. */
export async function mailHealthCached(
provider: MailProvider,
maxAgeMs = 6 * 3_600_000
): Promise<MailHealth | null> {
const account = await getMailAccount(provider);
if (!account) 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ée récemment" }
: { ok: false, reason: account.lastCheckError ?? "Boîte refusée" };
}
return checkMailHealth(provider);
}
// ---- Parcours OAuth ----
export class MailAuthError extends Error {}
/**
* Portées demandées.
*
* Google : `gmail.send` est classée *sensible*. `https://mail.google.com/`,
* qu'exigerait SMTP avec XOAUTH2, est *restreinte* et déclenche un audit de
* sécurité — d'où le choix de l'API Gmail.
*
* Microsoft : délégué, sur l'autorité `/common`, pour accepter aussi bien une
* boîte professionnelle qu'un compte outlook.com ou hotmail.com personnel.
*/
const SCOPES: Record<"google" | "microsoft", string> = {
google: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.email",
microsoft: "offline_access Mail.Send User.Read",
};
export async function mailRedirectUri(provider: MailProvider, base: string): Promise<string> {
return `${base.replace(/\/+$/, "")}/api/mail/${provider}/callback`;
}
export async function mailAuthorizeUrl(
provider: "google" | "microsoft",
appId: string,
state: string,
base: string
): Promise<string> {
const redirectUri = await mailRedirectUri(provider, base);
if (provider === "google") {
return `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri,
response_type: "code",
scope: SCOPES.google,
// Sans `offline` et `consent`, Google ne délivre pas de jeton de
// rafraîchissement au deuxième passage : l'envoi mourrait au bout d'une
// heure sans raison visible.
access_type: "offline",
prompt: "consent",
include_granted_scopes: "true",
state,
})}`;
}
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${new URLSearchParams({
client_id: appId,
redirect_uri: redirectUri,
response_type: "code",
response_mode: "query",
scope: SCOPES.microsoft,
state,
})}`;
}
export type MailExchange = {
accessToken: string;
refreshToken: string | null;
expiresAt: Date | null;
fromAddress: string;
fromName: string | null;
};
/**
* Échange le code contre des jetons, puis **lit l'adresse chez le
* fournisseur** : Gmail expédie comme l'utilisateur authentifié et Graph comme
* la boîte. Laisser l'administrateur saisir une autre adresse ferait tomber
* SPF et DKIM, et tous les messages partiraient en indésirable.
*/
export async function exchangeMailCode(
provider: "google" | "microsoft",
appId: string,
appSecret: string,
code: string,
base: string
): Promise<MailExchange> {
const res = await fetch(TOKEN_ENDPOINT[provider], {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: appId,
client_secret: appSecret,
code,
redirect_uri: await mailRedirectUri(provider, base),
grant_type: "authorization_code",
}),
});
if (!res.ok) throw new MailAuthError(await readError(res));
const payload = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!payload.access_token) throw new MailAuthError("Aucun jeton d'accès renvoyé.");
const identity = await readIdentity(provider, payload.access_token);
if (!identity.address) {
throw new MailAuthError("Impossible de lire l'adresse de la boîte connectée.");
}
return {
accessToken: payload.access_token,
refreshToken: payload.refresh_token ?? null,
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null,
fromAddress: identity.address,
fromName: identity.name,
};
}
async function readIdentity(
provider: "google" | "microsoft",
accessToken: string
): Promise<{ address: string; name: string | null }> {
if (provider === "google") {
const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new MailAuthError(await readError(res));
const profile = (await res.json()) as { emailAddress?: string };
return { address: profile.emailAddress ?? "", name: null };
}
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new MailAuthError(await readError(res));
const profile = (await res.json()) as { mail?: string; userPrincipalName?: string; displayName?: string };
return { address: profile.mail ?? profile.userPrincipalName ?? "", name: profile.displayName ?? null };
}
+243
View File
@@ -0,0 +1,243 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { mailMessages } from "@/db/schema";
import type { MailKind } from "@/db/schema";
import { sendNow } from "@/lib/mailer";
/**
* File d'attente d'envoi.
*
* Une diffusion à quatre-vingts adhérents ne peut pas partir dans la requête
* qui l'a déclenchée : la page attendrait quatre-vingts allers-retours. Les
* messages sont donc écrits en base et vidés par la boucle de fond, sur le
* modèle de `releaseDuePromotions`.
*
* Une ligne par destinataire, jamais de copie partagée : `to_address` est un
* `varchar` unique, la confidentialité est structurelle.
*
* **Les mots de passe temporaires ne passent pas par ici.** Le clair n'existe
* que dans la portée de l'action qui les émet, et `mail_messages.html` est
* stocké en base : ils partent en ligne directe (`sendNow`), et seule une
* trace sans contenu est journalisée.
*/
/** Débit par passage. Gmail bride autour de quelques centaines par jour. */
const DEFAULT_BATCH = Number(process.env.MAIL_RATE_PER_MINUTE ?? 20);
/** Au-delà, le message est abandonné : cinq échecs ne sont pas un incident réseau. */
export const MAX_ATTEMPTS = 5;
/** Un verrou plus vieux que cela vient d'un conteneur mort en plein envoi. */
const STALE_LOCK_MS = 10 * 60_000;
export type QueuedMail = {
kind: MailKind;
toAddress: string;
toName?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
informationId?: number | null;
meetingId?: number | null;
memberId?: number | null;
createdById?: number | null;
};
/** Attente avant la prochaine tentative : 2, 4, 8, 16 minutes. */
export function backoffDelayMs(attempts: number): number {
const capped = Math.min(Math.max(attempts, 1), MAX_ATTEMPTS);
return 2 ** capped * 60_000;
}
export function shouldGiveUp(attempts: number): boolean {
return attempts >= MAX_ATTEMPTS;
}
export async function queueMails(mails: QueuedMail[]): Promise<number> {
if (mails.length === 0) return 0;
const rows = await db.insert(mailMessages).values(mails).returning({ id: mailMessages.id });
return rows.length;
}
export async function queueMail(mail: QueuedMail): Promise<number> {
return queueMails([mail]);
}
/**
* Journalise un envoi déjà parti, sans son contenu.
*
* Sert aux mots de passe temporaires : le journal doit montrer qu'un message
* est parti, à qui et quand, sans que le corps — donc le mot de passe — ne
* touche la base.
*/
export async function logSentMail(entry: {
kind: MailKind;
toAddress: string;
subject: string;
memberId?: number | null;
createdById?: number | null;
result: { ok: true } | { ok: false; reason: string };
}) {
await db.insert(mailMessages).values({
kind: entry.kind,
toAddress: entry.toAddress,
subject: entry.subject,
html: "",
memberId: entry.memberId ?? null,
createdById: entry.createdById ?? null,
status: entry.result.ok ? "sent" : "failed",
attempts: 1,
sentAt: entry.result.ok ? new Date() : null,
error: entry.result.ok ? null : entry.result.reason.slice(0, 2000),
});
}
type ClaimedMail = {
id: number;
toAddress: string;
toName: string | null;
subject: string;
html: string;
text: string | null;
replyTo: string | null;
attempts: number;
};
/** Ligne telle que la renvoie le pilote : noms de colonnes bruts. */
type ClaimedRow = {
id: number;
to_address: string;
to_name: string | null;
subject: string;
html: string;
text: string | null;
reply_to: string | null;
attempts: number;
};
/**
* Réclame un lot de messages à envoyer.
*
* `FOR UPDATE SKIP LOCKED` : deux instances peuvent vider la file en parallèle
* sans jamais expédier deux fois le même message. `releaseDuePromotions` s'en
* passe parce que la transition de statut y fait office de verrou ; ici le
* passage en `sending` doit être exclusif avant l'appel réseau.
*/
async function claimDueMails(limit: number): Promise<ClaimedMail[]> {
const stale = new Date(Date.now() - STALE_LOCK_MS);
// Un conteneur arrêté en plein envoi laisse des lignes en `sending` :
// on les remet en file avant de servir le lot suivant.
await db
.update(mailMessages)
.set({ status: "queued", lockedAt: null })
.where(and(eq(mailMessages.status, "sending"), sql`${mailMessages.lockedAt} < ${stale}`));
const { rows } = await db.execute<ClaimedRow>(sql`
update ${mailMessages}
set status = 'sending', locked_at = now()
where id in (
select id from ${mailMessages}
where status = 'queued' and next_attempt_at <= now()
order by id
limit ${limit}
for update skip locked
)
returning id, to_address, to_name, subject, html, text, reply_to, attempts
`);
// `db.execute` rend les colonnes telles quelles : contrairement au
// constructeur de requêtes, il ne rebaptise pas `to_address` en `toAddress`.
// Sans cette conversion le destinataire arrivait vide chez nodemailer.
return rows.map((row) => ({
id: row.id,
toAddress: row.to_address,
toName: row.to_name,
subject: row.subject,
html: row.html,
text: row.text,
replyTo: row.reply_to,
attempts: Number(row.attempts),
}));
}
export type OutboxReport = { sent: number; failed: number };
/** Vide un lot de la file. Ne lève jamais : c'est une boucle de fond. */
export async function processOutbox(limit = DEFAULT_BATCH): Promise<OutboxReport> {
const report: OutboxReport = { sent: 0, failed: 0 };
let claimed: ClaimedMail[] = [];
try {
claimed = await claimDueMails(limit);
} catch (error) {
console.error("[mail] réclamation :", error);
return report;
}
for (const message of claimed) {
const result = await sendNow({
to: message.toAddress,
toName: message.toName,
subject: message.subject,
html: message.html,
text: message.text,
replyTo: message.replyTo,
});
if (result.ok) {
report.sent += 1;
await db
.update(mailMessages)
.set({ status: "sent", sentAt: new Date(), provider: result.provider, lockedAt: null, error: null })
.where(eq(mailMessages.id, message.id));
continue;
}
report.failed += 1;
const attempts = message.attempts + 1;
const exhausted = shouldGiveUp(attempts);
await db
.update(mailMessages)
.set({
status: exhausted ? "failed" : "queued",
attempts,
lockedAt: null,
nextAttemptAt: new Date(Date.now() + backoffDelayMs(attempts)),
error: result.reason.slice(0, 2000),
})
.where(eq(mailMessages.id, message.id));
}
return report;
}
// ---- Journal ----
export async function recentMails(limit = 40) {
return db.select().from(mailMessages).orderBy(desc(mailMessages.id)).limit(limit);
}
export async function mailCountsFor(informationId: number) {
const rows = await db
.select({ status: mailMessages.status, total: sql<number>`count(*)::int` })
.from(mailMessages)
.where(eq(mailMessages.informationId, informationId))
.groupBy(mailMessages.status);
return rows.reduce<Record<string, number>>((acc, row) => ({ ...acc, [row.status]: row.total }), {});
}
/** Remet un message échoué en file, immédiatement. */
export async function retryMailMessage(id: number) {
await db
.update(mailMessages)
.set({ status: "queued", attempts: 0, nextAttemptAt: new Date(), lockedAt: null, error: null })
.where(and(eq(mailMessages.id, id), eq(mailMessages.status, "failed")));
}
/** Annule un message encore en attente ; un message parti ne s'annule pas. */
export async function cancelMailMessage(id: number) {
await db
.update(mailMessages)
.set({ status: "cancelled", lockedAt: null })
.where(and(eq(mailMessages.id, id), eq(mailMessages.status, "queued")));
}
+122
View File
@@ -0,0 +1,122 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { categories, meetingRegistrations, members, users } from "@/db/schema";
/**
* Qui reçoit quoi.
*
* L'adresse retenue est **`members.email`**, l'adresse administrative qui sert
* aussi d'identifiant de connexion — jamais `members.contact_email`, qui est
* l'adresse publique de la fiche et peut pointer vers un standard.
*
* La partie pure (`dedupeRecipients`) est exportée pour être testée sans base :
* c'est elle qui garantit qu'un adhérent ne reçoit pas deux fois le même
* message, et que N destinataires donnent bien N envois.
*/
export type Recipient = { email: string; name: string; memberId: number | null };
export type AudienceKind = "all" | "category" | "members" | "meeting";
export type Audience =
| { kind: "all" }
| { kind: "category"; categoryId: number }
| { kind: "members"; memberIds: number[] }
| { kind: "meeting"; meetingId: number };
// Volontairement permissif : le rôle de ce contrôle est d'écarter les champs
// vides ou manifestement cassés, pas de réinventer la RFC 5322.
const PLAUSIBLE_EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]{2,}$/;
export function isPlausibleEmail(value: string): boolean {
return PLAUSIBLE_EMAIL.test(String(value ?? "").trim());
}
/**
* Écarte les adresses vides ou invalides et les doublons, insensiblement à la
* casse. L'ordre d'origine est conservé : la première occurrence gagne, avec
* son nom.
*/
export function dedupeRecipients(rows: { email: string | null; name?: string | null; memberId?: number | null }[]): Recipient[] {
const seen = new Set<string>();
const out: Recipient[] = [];
for (const row of rows) {
const email = String(row.email ?? "").trim();
if (!isPlausibleEmail(email)) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push({ email, name: String(row.name ?? "").trim(), memberId: row.memberId ?? null });
}
return out;
}
export async function resolveAudience(audience: Audience): Promise<Recipient[]> {
if (audience.kind === "meeting") {
const rows = await db
.select({ email: meetingRegistrations.attendeeEmail, name: meetingRegistrations.attendeeName, memberId: meetingRegistrations.memberId })
.from(meetingRegistrations)
.where(eq(meetingRegistrations.meetingId, audience.meetingId))
.orderBy(asc(meetingRegistrations.id));
return dedupeRecipients(rows);
}
const base = db
.select({ email: members.email, name: members.name, memberId: members.id })
.from(members)
.$dynamic();
if (audience.kind === "category") {
return dedupeRecipients(
await base
.where(and(eq(members.status, "active"), eq(members.categoryId, audience.categoryId)))
.orderBy(asc(members.name)),
);
}
if (audience.kind === "members") {
if (audience.memberIds.length === 0) return [];
return dedupeRecipients(await base.where(inArray(members.id, audience.memberIds)).orderBy(asc(members.name)));
}
return dedupeRecipients(await base.where(eq(members.status, "active")).orderBy(asc(members.name)));
}
/** Les adhérents actifs, pour la diffusion d'une information. */
export async function activeMemberRecipients(): Promise<Recipient[]> {
return resolveAudience({ kind: "all" });
}
export type AudienceOption = { value: string; label: string; count: number };
/** Options proposées par le sélecteur de destinataires. */
export async function audienceOptions(): Promise<{ all: number; categories: AudienceOption[] }> {
const all = (await activeMemberRecipients()).length;
const rows = await db
.select({ id: categories.id, label: categories.label, email: members.email, memberId: members.id })
.from(categories)
.innerJoin(members, and(eq(members.categoryId, categories.id), eq(members.status, "active")))
.orderBy(asc(categories.sort), asc(categories.label));
const grouped = new Map<number, { label: string; rows: { email: string | null; memberId: number }[] }>();
for (const row of rows) {
const entry = grouped.get(row.id) ?? { label: row.label, rows: [] };
entry.rows.push({ email: row.email, memberId: row.memberId });
grouped.set(row.id, entry);
}
return {
all,
categories: [...grouped.entries()].map(([id, entry]) => ({
value: String(id),
label: entry.label,
count: dedupeRecipients(entry.rows).length,
})),
};
}
/** L'adresse de l'utilisateur courant, pour « m'envoyer un test ». */
export async function selfRecipient(userId: number): Promise<Recipient | null> {
const [row] = await db.select({ email: users.email, name: users.name }).from(users).where(eq(users.id, userId));
return dedupeRecipients(row ? [{ ...row, memberId: null }] : [])[0] ?? null;
}
+168
View File
@@ -0,0 +1,168 @@
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
import { describeSmtpError } from "@/lib/smtp-config";
/**
* Expédition d'un message.
*
* Trois transports, deux familles :
*
* - **Google** passe par l'API Gmail (`users.messages.send`) et non par SMTP
* avec XOAUTH2 : celui-ci exigerait la portée `https://mail.google.com/`,
* classée « restreinte », donc un audit de sécurité. `gmail.send` est une
* portée simplement « sensible ».
* - **Microsoft** passe par Graph (`/me/sendMail`) : l'authentification
* basique SMTP est désactivée par défaut depuis 2024, y compris sur les
* boîtes outlook.com et hotmail.com.
* - **SMTP** couvre tout le reste (mot de passe d'application Gmail, Outlook
* professionnel, OVH, Ionos…) via `nodemailer`.
*
* `sendNow` ne lève jamais : elle renvoie un verdict, que la file d'attente ou
* l'action appelante affiche. Un envoi raté ne doit pas faire tomber la page
* qui l'a déclenché.
*/
export type OutgoingMail = {
to: string;
toName?: string | null;
subject: string;
html: string;
text?: string | null;
replyTo?: string | null;
};
export type SendResult =
| { ok: true; provider: MailSender["provider"] }
| { ok: false; reason: string };
export type SmtpConfig = { host: string; port: number; secure: boolean; user: string; password: string };
async function readError(res: Response): Promise<string> {
const body = await res.text().catch(() => "");
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
}
export async function sendNow(mail: OutgoingMail): Promise<SendResult> {
const sender = await resolveMailSender();
if (!sender) {
return { ok: false, reason: "Aucune boîte mail n'est configurée (Backend › Boîte mail)." };
}
try {
if (sender.provider === "google") return await sendViaGmail(sender, mail);
if (sender.provider === "microsoft") return await sendViaGraph(sender, mail);
return await sendViaSmtp(sender, mail);
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "Envoi impossible" };
}
}
async function sendViaGmail(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
// Le champ `raw` de l'API Gmail attend le message RFC 822 en base64url.
const raw = toBase64Url(
utf8ToBase64(
buildMimeMessage({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
}),
),
);
const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", {
method: "POST",
headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ raw }),
});
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, provider: "google" };
}
async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
// Graph prend du JSON : aucun MIME à construire de ce côté.
const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", {
method: "POST",
headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
message: {
subject: mail.subject,
body: { contentType: "HTML", content: mail.html },
toRecipients: [{ emailAddress: { address: mail.to, name: mail.toName ?? undefined } }],
...(mail.replyTo ? { replyTo: [{ emailAddress: { address: mail.replyTo } }] } : {}),
},
saveToSentItems: true,
}),
});
if (!res.ok) return { ok: false, reason: await readError(res) };
return { ok: true, provider: "microsoft" };
}
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
const transport = await smtpTransport(sender.smtp);
try {
await transport.sendMail({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
});
} catch (error) {
// Le verdict part dans le journal d'envoi : il doit se lire sans trace OpenSSL.
return { ok: false, reason: describeSmtpError(error instanceof Error ? error.message : String(error), sender.smtp) };
}
return { ok: true, provider: "smtp" };
}
/**
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
* jamais entrer dans un bundle qui ne les a pas.
*
* Deux réglages qui ne sont pas des détails :
*
* - `requireTLS` hors TLS implicite : sans lui, STARTTLS est *opportuniste*.
* Un serveur qui ne l'annonce pas — ou un intermédiaire qui retire la
* capacité de la réponse EHLO — ferait partir l'identifiant et le mot de
* passe en clair sans que rien ne le signale. On préfère l'échec.
* - les délais : ceux de `nodemailer` se comptent en minutes, or ces
* connexions sont ouvertes depuis une action serveur. Un port filtré en
* sortie — cas courant chez les hébergeurs — doit rendre son verdict en
* quelques secondes, pas bloquer l'écran.
*/
async function smtpTransport(config: SmtpConfig) {
const nodemailer = (await import("nodemailer")).default;
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
requireTLS: !config.secure,
auth: { user: config.user, pass: config.password },
connectionTimeout: 12_000,
greetingTimeout: 12_000,
socketTimeout: 30_000,
// SNI explicite : sans lui, un hôte joint par IP casse la validation.
tls: { servername: config.host, minVersion: "TLSv1.2" },
});
}
/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */
export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> {
const transport = await smtpTransport(config).catch(() => null);
if (!transport) return { ok: false, reason: "Transport SMTP indisponible." };
try {
await transport.verify();
const how = config.secure ? "chiffrée dès l'ouverture" : "STARTTLS";
return { ok: true, detail: `Connexion établie avec ${config.host} sur le port ${config.port} (${how})` };
} catch (error) {
return { ok: false, reason: describeSmtpError(error instanceof Error ? error.message : "", config) };
} finally {
// Sans cela, un échec laisserait la socket au pool jusqu'à son expiration.
transport.close();
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Référent d'un adhérent : la personne à joindre, sa ligne directe.
*
* Ces informations sont **réservées aux visiteurs connectés** : elles ne
* doivent jamais partir dans le HTML public, le JSON-LD ou les métadonnées.
* Le module est pur — le filtrage par session se fait chez l'appelant, qui
* n'appelle `memberContact()` qu'après avoir vérifié la session, et verrouillé
* par `tests/member-contact.test.ts`.
*/
export type MemberContactSource = {
contactFirstName?: string | null;
contactLastName?: string | null;
contactPhone?: string | null;
};
export type MemberContact = {
/** « Prénom Nom », ou l'un des deux seulement. */
name: string | null;
phone: string | null;
};
function clean(value: string | null | undefined) {
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
return trimmed || null;
}
/** « Prénom Nom » à partir des deux champs, `null` si les deux sont vides. */
export function contactName(source: MemberContactSource): string | null {
return clean([clean(source.contactFirstName), clean(source.contactLastName)].filter(Boolean).join(" "));
}
/**
* Le référent affichable, ou `null` s'il n'y a rien à montrer : le bloc
* « Contact adhérent » disparaît alors plutôt que d'afficher un cadre vide.
*/
export function memberContact(source: MemberContactSource): MemberContact | null {
const name = contactName(source);
const phone = clean(source.contactPhone);
if (!name && !phone) return null;
return { name, phone };
}
/**
* `tel:` à partir d'un numéro saisi librement (« 03 83 24 .. », « +33 3 83 … »).
* Seuls les chiffres sont conservés, avec l'indicatif international s'il ouvre
* le numéro ; une saisie sans aucun chiffre ne produit pas de lien.
*/
export function telHref(phone: string | null | undefined): string | null {
const raw = clean(phone);
if (!raw) return null;
const international = raw.startsWith("+");
const digits = raw.replace(/\D/g, "");
if (!digits) return null;
return `tel:${international ? "+" : ""}${digits}`;
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Construction d'un message RFC 822.
*
* Module **pur** (verrouillé par `tests/mime.test.ts`) : il n'utilise que
* `TextEncoder` et `btoa`, disponibles aussi bien dans Node que dans le
* navigateur. Il sert donc deux appelants qui n'ont rien à voir :
*
* - `src/lib/email-client.ts`, qui fabrique le brouillon `.eml` téléchargé
* depuis le studio de composition ;
* - le transport Gmail (`src/lib/mailer.ts`), dont le champ `raw` attend
* exactement ce format, encodé en base64url.
*
* Tout corps est encodé en base64 : une frontière ne peut donc jamais
* apparaître dans la charge utile, et les sauts de ligne restent en CRLF —
* un LF isolé casse le « dot-stuffing » SMTP et se fait rejeter par Gmail.
*/
const CRLF = "\r\n";
/** Longueur maximale d'une ligne base64 (RFC 2045). */
const BASE64_LINE = 76;
/**
* Un mot encodé RFC 2047 ne doit pas dépasser 75 caractères. L'enveloppe
* `=?UTF-8?B?` + `?=` en consomme 12, il reste 63 caractères de base64, soit
* 45 octets de source (4 caractères base64 pour 3 octets).
*/
const HEADER_CHUNK_BYTES = 45;
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
export type MimeAttachment = {
filename: string;
contentType: string;
/** Contenu déjà encodé en base64, sans repli de ligne. */
base64: string;
/** Présent = pièce jointe en ligne, référencée par `cid:<contentId>`. */
contentId?: string;
};
export type MimeMessage = {
from?: string;
to?: string;
replyTo?: string;
subject: string;
html: string;
text?: string;
/** En-têtes libres ajoutés en tête (`X-Unsent`, `List-Unsubscribe`…). */
headers?: Record<string, string>;
inline?: MimeAttachment[];
/**
* Frontières imposées. Injectable pour que les tests décrivent une sortie
* stable ; en production elles sont tirées au hasard.
*/
boundaries?: string[];
};
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
// Par tranches : `String.fromCharCode(...bytes)` dépasse la pile d'appels
// au-delà de quelques dizaines de milliers d'octets.
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return globalThis.btoa(binary);
}
export function utf8ToBase64(value: string): string {
return bytesToBase64(new TextEncoder().encode(value));
}
export function wrapBase64(value: string): string {
return value.match(new RegExp(`.{1,${BASE64_LINE}}`, "g"))?.join(CRLF) ?? "";
}
/** Encodage attendu par le champ `raw` de l'API Gmail. */
export function toBase64Url(value: string): string {
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Valeur d'en-tête sûre. Les caractères hors ASCII imprimable passent en mots
* encodés RFC 2047, repliés si le texte est long ; un accent dans un objet ne
* doit jamais partir tel quel.
*/
export function encodeHeaderValue(value: string): string {
// Un CR ou un LF dans une valeur d'en-tête permettrait d'en injecter
// d'autres (un `Bcc:` par exemple) : on les réduit à une espace.
const clean = String(value ?? "").replace(/[\r\n]+/g, " ").trim();
if (PRINTABLE_ASCII.test(clean)) return clean;
const encoder = new TextEncoder();
const words: string[] = [];
let chunk: number[] = [];
// Itération par point de code : découper le tableau d'octets au ras des 45
// couperait une séquence UTF-8 en deux.
for (const char of clean) {
const bytes = encoder.encode(char);
if (chunk.length > 0 && chunk.length + bytes.length > HEADER_CHUNK_BYTES) {
words.push(`=?UTF-8?B?${bytesToBase64(Uint8Array.from(chunk))}?=`);
chunk = [];
}
chunk.push(...bytes);
}
if (chunk.length > 0) words.push(`=?UTF-8?B?${bytesToBase64(Uint8Array.from(chunk))}?=`);
return words.join(`${CRLF} `);
}
/** « Nom affiché » <adresse>, avec le nom encodé ou échappé selon les cas. */
export function formatAddress(email: string, name?: string | null): string {
const address = String(email ?? "").replace(/[\r\n<>,;]/g, "").trim();
const display = String(name ?? "").replace(/[\r\n]+/g, " ").trim();
if (!address) return "";
if (!display) return address;
if (PRINTABLE_ASCII.test(display)) {
return `"${display.replace(/(["\\])/g, "\\$1")}" <${address}>`;
}
return `${encodeHeaderValue(display)} <${address}>`;
}
function randomBoundary(index: number): string {
const noise = Math.random().toString(36).slice(2, 12);
return `----=_PleinR_${index}_${noise}`;
}
type Part = { headers: string[]; body: string };
function textPart(contentType: string, value: string): Part {
return {
headers: [`Content-Type: ${contentType}; charset=UTF-8`, "Content-Transfer-Encoding: base64"],
body: wrapBase64(utf8ToBase64(value)),
};
}
function attachmentPart(attachment: MimeAttachment): Part {
const disposition = attachment.contentId ? "inline" : "attachment";
const headers = [
`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`,
"Content-Transfer-Encoding: base64",
`Content-Disposition: ${disposition}; filename="${attachment.filename}"`,
];
if (attachment.contentId) headers.splice(2, 0, `Content-ID: <${attachment.contentId}>`);
return { headers, body: wrapBase64(attachment.base64) };
}
function multipart(subtype: string, boundary: string, parts: Part[], extra = ""): Part {
const body = [
...parts.flatMap((part) => [`--${boundary}`, ...part.headers, "", part.body]),
`--${boundary}--`,
].join(CRLF);
return {
headers: [`Content-Type: multipart/${subtype}; ${extra}boundary="${boundary}"`],
body,
};
}
/**
* Assemble le message complet. Structure choisie selon ce qui est fourni :
*
* html seul → text/html
* html + texte → multipart/alternative
* … + images en ligne → multipart/related enveloppant le précédent
*/
export function buildMimeMessage(message: MimeMessage): string {
const boundaryAt = (index: number) => message.boundaries?.[index] ?? randomBoundary(index);
const inline = message.inline ?? [];
let root: Part = textPart("text/html", message.html);
if (message.text) {
// L'ordre compte : le repli texte d'abord, la version préférée en dernier.
root = multipart("alternative", boundaryAt(0), [
textPart("text/plain", message.text),
root,
]);
}
if (inline.length > 0) {
root = multipart(
"related",
boundaryAt(1),
[root, ...inline.map(attachmentPart)],
'type="text/html"; ',
);
}
const headers: string[] = [];
for (const [key, value] of Object.entries(message.headers ?? {})) {
headers.push(`${key}: ${encodeHeaderValue(value)}`);
}
if (message.from) headers.push(`From: ${message.from}`);
if (message.to) headers.push(`To: ${message.to}`);
if (message.replyTo) headers.push(`Reply-To: ${message.replyTo}`);
headers.push(`Subject: ${encodeHeaderValue(message.subject)}`);
headers.push("MIME-Version: 1.0");
return [...headers, ...root.headers, "", root.body, ""].join(CRLF);
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Mise en ligne d'une promotion : diffusion sur les réseaux et libération des
* publications programmées.
*
* Ce module vit hors de `backend/actions.ts` parce qu'il a deux appelants : la
* validation par un modérateur (une requête HTTP) et le libérateur périodique
* démarré par `instrumentation.ts` (aucune requête). Il ne fait donc jamais
* appel à `revalidatePath()`, qui n'a pas de sens hors requête ; les pages
* publiques sont en `force-dynamic` et relisent la base à chaque affichage.
*/
import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
import { db } from "@/db";
import { members, promotions, socialPosts } from "@/db/schema";
import { logActivity } from "@/lib/activity-log";
import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social";
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
export function requestedNetworks(promo: {
shareFacebook: boolean;
shareLinkedin: boolean;
}): SocialNetwork[] {
return SOCIAL_NETWORKS.filter((n) =>
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
);
}
/**
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
*
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
* republication, y compris sur un cycle suspension → remise en ligne, et ce
* qui rend le libérateur périodique sans danger s'il repasse sur une promo.
*/
export async function publishPromoShares(
promoId: number,
networks: SocialNetwork[],
userId: number | null
) {
if (networks.length === 0) return;
const [promo] = await db
.select({
title: promotions.title,
text: promotions.text,
badge: promotions.badge,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(eq(promotions.id, promoId));
if (!promo) return;
const done = await db
.select({ network: socialPosts.network })
.from(socialPosts)
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
const alreadyPosted = new Set(done.map((d) => d.network));
for (const network of networks) {
if (alreadyPosted.has(network)) continue;
if (!(await isNetworkConfigured(network))) {
await db.insert(socialPosts).values({
promotionId: promoId,
network,
status: "failed",
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
postedById: userId,
});
continue;
}
try {
const result = await publishPromoToNetwork(network, promo);
await db.insert(socialPosts).values({
promotionId: promoId,
network,
status: "posted",
externalId: result.externalId,
url: result.url,
postedById: userId,
});
await logActivity(
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
"#2C6FB3"
);
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
await db.insert(socialPosts).values({
promotionId: promoId,
network,
status: "failed",
error: message.slice(0, 2000),
postedById: userId,
});
}
}
}
/**
* Met en ligne les promotions programmées dont l'échéance est passée.
*
* La bascule `scheduled → live` est faite par un `UPDATE … RETURNING` filtré
* sur le statut : deux instances de l'application qui tourneraient en même
* temps ne peuvent pas récupérer la même ligne, chacune n'obtient que celles
* qu'elle a effectivement changées. La diffusion réseaux suit, protégée en
* plus par la garde anti-republication de `publishPromoShares`.
*
* Ne lève jamais : le libérateur tourne en tâche de fond, une erreur ne doit
* pas arrêter la boucle.
*/
export async function releaseDuePromotions(now = new Date()): Promise<number> {
let released: { id: number; title: string; memberId: number | null; shareFacebook: boolean; shareLinkedin: boolean }[];
try {
released = await db
.update(promotions)
.set({ status: "live", publishAt: null })
.where(
and(
eq(promotions.status, "scheduled"),
isNotNull(promotions.publishAt),
lte(promotions.publishAt, now)
)
)
.returning({
id: promotions.id,
title: promotions.title,
memberId: promotions.memberId,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
});
} catch (error) {
console.error("[promotions] libération programmée impossible :", error);
return 0;
}
for (const promo of released) {
try {
await logActivity(
`Promotion « ${promo.title} » publiée à l'heure programmée`,
"#1f8a5b"
);
// `null` : la publication n'est déclenchée par personne, c'est l'échéance.
await publishPromoShares(promo.id, requestedNetworks(promo), null);
} catch (error) {
console.error(`[promotions] publication programmée #${promo.id} :`, error);
}
}
return released.length;
}
/** Nombre de promotions encore en attente de leur date de publication. */
export async function countScheduledPromotions(): Promise<number> {
const [row] = await db
.select({ n: sql<number>`count(*)::int` })
.from(promotions)
.where(eq(promotions.status, "scheduled"));
return row?.n ?? 0;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Programmation de la publication d'une promotion.
*
* Une promotion validée peut soit partir tout de suite, soit attendre une
* date choisie : c'est `promotions.publish_at`. Tant que cette date n'est pas
* atteinte la promotion reste au statut `scheduled` — invisible sur le site
* public (les lectures filtrent `status = 'live'`) et non diffusée sur les
* réseaux. Le libérateur (`releaseDuePromotions`) fait la bascule.
*
* Ce module est **pur** : aucune base, aucun réseau. Il ne s'occupe que de la
* conversion et du rendu des dates, et il est verrouillé par
* `tests/promo-schedule.test.ts`.
*
* Le fuseau est celui de l'association, pas celui du serveur : un conteneur
* qui tourne en UTC ne doit pas décaler d'une heure la publication demandée
* par un adhérent lorrain.
*/
export const ASSOCIATION_TIME_ZONE = "Europe/Paris";
const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?$/;
/**
* Décalage du fuseau de l'association à un instant donné, en millisecondes.
* Passe par `Intl` plutôt que par une constante : l'heure d'été change deux
* fois par an et une valeur figée publierait à la mauvaise heure la moitié de
* l'année.
*/
function zoneOffsetMs(instant: Date): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: ASSOCIATION_TIME_ZONE,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
// `hour` vaut 24 à minuit avec hour12:false sur certaines plateformes.
const hour = get("hour") % 24;
const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
return asIfUtc - instant.getTime();
}
/**
* Convertit la saisie d'un `<input type="datetime-local">` (heure locale de
* l'association) en instant absolu.
*
* Renvoie `null` si le champ est vide, et lève si la saisie n'a pas la forme
* attendue : une date illisible ne doit pas se transformer silencieusement en
* publication immédiate.
*/
export function parseScheduleInput(value: string | null | undefined): Date | null {
const raw = (value ?? "").trim();
if (!raw) return null;
const m = LOCAL_DATETIME.exec(raw);
if (!m) throw new Error("Date de programmation invalide.");
const [, y, mo, d, h, mi] = m.map(Number) as unknown as number[];
// `Date.UTC` accepte 13 comme mois ou 99 comme heure en débordant sur la
// date suivante : on refuse plutôt que de publier à un moment inattendu.
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) {
throw new Error("Date de programmation invalide.");
}
const naive = Date.UTC(y, mo - 1, d, h, mi);
if (new Date(naive).getUTCDate() !== d) throw new Error("Date de programmation invalide.");
// Deux passes : la première donne un décalage approché, la seconde le corrige
// quand la date tombe juste après un changement d'heure.
const first = new Date(naive - zoneOffsetMs(new Date(naive)));
const exact = new Date(naive - zoneOffsetMs(first));
if (Number.isNaN(exact.getTime())) throw new Error("Date de programmation invalide.");
return exact;
}
/** Valeur à remettre dans un `<input type="datetime-local">`. */
export function toScheduleInput(date: Date | null | undefined): string {
if (!date || Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() + zoneOffsetMs(date));
return local.toISOString().slice(0, 16);
}
/** Une programmation passée n'a plus lieu d'être : on publie tout de suite. */
export function isDue(publishAt: Date | null | undefined, now = new Date()): boolean {
return !publishAt || publishAt.getTime() <= now.getTime();
}
/** `true` si la promotion doit attendre. */
export function isPending(publishAt: Date | null | undefined, now = new Date()): boolean {
return !isDue(publishAt, now);
}
/** « le 12 mars 2027 à 09:00 » — sans majuscule, à insérer dans une phrase. */
export function formatSchedule(publishAt: Date | null | undefined): string | null {
if (!publishAt || Number.isNaN(publishAt.getTime())) return null;
const day = new Intl.DateTimeFormat("fr-FR", {
timeZone: ASSOCIATION_TIME_ZONE,
day: "numeric",
month: "long",
year: "numeric",
}).format(publishAt);
const time = new Intl.DateTimeFormat("fr-FR", {
timeZone: ASSOCIATION_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
}).format(publishAt);
return `le ${day} à ${time}`;
}
/** Phrase complète pour le backoffice. */
export function scheduleLabel(publishAt: Date | null | undefined): string | null {
const when = formatSchedule(publishAt);
return when ? `Publication programmée ${when}` : null;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Période de validité d'une promotion.
*
* Module **pur** : aucune dépendance à la base ni à React, pour être verrouillé
* par les tests. Les dates arrivent en `YYYY-MM-DD` (colonnes `date` de
* Postgres, lues telles quelles par Drizzle) et sont donc traitées comme des
* jours calendaires, sans fuseau horaire.
*/
export type PromoValidity = {
startsOn?: string | null;
endsOn?: string | null;
/** Texte libre des anciennes promotions, utilisé à défaut de dates. */
validUntil?: string | null;
};
const MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
];
type Parts = { day: number; month: number; year: number };
function parse(value: string | null | undefined): Parts | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
if (!match) return null;
const [, year, month, day] = match;
const parts = { day: Number(day), month: Number(month) - 1, year: Number(year) };
if (parts.month < 0 || parts.month > 11 || parts.day < 1 || parts.day > 31) return null;
return parts;
}
/** « 1er » et non « 1 », comme on l'écrit en français. */
function dayLabel(day: number): string {
return day === 1 ? "1er" : String(day);
}
function full(p: Parts): string {
return `${dayLabel(p.day)} ${MONTHS[p.month]} ${p.year}`;
}
/**
* Phrase de validité, ou `null` si la promotion n'en a pas.
* L'année et le mois ne sont répétés que lorsqu'ils changent : « du 1er au
* 15 mars 2027 » plutôt que « du 1er mars 2027 au 15 mars 2027 ».
*/
export function formatValidity(promo: PromoValidity): string | null {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (start && end) {
const sameYear = start.year === end.year;
const sameMonth = sameYear && start.month === end.month;
const from = sameMonth
? dayLabel(start.day)
: sameYear
? `${dayLabel(start.day)} ${MONTHS[start.month]}`
: full(start);
return `Valable du ${from} au ${full(end)}`;
}
if (end) return `Valable jusqu'au ${full(end)}`;
if (start) return `Valable à partir du ${full(start)}`;
const legacy = promo.validUntil?.trim();
return legacy || null;
}
/** Variante sans le mot « Valable », pour les libellés déjà introduits. */
export function formatValidityShort(promo: PromoValidity): string | null {
const formatted = formatValidity(promo);
if (!formatted) return null;
return formatted.replace(/^Valable /, "");
}
/** `true` si la date de fin est dépassée. Un jour de fin reste valable en entier. */
export function isExpired(promo: PromoValidity, today = new Date()): boolean {
const end = parse(promo.endsOn);
if (!end) return false;
const endOfDay = Date.UTC(end.year, end.month, end.day, 23, 59, 59);
return today.getTime() > endOfDay;
}
/** Bornes invalides : fin antérieure au début. */
export function isRangeInvalid(promo: PromoValidity): boolean {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (!start || !end) return false;
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day);
}
/** `true` si la période n'a pas encore commencé. */
export function isNotStarted(promo: PromoValidity, today = new Date()): boolean {
const start = parse(promo.startsOn);
if (!start) return false;
return today.getTime() < Date.UTC(start.year, start.month, start.day);
}
/**
* Pourquoi une promotion validée n'apparaît pas sur le site aujourd'hui.
*
* Les lectures publiques (`src/lib/queries.ts`) écartent les offres hors
* période ; ce message l'explique côté backoffice, pour qu'un adhérent ne
* cherche pas une offre « en ligne » qui n'est pas encore, ou plus, affichée.
* Renvoie `null` quand la promotion est bien visible.
*/
export function visibilityNote(promo: PromoValidity, today = new Date()): string | null {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (start && isNotStarted(promo, today)) {
return `Pas encore affichée : elle apparaîtra sur le site le ${full(start)}.`;
}
if (end && isExpired(promo, today)) {
return `Période terminée le ${full(end)} : elle n'apparaît plus sur le site.`;
}
return null;
}
+83 -8
View File
@@ -1,6 +1,22 @@
import { and, asc, count, desc, eq } from "drizzle-orm";
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { memberContact, type MemberContact } from "@/lib/member-contact";
/**
* Promotion réellement visible par le public : validée **et** dans sa période
* de validité.
*
* Les deux bornes sont facultatives — une offre sans date reste affichée sans
* limite. La journée de référence est celle de l'association, pas celle du
* serveur : un conteneur en UTC retirerait sinon une offre « valable jusqu'au
* 30 juin » deux heures trop tôt.
*/
const VISIBLE_PROMO = and(
eq(promotions.status, "live"),
sql`(${promotions.startsOn} is null or ${promotions.startsOn} <= (current_timestamp at time zone 'Europe/Paris')::date)`,
sql`(${promotions.endsOn} is null or ${promotions.endsOn} >= (current_timestamp at time zone 'Europe/Paris')::date)`
);
export async function getActiveMembersWithCategory() {
return db
@@ -32,7 +48,7 @@ export async function getLiveBadgesByMember() {
return db
.select({ memberId: promotions.memberId, badge: promotions.badge })
.from(promotions)
.where(eq(promotions.status, "live"));
.where(VISIBLE_PROMO);
}
export async function getCategoriesWithCounts(limit = 6) {
@@ -57,8 +73,14 @@ export async function getCategoriesWithCounts(limit = 6) {
return rows;
}
export async function getLivePromotions(limit = 6) {
const rows = await db
/**
* Promotions visibles, de la plus récente à la plus ancienne.
*
* `limit` à `null` : toutes les offres en cours (page `/promotions`) ; sinon
* les N dernières (accueil).
*/
export async function getLivePromotions(limit: number | null = 6) {
const query = db
.select({
id: promotions.id,
title: promotions.title,
@@ -67,16 +89,21 @@ export async function getLivePromotions(limit = 6) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
memberLogoUrl: members.logoUrl,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(eq(promotions.status, "live"))
.where(VISIBLE_PROMO)
.orderBy(desc(promotions.createdAt))
.limit(limit);
return rows;
.$dynamic();
// La limite reste dans le SQL : la page « toutes les promos » est la seule à
// tout demander, l'accueil ne rapatrie que ses six cartes.
return limit === null ? query : query.limit(limit);
}
export async function getPublicMember(id: number) {
@@ -109,6 +136,46 @@ export async function getPublicMember(id: number) {
return m ?? null;
}
/**
* Référents des adhérents actifs, indexés par fiche.
*
* Volontairement séparé des lectures publiques (`getActiveMembersWithCategory`,
* `getPublicMember`) : nom, prénom et ligne directe ne sont réservés aux
* visiteurs connectés que parce qu'aucune requête publique ne les rapatrie.
* L'appelant vérifie la session **avant** d'appeler.
*/
export async function getMemberContacts(): Promise<Map<number, MemberContact>> {
const rows = await db
.select({
id: members.id,
contactFirstName: members.contactFirstName,
contactLastName: members.contactLastName,
contactPhone: members.contactPhone,
})
.from(members)
.where(eq(members.status, "active"));
const byMember = new Map<number, MemberContact>();
for (const row of rows) {
const contact = memberContact(row);
if (contact) byMember.set(row.id, contact);
}
return byMember;
}
/** Référent d'une fiche. Même règle que `getMemberContacts()` : session vérifiée d'abord. */
export async function getMemberContact(id: number): Promise<MemberContact | null> {
const [row] = await db
.select({
contactFirstName: members.contactFirstName,
contactLastName: members.contactLastName,
contactPhone: members.contactPhone,
})
.from(members)
.where(eq(members.id, id));
return row ? memberContact(row) : null;
}
export async function getMemberLivePromotions(memberId: number) {
return db
.select({
@@ -119,9 +186,11 @@ export async function getMemberLivePromotions(memberId: number) {
badge: promotions.badge,
imageUrl: promotions.imageUrl,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
})
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
.where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
.orderBy(desc(promotions.createdAt));
}
@@ -218,3 +287,9 @@ export async function getCategoriesInUse() {
const rows = await getCategoriesWithCounts(1000);
return rows.filter((c) => Number(c.memberCount) > 0);
}
/** Promotions réellement affichées aujourd'hui — le chiffre du tableau de bord. */
export async function countVisiblePromotions(): Promise<number> {
const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO);
return row?.n ?? 0;
}
+3
View File
@@ -22,6 +22,7 @@ type Capability =
| "manageAdmins"
| "manageCategories"
| "manageMeetings"
| "manageInformations"
| "manageEmails"
| "manageSettings"
| "memberSpace";
@@ -35,6 +36,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
"manageAdmins",
"manageCategories",
"manageMeetings",
"manageInformations",
"manageEmails",
"manageSettings",
"memberSpace",
@@ -45,6 +47,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
"moderatePromos",
"publishSocial",
"manageMeetings",
"manageInformations",
],
editor: ["viewDashboard", "manageMembers"],
member: ["memberSpace"],
+190
View File
@@ -0,0 +1,190 @@
import { safeHttpUrl } from "@/lib/rich-text";
/**
* Conversion d'un contenu édité visuellement vers le format balisé stocké.
*
* C'est la pièce qui permet un éditeur « comme dans Word » sans renoncer à
* l'invariant du produit : ce qui est **stocké** reste le sous-ensemble
* restreint de `src/lib/rich-text.ts`, jamais du HTML tiers. Le danger n'a
* jamais été la surface d'édition, mais la base de données — alors le
* navigateur retraverse ce qu'il a édité et n'en garde que le gras,
* l'italique, les listes, les sous-titres et les liens. Tout le reste — styles
* de Word, `<span>`, `<script>`, attributs d'événement — disparaît avant même
* de quitter la page.
*
* Le module est **pur** (`tests/rich-text-dom.test.ts`) : il ne connaît du DOM
* que `MinimalNode`, ce qui le rend testable sans navigateur.
*/
/** Ce que le sérialiseur a besoin de savoir d'un nœud. */
export type MinimalNode = {
nodeType: number;
nodeName: string;
textContent?: string | null;
childNodes: ArrayLike<MinimalNode>;
getAttribute?: (name: string) => string | null;
};
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
/** Éléments qui ouvrent une nouvelle ligne dans le format balisé. */
const BLOCKS = new Set(["P", "DIV", "LI", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE"]);
/** Éléments dont le contenu ne doit jamais être lu, même comme texte. */
const DROPPED = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "SVG"]);
type LineKind = "paragraph" | "heading" | "bullet" | "ordered";
type Line = { kind: LineKind; text: string };
function nodeName(node: MinimalNode): string {
return node.nodeType === ELEMENT_NODE ? String(node.nodeName ?? "").toUpperCase() : "";
}
function children(node: MinimalNode): MinimalNode[] {
return Array.from(node.childNodes ?? []);
}
/** Texte d'un nœud, espaces insécables de l'éditeur ramenés à des espaces. */
function plain(value: string | null | undefined): string {
return String(value ?? "").replace(/\u00a0/g, " ").replace(/\r/g, "");
}
/** Contenu en ligne d'un nœud : gras, italique, liens, sauts de ligne. */
function inline(node: MinimalNode): string {
if (node.nodeType === TEXT_NODE) return plain(node.textContent);
if (node.nodeType !== ELEMENT_NODE) return "";
const name = nodeName(node);
if (DROPPED.has(name)) return "";
if (name === "BR") return "\n";
const inner = children(node).map(inline).join("");
// Un marqueur autour d'une sélection vide produirait « **** », que
// l'analyseur relirait comme du texte littéral.
if (!inner.trim()) return inner;
if (name === "A") {
const href = safeHttpUrl(node.getAttribute?.("href") ?? "");
return href ? `[${inner}](${href})` : inner;
}
const style = node.getAttribute?.("style") ?? "";
let marked = inner;
// L'italique en premier : le gras l'enveloppe, jamais l'inverse.
if (isItalic(name, style) && !wrapped(marked, "_")) marked = `_${marked}_`;
if (isBold(name, style) && !wrapped(marked, "**")) marked = `**${marked}**`;
return marked;
}
/** Déjà encadré par ce marqueur : ne pas le doubler. */
function wrapped(value: string, marker: string): boolean {
return value.startsWith(marker) && value.endsWith(marker) && value.length > marker.length * 2;
}
/**
* Un traitement de texte code souvent le gras en style plutôt qu'en balise :
* Google Docs écrit `<span style="font-weight:700">`. Sans cela, coller un
* brouillon rédigé ailleurs perdrait silencieusement toute sa mise en forme.
*
* Le style explicite l'emporte sur la balise, parce que Google Docs enveloppe
* aussi tout le document dans un `<b style="font-weight:normal">` — le prendre
* au mot mettrait le compte rendu entier en gras.
*/
function isBold(name: string, style: string): boolean {
if (/font-weight\s*:\s*(normal|[1-5]00)\b/i.test(style)) return false;
return name === "STRONG" || name === "B" || /font-weight\s*:\s*(bold(er)?|[6-9]00)\b/i.test(style);
}
/**
* L'italique est marqué par des tirets bas et non des étoiles :
* « **_gras italique_** » reste lisible par l'analyseur, là où « ***…*** »
* serait ambigu.
*/
function isItalic(name: string, style: string): boolean {
if (/font-style\s*:\s*normal\b/i.test(style)) return false;
return name === "EM" || name === "I" || /font-style\s*:\s*italic\b/i.test(style);
}
function push(lines: Line[], kind: LineKind, text: string) {
// Les espaces de bord disparaissent, les sauts de ligne internes restent :
// ce sont eux qui portent les retours à la ligne dans un paragraphe.
const cleaned = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
if (cleaned) lines.push({ kind, text: cleaned });
}
function walk(node: MinimalNode, lines: Line[], ordered: boolean) {
let buffer = "";
const flush = () => {
push(lines, "paragraph", buffer);
buffer = "";
};
for (const child of children(node)) {
const name = nodeName(child);
if (DROPPED.has(name)) continue;
if (name === "UL" || name === "OL") {
flush();
walk(child, lines, name === "OL");
continue;
}
if (name === "LI") {
flush();
push(lines, ordered ? "ordered" : "bullet", inline(child));
continue;
}
if (BLOCKS.has(name)) {
flush();
// Un `<div>` d'éditeur peut contenir d'autres blocs plutôt que du texte.
if (children(child).some((grandChild) => {
const grandName = nodeName(grandChild);
return BLOCKS.has(grandName) || grandName === "UL" || grandName === "OL";
})) {
walk(child, lines, ordered);
} else {
push(lines, /^H[1-6]$/.test(name) ? "heading" : "paragraph", inline(child));
}
continue;
}
// Texte nu et éléments en ligne : ils appartiennent au paragraphe courant.
buffer += inline(child);
}
flush();
}
function isList(kind: LineKind): boolean {
return kind === "bullet" || kind === "ordered";
}
/**
* Sérialise un fragment édité vers le format balisé.
*
* `root` est typiquement la `<div contenteditable>` de l'éditeur, ou le
* `body` d'un document inerte produit par `DOMParser` lors d'un collage.
*/
export function serializeToRichText(root: MinimalNode): string {
const lines: Line[] = [];
walk(root, lines, false);
let counter = 0;
return lines
.map((line, index) => {
const previous = lines[index - 1];
const continues = previous && isList(previous.kind) && previous.kind === line.kind;
counter = line.kind === "ordered" ? (continues ? counter + 1 : 1) : 0;
const prefix =
line.kind === "heading" ? "## " : line.kind === "bullet" ? "- " : line.kind === "ordered" ? `${counter}. ` : "";
// Les éléments d'une même liste se suivent ; tout le reste est séparé par
// une ligne vide, qui marque un nouveau paragraphe.
const separator = index === 0 ? "" : continues ? "\n" : "\n\n";
return `${separator}${prefix}${line.text}`;
})
.join("");
}
+398
View File
@@ -0,0 +1,398 @@
import type { ReactNode } from "react";
import { createElement, Fragment } from "react";
/**
* Sous-ensemble de Markdown pour les informations de l'association.
*
* Module **pur**, verrouillé par `tests/rich-text.test.ts`, bâti sur le même
* principe que `src/lib/activity.ts` : on ne stocke jamais de HTML et on ne
* rend jamais de HTML brut. Le texte saisi est analysé ici, puis rendu en
* éléments React côté site — ce que React échappe. Un éditeur WYSIWYG
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
* dépendance, deuxième surface d'attaque.
*
* Grammaire : `**gras**`, `*italique*` ou `_italique_`, `- puce`, `1. numéro`,
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
* Un saut de ligne simple reste un saut de ligne.
*
* Ce balisage n'est **pas** montré à l'utilisateur : il écrit dans un éditeur
* visuel (`src/components/RichTextEditor.tsx`) et c'est le navigateur qui
* resérialise vers ce format (`src/lib/rich-text-dom.ts`) avant l'envoi. Le
* format reste donc le contrat de stockage, jamais une syntaxe à apprendre.
*
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
* `richTextNodes()`, il est donc fidèle par construction.
*/
export type Inline =
| { kind: "text"; value: string }
| { kind: "break" }
| { kind: "strong"; children: Inline[] }
| { kind: "em"; children: Inline[] }
| { kind: "link"; href: string; children: Inline[] };
export type Block =
| { kind: "paragraph"; children: Inline[] }
| { kind: "heading"; children: Inline[] }
| { kind: "bullets"; items: Inline[][] }
| { kind: "ordered"; items: Inline[][] };
/**
* Seuls `http:` et `https:` passent. Un `javascript:` ou un `data:` rendrait
* exécutable un lien écrit par un tiers ; il est rendu comme du texte.
*/
export function safeHttpUrl(value: string): string {
try {
const url = new URL(String(value ?? "").trim());
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
} catch {
return "";
}
}
/** Vrai en bord de mot : début, fin, espace ou ponctuation. */
function isWordEdge(char: string | undefined): boolean {
return char === undefined || !/[\p{L}\p{N}]/u.test(char);
}
/** Position du tiret bas fermant, ou -1 s'il n'y en a pas en bord de mot. */
function closingUnderscore(text: string, from: number): number {
for (let index = from; index < text.length; index += 1) {
if (text[index] === "_" && isWordEdge(text[index + 1])) return index;
}
return -1;
}
function parseInline(text: string): Inline[] {
const nodes: Inline[] = [];
let buffer = "";
let index = 0;
const flush = () => {
if (buffer) nodes.push({ kind: "text", value: buffer });
buffer = "";
};
while (index < text.length) {
// `**` avant `*` : sans cet ordre, « **gras** » serait lu comme un
// italique vide suivi du mot.
if (text.startsWith("**", index)) {
const end = text.indexOf("**", index + 2);
if (end > index + 2) {
flush();
nodes.push({ kind: "strong", children: parseInline(text.slice(index + 2, end)) });
index = end + 2;
continue;
}
}
if (text[index] === "*") {
const end = text.indexOf("*", index + 1);
if (end > index + 1) {
flush();
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
index = end + 1;
continue;
}
}
// Tiret bas accepté comme italique — c'est ce qu'émet le sérialiseur, pour
// éviter l'ambiguïté de « ***gras italique*** ». Il ne compte qu'aux
// frontières de mot, sans quoi « fichier_de_sauvegarde » deviendrait
// italique en son milieu.
if (text[index] === "_" && isWordEdge(text[index - 1])) {
const end = closingUnderscore(text, index + 1);
if (end > index + 1) {
flush();
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
index = end + 1;
continue;
}
}
if (text[index] === "[") {
const close = text.indexOf("]", index + 1);
if (close > index && text[close + 1] === "(") {
const paren = text.indexOf(")", close + 2);
if (paren > close + 1) {
const label = text.slice(index + 1, close);
const href = safeHttpUrl(text.slice(close + 2, paren));
flush();
// Lien refusé : le libellé reste, la cible disparaît.
if (href) nodes.push({ kind: "link", href, children: parseInline(label) });
else nodes.push(...parseInline(label));
index = paren + 1;
continue;
}
}
}
buffer += text[index];
index += 1;
}
flush();
return nodes;
}
function parseInlineLines(lines: string[]): Inline[] {
const nodes: Inline[] = [];
lines.forEach((line, index) => {
if (index > 0) nodes.push({ kind: "break" });
nodes.push(...parseInline(line.trim()));
});
return nodes;
}
export function parseRichText(source: string): Block[] {
const lines = String(source ?? "").replace(/\r\n?/g, "\n").split("\n");
const blocks: Block[] = [];
let paragraph: string[] = [];
const flushParagraph = () => {
if (paragraph.length === 0) return;
blocks.push({ kind: "paragraph", children: parseInlineLines(paragraph) });
paragraph = [];
};
const pushItem = (kind: "bullets" | "ordered", item: Inline[]) => {
flushParagraph();
const last = blocks[blocks.length - 1];
if (last && last.kind === kind) last.items.push(item);
else blocks.push({ kind, items: [item] } as Block);
};
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim()) {
flushParagraph();
continue;
}
const heading = /^##\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
blocks.push({ kind: "heading", children: parseInline(heading[1].trim()) });
continue;
}
// L'espace après le tiret est obligatoire : « *italique* » en début de
// ligne ne doit pas devenir une puce.
const bullet = /^[-*]\s+(.*)$/.exec(line);
if (bullet) {
pushItem("bullets", parseInline(bullet[1].trim()));
continue;
}
const ordered = /^\d+[.)]\s+(.*)$/.exec(line);
if (ordered) {
pushItem("ordered", parseInline(ordered[1].trim()));
continue;
}
paragraph.push(line);
}
flushParagraph();
return blocks;
}
// ---- Rendu écran ----
function inlineNodes(nodes: Inline[]): ReactNode[] {
return nodes.map((node, index) => {
switch (node.kind) {
case "break":
return createElement("br", { key: index });
case "strong":
return createElement("strong", { key: index }, inlineNodes(node.children));
case "em":
return createElement("em", { key: index }, inlineNodes(node.children));
case "link":
return createElement(
"a",
{ key: index, href: node.href, target: "_blank", rel: "noopener noreferrer" },
inlineNodes(node.children),
);
default:
return createElement(Fragment, { key: index }, node.value);
}
});
}
/**
* Rend le texte en nœuds React. Rien n'est jamais passé à
* `dangerouslySetInnerHTML` : une balise écrite dans la saisie s'affiche
* littéralement au lieu d'être interprétée.
*/
export function richTextNodes(source: string): ReactNode {
return parseRichText(source).map((block, index) => {
switch (block.kind) {
case "heading":
return createElement("h4", { key: index }, inlineNodes(block.children));
case "bullets":
return createElement(
"ul",
{ key: index },
block.items.map((item, position) => createElement("li", { key: position }, inlineNodes(item))),
);
case "ordered":
return createElement(
"ol",
{ key: index },
block.items.map((item, position) => createElement("li", { key: position }, inlineNodes(item))),
);
default:
return createElement("p", { key: index }, inlineNodes(block.children));
}
});
}
// ---- Rendu e-mail ----
const BODY_STYLE = "font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5d5447;";
const HEADING_STYLE = "font-family:Georgia,'Times New Roman',serif;font-size:19px;line-height:26px;color:#26201a;";
function esc(value: string) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function inlineHtml(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "<br>";
case "strong":
return `<strong style="color:#26201a;">${inlineHtml(node.children)}</strong>`;
case "em":
return `<em>${inlineHtml(node.children)}</em>`;
case "link":
return `<a href="${esc(node.href)}" target="_blank" style="color:#2C6FB3;">${inlineHtml(node.children)}</a>`;
default:
return esc(node.value);
}
})
.join("");
}
/**
* HTML à styles en ligne pour le gabarit d'e-mail. Sûr parce que le seul
* producteur est l'analyseur ci-dessus : aucune balise de la saisie n'arrive
* jusqu'ici, tout passe par `esc()`.
*/
export function richTextToEmailHtml(source: string): string {
return parseRichText(source)
.map((block) => {
switch (block.kind) {
case "heading":
return `<div style="${HEADING_STYLE}margin:0 0 12px;">${inlineHtml(block.children)}</div>`;
case "bullets":
case "ordered": {
const tag = block.kind === "bullets" ? "ul" : "ol";
const items = block.items.map((item) => `<li style="margin-bottom:4px;">${inlineHtml(item)}</li>`).join("");
return `<${tag} style="${BODY_STYLE}margin:0 0 18px;padding-left:22px;">${items}</${tag}>`;
}
default:
return `<div style="${BODY_STYLE}margin:0 0 18px;">${inlineHtml(block.children)}</div>`;
}
})
.join("");
}
// ---- Rendus texte ----
function inlinePlain(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "\n";
case "link": {
const label = inlinePlain(node.children);
return label === node.href ? node.href : `${label} (${node.href})`;
}
case "strong":
case "em":
return inlinePlain(node.children);
default:
return node.value;
}
})
.join("");
}
/** Repli `text/plain` du message : même contenu, sans balisage. */
export function richTextToPlain(source: string): string {
return parseRichText(source)
.map((block) => {
switch (block.kind) {
case "bullets":
return block.items.map((item) => `- ${inlinePlain(item)}`).join("\n");
case "ordered":
return block.items.map((item, index) => `${index + 1}. ${inlinePlain(item)}`).join("\n");
default:
return inlinePlain(block.children);
}
})
.join("\n\n");
}
/** Résumé d'une ligne, pour les listes du back-office. */
export function richTextExcerpt(source: string, max = 160): string {
const plain = richTextToPlain(source).replace(/\s+/g, " ").trim();
if (plain.length <= max) return plain;
const cut = plain.slice(0, max);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
// ---- Rendu vers l'éditeur visuel ----
function inlineEditorHtml(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "<br>";
case "strong":
return `<strong>${inlineEditorHtml(node.children)}</strong>`;
case "em":
return `<em>${inlineEditorHtml(node.children)}</em>`;
case "link":
return `<a href="${esc(node.href)}">${inlineEditorHtml(node.children)}</a>`;
default:
return esc(node.value);
}
})
.join("");
}
/**
* HTML minimal posé dans la zone d'édition visuelle.
*
* Produit exactement les balises que `serializeToRichText` sait relire, pour
* que l'aller-retour édition → stockage → édition soit stable. Un contenu vide
* rend un paragraphe avec un `<br>` : sans lui, le navigateur n'offre aucune
* ligne où poser le curseur.
*/
export function richTextToEditorHtml(source: string): string {
const blocks = parseRichText(source);
if (blocks.length === 0) return "<p><br></p>";
return blocks
.map((block) => {
switch (block.kind) {
case "heading":
return `<h4>${inlineEditorHtml(block.children)}</h4>`;
case "bullets":
return `<ul>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ul>`;
case "ordered":
return `<ol>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ol>`;
default:
return `<p>${inlineEditorHtml(block.children) || "<br>"}</p>`;
}
})
.join("");
}
+4 -19
View File
@@ -1,28 +1,13 @@
import { headers } from "next/headers";
import { siteUrl } from "@/lib/social-accounts";
import { publicBaseUrl } from "@/lib/social-accounts";
/**
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
*
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
* variables d'environnement ; en dernier recours, l'URL est déduite des
* en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`.
*/
export async function publicBaseUrl(): Promise<string> {
const configured = await siteUrl().catch(() => "");
if (configured) return configured;
try {
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
if (!host) return "";
const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${protocol}://${host}`.replace(/\/+$/, "");
} catch {
// Hors requête (génération de robots/sitemap au build par exemple).
return "";
}
}
export { publicBaseUrl };
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
export async function metadataBase(): Promise<URL | undefined> {
+1
View File
@@ -71,6 +71,7 @@ export const PRIVATE_PATHS = ["/backend", "/api", "/login", "/inscription"];
export const STATIC_ROUTES: { path: string; priority: number; changeFrequency: "daily" | "weekly" | "monthly" | "yearly" }[] = [
{ path: "/", priority: 1, changeFrequency: "daily" },
{ path: "/annuaire", priority: 0.9, changeFrequency: "daily" },
{ path: "/promotions", priority: 0.9, changeFrequency: "daily" },
{ path: "/association", priority: 0.8, changeFrequency: "weekly" },
{ path: "/rencontres-passees", priority: 0.6, changeFrequency: "monthly" },
{ path: "/mentions-legales", priority: 0.2, changeFrequency: "yearly" },
+20
View File
@@ -1,3 +1,4 @@
import type { EmailBrand } from "@/lib/email-templates";
import { asc } from "drizzle-orm";
import { db } from "@/db";
import { siteSettings } from "@/db/schema";
@@ -44,6 +45,10 @@ export const SITE_SETTING_DEFAULTS = {
association_publication_director: "",
association_privacy_contact: "",
legal_updated: "",
// Référencement : identifiant de mesure GA4 (« G-… ») et jeton de
// validation Search Console. Vides = aucune balise posée sur le site.
google_analytics_id: "",
google_site_verification: "",
};
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
@@ -122,3 +127,18 @@ export function parseBoardMembers(value: string) {
return { name, role: role || "Membre du directoire" };
});
}
/**
* Identité de l'association telle qu'elle apparaît en pied de tous les
* e-mails. Elle sert au studio de composition comme aux envois automatiques :
* un seul endroit à changer.
*/
export function emailBrand(settings: SiteSettings): EmailBrand {
return {
associationName: settings.association_name,
address: settings.association_address,
email: settings.association_email,
phone: settings.association_phone,
siret: settings.association_siret,
};
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Déduction de l'origine publique du site à partir des en-têtes d'une requête.
*
* Sans dépendance à la base ni à Next : utilisable partout et testable. Sert de
* repli quand aucune URL publique n'est enregistrée dans Backend › Réseaux
* sociaux : derrière Caddy ou Nginx Proxy Manager, `X-Forwarded-Host` et
* `X-Forwarded-Proto` décrivent l'adresse vue par le visiteur.
*/
type HeaderReader = { get(name: string): string | null };
const LOCAL_HOSTS = /^(localhost|.*\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|\[::\])$/i;
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
function first(value: string | null): string {
return (value ?? "").split(",")[0]?.trim() ?? "";
}
/** Nom d'hôte sans port, en minuscules. */
function hostname(host: string): string {
const match = /^(\[[^\]]*\]|[^:]+)/.exec(host);
return (match?.[1] ?? host).toLowerCase();
}
/**
* Sans `X-Forwarded-Proto`, on présume HTTPS pour un nom de domaine — c'est le
* cas de tout déploiement réel — et HTTP pour une adresse locale ou une IP nue,
* qu'aucun certificat ne couvre.
*/
export function defaultProtocol(host: string): "http" | "https" {
const name = hostname(host);
if (!name || LOCAL_HOSTS.test(name) || IPV4.test(name)) return "http";
return "https";
}
/**
* Origine (`https://pleinr.example.fr`) vue par le visiteur, ou chaîne vide si
* la requête ne porte aucun hôte.
*/
export function originFromHeaders(headers: HeaderReader): string {
const host = first(headers.get("x-forwarded-host")) || first(headers.get("host"));
if (!host || /[\s/\\]/.test(host)) return "";
const forwarded = first(headers.get("x-forwarded-proto")).toLowerCase();
const protocol = forwarded === "https" || forwarded === "http" ? forwarded : defaultProtocol(host);
try {
return new URL(`${protocol}://${host}`).origin;
} catch {
return "";
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Port SMTP et chiffrement : deux réglages qui doivent s'accorder.
*
* Il existe deux façons de chiffrer une session SMTP, et elles ne se
* négocient pas sur le même port :
*
* - **TLS implicite** (port 465) : la connexion est chiffrée dès son
* ouverture, avant le moindre octet de protocole.
* - **STARTTLS** (ports 587, 25, 2525) : la connexion s'ouvre en clair, le
* client lit les capacités annoncées, puis demande `STARTTLS` pour passer
* la suite — authentification comprise — dans le tunnel.
*
* Se tromper ne produit pas un message d'erreur lisible : un client qui tente
* une poignée de main TLS sur le port 587 reçoit la bannière en clair du
* serveur et la lit comme un enregistrement TLS invalide (« wrong version
* number ») ; à l'inverse, un client qui parle en clair sur le port 465
* attend une bannière qui ne viendra jamais et finit en délai dépassé. D'où
* ce module : l'écran accorde les deux champs tout seul, et les échecs sont
* traduits avant d'être affichés.
*
* Module **pur** (aucun accès réseau ni base), verrouillé par
* `tests/smtp-config.test.ts`.
*/
/** Seul port où le chiffrement précède le protocole. */
export const IMPLICIT_TLS_PORT = 465;
/** Ports de soumission qui s'ouvrent en clair puis passent par STARTTLS. */
export const STARTTLS_PORTS = [587, 25, 2525];
/** Le réglage « chiffré dès l'ouverture » qu'appelle un port donné. */
export function impliedSecure(port: number): boolean {
return port === IMPLICIT_TLS_PORT;
}
/**
* Message d'avertissement quand le port et la case ne s'accordent pas —
* `null` si le couple est cohérent. On explique, on ne bloque pas :
* un relais peut très bien écouter du TLS implicite sur un port exotique.
*/
export function tlsMismatch(port: number, secure: boolean): string | null {
if (secure && !impliedSecure(port)) {
return `Le port ${port} s'ouvre en clair puis passe par STARTTLS : en gardant la case cochée, la poignée de main TLS échouera dès la connexion.`;
}
if (!secure && impliedSecure(port)) {
return "Le port 465 chiffre dès l'ouverture : sans la case cochée, la connexion reste sans réponse jusqu'au délai dépassé.";
}
return null;
}
export type SmtpErrorContext = { host?: string; port?: number; secure?: boolean };
const RAW_DETAIL = 110;
function withDetail(explanation: string, raw: string): string {
const detail = raw.replace(/\s+/g, " ").trim();
if (!detail) return explanation;
return `${explanation} (détail : ${detail.slice(0, RAW_DETAIL)})`;
}
/**
* Traduit l'échec d'une connexion SMTP en phrase actionnable.
*
* L'explication passe **avant** le message d'origine : l'écran tronque le
* verdict, et c'est la consigne qu'il faut préserver, pas la trace OpenSSL.
*/
export function describeSmtpError(raw: string, context: SmtpErrorContext = {}): string {
const text = String(raw ?? "");
const lower = text.toLowerCase();
const where = context.host ? `${context.host}:${context.port ?? IMPLICIT_TLS_PORT}` : "le serveur";
// Bannière en clair lue comme du TLS : la case est cochée sur un port STARTTLS.
if (lower.includes("wrong version number") || lower.includes("ssl3_get_record") || lower.includes("packet length too long")) {
return withDetail(
`${where} a répondu en clair alors qu'une connexion chiffrée était attendue. Décochez « Connexion chiffrée dès l'ouverture » : sur le port ${context.port ?? 587}, le chiffrement se demande par STARTTLS.`,
text,
);
}
// Le serveur exige le tunnel avant l'authentification.
if (lower.includes("must issue a starttls") || lower.includes("starttls is required") || lower.includes("no starttls")) {
return withDetail(
`${where} exige STARTTLS avant l'authentification. Vérifiez que le port est bien un port de soumission (587) et non un port de relais filtré.`,
text,
);
}
// Poignée de main en clair sur un port qui chiffre d'emblée, ou port filtré.
if (lower.includes("greeting never received") || lower.includes("etimedout") || lower.includes("timed out") || lower.includes("timeout")) {
const hint = context.secure === false && impliedSecure(context.port ?? 0)
? "Cochez « Connexion chiffrée dès l'ouverture » : le port 465 chiffre avant toute réponse."
: "Le port est probablement filtré en sortie : beaucoup d'hébergeurs bloquent les connexions SMTP sortantes, et il faut alors leur demander l'ouverture.";
return withDetail(`Aucune réponse de ${where}. ${hint}`, text);
}
if (lower.includes("econnrefused")) {
return withDetail(`${where} a refusé la connexion : ce port n'écoute pas. Essayez 465 (chiffré dès l'ouverture) ou 587 (STARTTLS).`, text);
}
if (lower.includes("enotfound") || lower.includes("eai_again") || lower.includes("getaddrinfo")) {
return withDetail(`Nom de serveur introuvable : vérifiez l'adresse « ${context.host ?? ""} ».`, text);
}
if (lower.includes("eauth") || lower.includes("535") || lower.includes("invalid login") || lower.includes("authentication failed") || lower.includes("username and password not accepted")) {
return withDetail(
"Identifiants refusés. L'identifiant est l'adresse complète de la boîte, et certains fournisseurs exigent un mot de passe d'application plutôt que celui du compte.",
text,
);
}
if (lower.includes("altnames") || lower.includes("self signed") || lower.includes("self-signed") || lower.includes("unable to verify") || lower.includes("cert")) {
return withDetail(
`Le certificat présenté par ${where} ne correspond pas à ce nom de serveur. Utilisez le nom annoncé par le fournisseur plutôt qu'une adresse IP ou un alias.`,
text,
);
}
if (lower.includes("5.7.1") || lower.includes("relay") || lower.includes("not permitted") || lower.includes("sender address rejected")) {
return withDetail(
"Le serveur refuse cette adresse d'expédition : elle doit appartenir au domaine de la boîte qui authentifie la connexion.",
text,
);
}
return text || "Serveur SMTP injoignable";
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more