mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-24 15:48:05 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d31935641 | ||
|
|
0e340dde58 | ||
|
|
2da125eca2 | ||
|
|
cc33cf5fbc | ||
|
|
90b121a3c1 | ||
|
|
b860eeaf29 | ||
|
|
7e7d307cdd | ||
|
|
39eb8cfc13 | ||
|
|
0c8542f894 | ||
|
|
158958a8f4 | ||
|
|
877b0ece78 | ||
|
|
a7ac27e530 | ||
|
|
8d4443a1a1 | ||
|
|
d57e544a18 | ||
|
|
1701fc2834 | ||
|
|
3df3e55974 | ||
|
|
67cd9d6684 | ||
|
|
fb09bf0e3a | ||
|
|
7d72016bfb | ||
|
|
e59ae3a908 | ||
|
|
146250f973 | ||
|
|
dd16e05b47 | ||
|
|
e9b6fdfd91 |
No files matched your search
@@ -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=
|
||||
@@ -139,6 +139,14 @@ même raison.
|
||||
- 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.
|
||||
- **Aucun recadrage** : le rapport largeur/hauteur n'est jamais touché, 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 l'image **telle qu'elle sera publiée** et
|
||||
`MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne bloque pas.
|
||||
- Le poids, lui, est ramené dans les clous **par le navigateur** (cf. Images)
|
||||
avant l'envoi : une photo de téléphone dépassait la limite de 3 Mo du serveur
|
||||
et la publication échouait sans recours depuis l'écran de l'adhérent.
|
||||
- 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,
|
||||
@@ -162,6 +170,64 @@ même raison.
|
||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||
|
||||
## Images
|
||||
|
||||
Toute image déposée est **compressée dans le navigateur** avant de partir :
|
||||
elle voyage en data-URI à travers une server action plafonnée à 4 Mo et finit
|
||||
stockée en base, alors qu'une photo de téléphone pèse 4 à 12 Mo. L'ancien
|
||||
réflexe — « Image trop lourde (max ~900 Ko), compressez-la d'abord » — revenait
|
||||
à demander à l'utilisateur ce que le navigateur sait faire seul.
|
||||
|
||||
- `src/lib/image-compress.ts` est **pur** (`tests/image-compress.test.ts`) :
|
||||
seuils, `scaleToFit()` (jamais d'agrandissement), `compressionSteps()` (on
|
||||
épuise la qualité à définition pleine **avant** de réduire la définition),
|
||||
`base64Bytes()`, `compressionSummary()` et `chunkByBytes()` pour découper un
|
||||
envoi groupé sous la limite d'une requête.
|
||||
- `src/lib/image-compress-dom.ts` porte `prepareImageFile()` : décodage
|
||||
(orientation EXIF appliquée via `createImageBitmap`, sans quoi une photo de
|
||||
téléphone repartirait couchée), canvas, encodage. Une image **déjà dans le
|
||||
budget n'est pas réencodée** — un réencodage JPEG dégrade toujours un peu —
|
||||
et une image **avec transparence** ne devient jamais un JPEG (WebP, sinon
|
||||
PNG) : un logo se retrouverait sur fond noir.
|
||||
- Budgets : `FIELD_MAX_BYTES` (1,2 Mo) pour une image de formulaire,
|
||||
`PHOTO_MAX_BYTES` (800 Ko) pour une photo de galerie, `MAX_SIDE` 2048 px.
|
||||
- `ImageField` et `MemberSpaceForm` passent par là et affichent ce qui a été
|
||||
fait (« 4,2 Mo → 620 Ko »). Un format que le navigateur ne sait pas décoder
|
||||
(HEIC d'iPhone sur un poste Windows) est le seul refus, et il est expliqué.
|
||||
|
||||
## Rencontres passées
|
||||
|
||||
Le backoffice (`/backend/rencontres-passees`) est une grille de cartes ; tout
|
||||
le reste se passe dans une **fenêtre** (`PastMeetingDialog`) : titre, date,
|
||||
lieu, rencontre liée, récit, participants **et photos**, y compris à la
|
||||
création. Les photos ne dépendent plus d'une archive déjà publiée.
|
||||
|
||||
- `savePastMeeting()` crée ou met à jour, `addPastMeetingPhotos()` attache un
|
||||
lot (plusieurs appels : `chunkByBytes` découpe sous la limite des server
|
||||
actions et rend les identifiants insérés), `savePastMeetingPhotos()` aligne
|
||||
ordre, légendes et suppressions. Cette dernière exige `syncPhotos=1` : sans
|
||||
ce drapeau, un appel amputé de sa liste viderait la galerie en silence.
|
||||
- Les refus attendus sont **renvoyés** (`ActionError`), jamais levés ; la
|
||||
rencontre liée est vérifiée avant l'écriture, une rencontre supprimée
|
||||
entre-temps donnant sinon une violation de clé étrangère sans explication.
|
||||
- **La fenêtre ne se referme qu'une fois la page rafraîchie**
|
||||
(`closing && !refreshing`). Sans cette attente, rouvrir la fiche montre
|
||||
l'état d'avant l'enregistrement — la rencontre liée revenue à « Aucune » —
|
||||
et on croit que rien n'a été retenu.
|
||||
- `ModalShell` (partagé avec le site public) rend la fenêtre dans un
|
||||
**portail** vers `<body>` : une carte survolée porte un `transform`, qui
|
||||
redéfinit le bloc conteneur d'un `position: fixed`, et la fenêtre s'ouvrirait
|
||||
*dans* la carte. Le portail la sort aussi du bouton qui l'ouvre.
|
||||
- Côté public, `PastMeetingCard` rend une carte de **hauteur fixe** (424 px,
|
||||
couverture + titre sur 2 lignes + 3 lignes de texte écrêtées par
|
||||
`line-clamp`, d'où des classes dans `globals.css` plutôt que des styles en
|
||||
ligne) : le récit complet et toutes les photos vivent dans la fenêtre, avec
|
||||
galerie, flèches, vignettes et navigation au clavier. Les sauts de ligne du
|
||||
récit sont conservés (`white-space: pre-line`).
|
||||
- Le nombre de participants affiché vient des inscriptions **si** une rencontre
|
||||
du calendrier est liée, sinon des lignes saisies à la main ; la fenêtre le
|
||||
dit sous le champ pour éviter la surprise.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Erreurs des server actions** : un `throw` dans une action est masqué par
|
||||
@@ -196,7 +262,13 @@ même raison.
|
||||
- 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)
|
||||
|
||||
@@ -215,6 +287,24 @@ même raison.
|
||||
`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
|
||||
@@ -238,10 +328,33 @@ même raison.
|
||||
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
|
||||
@@ -256,6 +369,133 @@ même raison.
|
||||
`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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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
@@ -120,6 +120,27 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,9 @@ 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" },
|
||||
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -144,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(" ")]
|
||||
@@ -384,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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { MeetingCard } from "@/components/MeetingCard";
|
||||
import { PastMeetingCard } from "@/components/PastMeetingCard";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import {
|
||||
@@ -44,6 +45,11 @@ function formatDate(date: Date) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** Sans le jour de la semaine : la carte d'archive est déjà dense. */
|
||||
function formatPastDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
|
||||
}
|
||||
@@ -232,40 +238,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>
|
||||
@@ -298,16 +295,18 @@ export default async function AssociationPage() {
|
||||
? registrationCounts.get(meeting.meetingId) ?? 0
|
||||
: splitLines(meeting.participants ?? "").length;
|
||||
return (
|
||||
<article key={meeting.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.eventDate)}</div>
|
||||
<h3 className="font-display" style={{ color: "#26201a", fontSize: 22, margin: "7px 0" }}>{meeting.title}</h3>
|
||||
{meeting.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>}
|
||||
{meeting.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{meeting.description}</p>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
{participantCount} participant{participantCount > 1 ? "s" : ""} · {photos.length} photo{photos.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<PastMeetingGallery title={meeting.title} photos={photos} />
|
||||
</article>
|
||||
<PastMeetingCard
|
||||
key={meeting.id}
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
dateLabel: formatPastDate(meeting.eventDate),
|
||||
location: meeting.location,
|
||||
description: meeting.description,
|
||||
participantCount,
|
||||
photos: photos.map((photo) => ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+798
-70
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'expédie pour l'instant : les mots de passe temporaires restent affichés à
|
||||
l'é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'application
|
||||
<input className="field" name="appId" required defaultValue={account?.appId ?? ""} style={{ marginTop: 6 }} />
|
||||
</label>
|
||||
<label className="field-label">
|
||||
Secret de l'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'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'envoi</div>
|
||||
{log.length === 0 ? (
|
||||
<div style={{ color: "#a99c82", fontSize: 13 }}>Aucun message expédié pour l'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,8 +10,19 @@ 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);
|
||||
@@ -28,6 +39,8 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== "new") return null;
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async (fd) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,9 @@ 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";
|
||||
import { FIELD_MAX_BYTES, compressionSummary } from "@/lib/image-compress";
|
||||
import { prepareImageFile } from "@/lib/image-compress-dom";
|
||||
|
||||
const STRIPE_WARM =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
@@ -51,6 +54,41 @@ function SummaryRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -78,6 +116,11 @@ export function MemberSpaceForm({
|
||||
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 [imgNote, setImgNote] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<SocialNetwork[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -88,15 +131,30 @@ export function MemberSpaceForm({
|
||||
setSubmitted(false);
|
||||
}
|
||||
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
/**
|
||||
* Le navigateur compresse avant l'envoi. Une photo de téléphone dépasse la
|
||||
* limite de 3 Mo du serveur : elle était refusée en pleine publication, sans
|
||||
* que l'adhérent puisse rien y faire depuis son écran. Le cadrage, lui, n'est
|
||||
* pas touché — le rapport largeur/hauteur est conservé, donc les remarques de
|
||||
* `ImageReport` sur ce que feront les réseaux restent vraies.
|
||||
*/
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgData(String(reader.result));
|
||||
setImgInfo(null);
|
||||
setImgNote("Compression en cours…");
|
||||
try {
|
||||
const prepared = await prepareImageFile(f, { maxBytes: FIELD_MAX_BYTES });
|
||||
setImgData(prepared.dataUri);
|
||||
setSubmitted(false);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
// Le résumé décrit l'image telle qu'elle sera publiée, pas le fichier
|
||||
// d'origine : c'est celle-là que verront le site et les réseaux.
|
||||
setImgInfo({ width: prepared.width, height: prepared.height, bytes: prepared.bytes });
|
||||
setImgNote(prepared.recompressed ? compressionSummary(prepared) : null);
|
||||
} catch (cause) {
|
||||
setImgNote(cause instanceof Error ? cause.message : "Image refusée.");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -107,6 +165,8 @@ export function MemberSpaceForm({
|
||||
setEndsOn("");
|
||||
setPublishAt("");
|
||||
setImgData("");
|
||||
setImgInfo(null);
|
||||
setImgNote(null);
|
||||
setShares([]);
|
||||
setSubmitted(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
@@ -194,6 +254,11 @@ export function MemberSpaceForm({
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||
</label>
|
||||
|
||||
{imgNote && (
|
||||
<div style={{ marginTop: -8, marginBottom: 10, fontSize: 12.5, color: "#6c6150" }}>{imgNote}</div>
|
||||
)}
|
||||
{imgInfo && <ImageReport info={imgInfo} />}
|
||||
|
||||
<label className="field-label">Titre de la promotion</label>
|
||||
<input
|
||||
value={title}
|
||||
|
||||
@@ -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'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'association n'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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
@@ -51,6 +52,10 @@ 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";
|
||||
@@ -139,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 }}>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'est encore visible dans l'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'est
|
||||
posée sur le site. La mesure d'audience ne couvre que les pages publiques — ni le
|
||||
backoffice, ni l'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'activer.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -246,7 +246,9 @@ 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'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'est plus modifiable ensuite.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ModalShell } from "@/components/ModalShell";
|
||||
import { PHOTO_MAX_BYTES, base64Bytes, chunkByBytes, compressionSummary } from "@/lib/image-compress";
|
||||
import { prepareImageFile } from "@/lib/image-compress-dom";
|
||||
import type { ArchiveDraft, DialogPhoto, MeetingOption } from "./draft";
|
||||
import {
|
||||
addPastMeetingPhotos,
|
||||
deletePastMeeting,
|
||||
savePastMeeting,
|
||||
savePastMeetingPhotos,
|
||||
} from "../actions";
|
||||
|
||||
/**
|
||||
* Bouton d'ouverture de la fenêtre d'édition. La fenêtre n'est montée qu'à
|
||||
* l'ouverture : chaque ouverture repart donc de la fiche telle qu'elle est en
|
||||
* base, sans état résiduel de la précédente.
|
||||
*/
|
||||
export function PastMeetingDialogButton({
|
||||
draft,
|
||||
meetings,
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
draft: ArchiveDraft;
|
||||
meetings: MeetingOption[];
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)} style={style}>
|
||||
{children}
|
||||
</button>
|
||||
{open && <PastMeetingDialog draft={draft} meetings={meetings} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PastMeetingDialog({
|
||||
draft,
|
||||
meetings,
|
||||
onClose,
|
||||
}: {
|
||||
draft: ArchiveDraft;
|
||||
meetings: MeetingOption[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const photosRef = useRef<HTMLDivElement>(null);
|
||||
const [fields, setFields] = useState(draft);
|
||||
const [photos, setPhotos] = useState<DialogPhoto[]>(draft.photos);
|
||||
const [over, setOver] = useState(false);
|
||||
const [working, setWorking] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<string[]>([]);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [refreshing, startRefresh] = useTransition();
|
||||
|
||||
/**
|
||||
* On ne referme qu'une fois la page rafraîchie. Sans cette attente, rouvrir la
|
||||
* fiche dans la foulée montrerait encore l'état d'avant l'enregistrement — la
|
||||
* rencontre liée revenant à « Aucune », par exemple — et on croirait que rien
|
||||
* n'a été retenu.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (closing && !refreshing) onClose();
|
||||
}, [closing, refreshing, onClose]);
|
||||
|
||||
const linked = meetings.find((meeting) => String(meeting.id) === fields.meetingId);
|
||||
const busy = working !== null || closing;
|
||||
|
||||
function set<K extends keyof ArchiveDraft>(key: K, value: ArchiveDraft[K]) {
|
||||
setFields((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compression **avant** l'envoi : une photo de téléphone pèse 4 à 12 Mo, la
|
||||
* limite d'une server action est à 4 Mo. Le navigateur redimensionne et
|
||||
* réencode, en ne descendant en qualité que le strict nécessaire.
|
||||
*/
|
||||
async function addFiles(list: FileList | null) {
|
||||
if (!list || list.length === 0) return;
|
||||
const files = Array.from(list);
|
||||
setError(null);
|
||||
const added: DialogPhoto[] = [];
|
||||
const messages: string[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
setWorking(`Compression de la photo ${index + 1} sur ${files.length}…`);
|
||||
try {
|
||||
const prepared = await prepareImageFile(file, { maxBytes: PHOTO_MAX_BYTES });
|
||||
added.push({
|
||||
key: `${Date.now()}-${index}-${file.name}`,
|
||||
id: null,
|
||||
imageUrl: prepared.dataUri,
|
||||
caption: "",
|
||||
bytes: prepared.bytes,
|
||||
});
|
||||
messages.push(`${file.name} — ${compressionSummary(prepared)}`);
|
||||
} catch (cause) {
|
||||
messages.push(`${file.name} — ${cause instanceof Error ? cause.message : "photo refusée"}`);
|
||||
}
|
||||
}
|
||||
setWorking(null);
|
||||
setPhotos((current) => [...current, ...added]);
|
||||
setNotes(messages);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
// La galerie est en bas de la fenêtre : sans cela, on dépose des photos
|
||||
// sans jamais voir apparaître ce qu'on vient d'ajouter.
|
||||
photosRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
|
||||
function movePhoto(index: number, step: number) {
|
||||
setPhotos((current) => {
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setError(null);
|
||||
setWorking("Enregistrement…");
|
||||
try {
|
||||
const form = new FormData();
|
||||
if (fields.id) form.set("id", String(fields.id));
|
||||
form.set("title", fields.title);
|
||||
form.set("eventDate", fields.eventDate);
|
||||
form.set("location", fields.location);
|
||||
form.set("description", fields.description);
|
||||
form.set("participants", fields.participants);
|
||||
form.set("meetingId", fields.meetingId);
|
||||
const saved = await savePastMeeting(form);
|
||||
if ("error" in saved) {
|
||||
setError(saved.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Les nouvelles photos partent en paquets : plusieurs data-URI dans une
|
||||
// seule requête dépasseraient la limite de corps des server actions.
|
||||
const fresh = photos.filter((photo) => photo.id === null);
|
||||
const groups = chunkByBytes(fresh.map((photo) => photo.bytes || base64Bytes(photo.imageUrl)));
|
||||
const freshIds: number[] = [];
|
||||
let sent = 0;
|
||||
for (const group of groups) {
|
||||
setWorking(`Envoi des photos ${sent + 1} à ${sent + group.length} sur ${fresh.length}…`);
|
||||
const batch = new FormData();
|
||||
batch.set("pastMeetingId", String(saved.id));
|
||||
for (const index of group) {
|
||||
batch.append("imageUrl", fresh[index].imageUrl);
|
||||
batch.append("caption", fresh[index].caption);
|
||||
}
|
||||
const result = await addPastMeetingPhotos(batch);
|
||||
if ("error" in result) {
|
||||
setError(`${result.error} Les photos déjà envoyées sont conservées.`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
freshIds.push(...result.ids);
|
||||
sent += group.length;
|
||||
}
|
||||
|
||||
// Passage final : ordre affiché, légendes, et suppression de ce qui a été
|
||||
// retiré de la fenêtre.
|
||||
setWorking("Mise en ordre de la galerie…");
|
||||
const sync = new FormData();
|
||||
sync.set("syncPhotos", "1");
|
||||
sync.set("pastMeetingId", String(saved.id));
|
||||
let cursor = 0;
|
||||
for (const photo of photos) {
|
||||
const photoId = photo.id ?? freshIds[cursor++];
|
||||
if (!photoId) continue;
|
||||
sync.append("photoId", String(photoId));
|
||||
sync.append("photoCaption", photo.caption);
|
||||
}
|
||||
const synced = await savePastMeetingPhotos(sync);
|
||||
if ("error" in synced) {
|
||||
setError(synced.error);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
setClosing(true);
|
||||
startRefresh(() => router.refresh());
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Enregistrement impossible.");
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!fields.id) return;
|
||||
if (!window.confirm("Supprimer définitivement cette rencontre passée et ses photos ?")) return;
|
||||
setWorking("Suppression…");
|
||||
const form = new FormData();
|
||||
form.set("id", String(fields.id));
|
||||
await deletePastMeeting(form);
|
||||
setWorking(null);
|
||||
setClosing(true);
|
||||
startRefresh(() => router.refresh());
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
label={fields.id ? `Modifier ${fields.title}` : "Nouvelle rencontre passée"}
|
||||
onClose={() => {
|
||||
if (busy) return;
|
||||
onClose();
|
||||
}}
|
||||
panelClassName="pm-panel pm-panel--form"
|
||||
>
|
||||
<div className="pm-form">
|
||||
<div className="pm-form__scroll">
|
||||
<h2 className="font-display" style={{ margin: "0 0 4px", fontSize: 22, color: "#26201a", paddingRight: 44 }}>
|
||||
{fields.id ? "Modifier la rencontre passée" : "Nouvelle rencontre passée"}
|
||||
</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>
|
||||
Tout se remplit ici : le récit, les participants et les photos — y compris pour une rencontre qui n'est pas
|
||||
encore publiée.
|
||||
</p>
|
||||
|
||||
{fields.refused.length > 0 && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 14 }}>
|
||||
<strong>Droit à l'image refusé :</strong> {fields.refused.join(", ")}. Vérifiez que ces personnes ne
|
||||
figurent pas sur les photos publiées.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-2" style={{ gap: 14 }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input className="field" value={fields.title} onChange={(event) => set("title", event.target.value)} maxLength={200} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input type="date" className="field" value={fields.eventDate} onChange={(event) => set("eventDate", event.target.value)} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input className="field" value={fields.location} onChange={(event) => set("location", event.target.value)} maxLength={240} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Rencontre liée (facultatif)</label>
|
||||
<select className="field" value={fields.meetingId} onChange={(event) => set("meetingId", event.target.value)}>
|
||||
<option value="">Aucune — participants saisis à la main</option>
|
||||
{meetings.map((meeting) => (
|
||||
<option key={meeting.id} value={String(meeting.id)}>
|
||||
{meeting.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6, lineHeight: 1.5 }}>
|
||||
{linked
|
||||
? `Le site affichera ${linked.registered} participant${linked.registered > 1 ? "s" : ""} d'après les inscriptions ; la liste saisie à la main est alors ignorée.`
|
||||
: "Sans rencontre liée, le nombre affiché est celui des lignes saisies ci-dessous (une personne par ligne)."}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Récit de la rencontre</label>
|
||||
<textarea
|
||||
className="field"
|
||||
rows={12}
|
||||
value={fields.description}
|
||||
onChange={(event) => set("description", event.target.value)}
|
||||
placeholder="Déroulé de la matinée, intervenants, décisions prises, prochaines étapes…"
|
||||
style={{ minHeight: 260, resize: "vertical", lineHeight: 1.7 }}
|
||||
/>
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6 }}>
|
||||
Les paragraphes sont conservés tels quels sur le site. {fields.description.length} caractères.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Participants (une personne par ligne)</label>
|
||||
<textarea
|
||||
className="field"
|
||||
rows={4}
|
||||
value={fields.participants}
|
||||
onChange={(event) => set("participants", event.target.value)}
|
||||
style={{ resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={photosRef} style={{ marginTop: 22, borderTop: "1px solid #efe7d6", paddingTop: 18 }}>
|
||||
<label className="field-label">Photos ({photos.length})</label>
|
||||
<div
|
||||
className={over ? "pm-dropzone pm-dropzone--over" : "pm-dropzone"}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setOver(true);
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setOver(false);
|
||||
void addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
Glissez plusieurs photos ici, ou choisissez-les sur votre appareil. Elles sont redimensionnées et
|
||||
compressées automatiquement : aucune n'est trop lourde.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 700, fontSize: 13, padding: "9px 16px", borderRadius: 9, cursor: busy ? "progress" : "pointer" }}
|
||||
>
|
||||
Choisir des photos
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(event) => void addFiles(event.target.files)}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<ul style={{ margin: "10px 0 0", paddingLeft: 18, color: "#8c8068", fontSize: 12.5, lineHeight: 1.6 }}>
|
||||
{notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="pm-photo-grid">
|
||||
{photos.map((photo, index) => (
|
||||
<div key={photo.key} className="pm-photo">
|
||||
<div className="pm-photo__image" style={{ backgroundImage: `url(${photo.imageUrl})` }} />
|
||||
<div className="pm-photo__tools">
|
||||
<button type="button" className="pm-photo__tool" onClick={() => movePhoto(index, -1)} disabled={index === 0} aria-label="Déplacer avant">←</button>
|
||||
<button type="button" className="pm-photo__tool" onClick={() => movePhoto(index, 1)} disabled={index === photos.length - 1} aria-label="Déplacer après">→</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pm-photo__tool pm-photo__tool--danger"
|
||||
onClick={() => setPhotos((current) => current.filter((item) => item.key !== photo.key))}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="field"
|
||||
style={{ border: "none", borderTop: "1px solid #efe7d6", borderRadius: 0, fontSize: 12.5 }}
|
||||
placeholder="Légende (facultative)"
|
||||
maxLength={200}
|
||||
value={photo.caption}
|
||||
onChange={(event) =>
|
||||
setPhotos((current) =>
|
||||
current.map((item) => (item.key === photo.key ? { ...item, caption: event.target.value } : item)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 10 }}>
|
||||
La première photo sert de couverture à la carte ; les flèches changent l'ordre de la galerie.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pm-form__foot">
|
||||
<div style={{ fontSize: 13, color: error ? "#a3372e" : "#6c6150", fontWeight: error ? 700 : 600, flex: "1 1 240px" }}>
|
||||
{error ?? working ?? (closing ? "Mise à jour de la page…" : fields.id ? "" : "La rencontre sera visible sur le site dès l'enregistrement.")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
{fields.id && (
|
||||
<button type="button" onClick={remove} disabled={busy} style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} disabled={busy} style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 700, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" }}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={busy} style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 18px", borderRadius: 8, cursor: busy ? "progress" : "pointer", opacity: busy ? 0.7 : 1 }}>
|
||||
{busy ? "Patientez…" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Forme d'une rencontre passée telle que la manipule la fenêtre d'édition.
|
||||
*
|
||||
* Ce module n'est **pas** marqué `"use client"` à dessein : la page (composant
|
||||
* serveur) appelle `emptyDraft()` pour préparer une fiche vierge. Exporté
|
||||
* depuis un module client, cet appel deviendrait une référence client, que le
|
||||
* serveur ne peut pas exécuter.
|
||||
*/
|
||||
|
||||
export type MeetingOption = { id: number; label: string; registered: number };
|
||||
|
||||
export type DialogPhoto = {
|
||||
/** Clé stable côté navigateur : une photo pas encore enregistrée n'a pas d'id. */
|
||||
key: string;
|
||||
id: number | null;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
/** Poids de la data-URI, utilisé pour découper les envois en paquets. */
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export type ArchiveDraft = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
location: string;
|
||||
description: string;
|
||||
participants: string;
|
||||
meetingId: string;
|
||||
photos: DialogPhoto[];
|
||||
/** Personnes ayant refusé le droit à l'image sur la rencontre liée. */
|
||||
refused: string[];
|
||||
};
|
||||
|
||||
/** Fiche vierge, datée d'aujourd'hui dans le fuseau du navigateur du serveur. */
|
||||
export function emptyDraft(): ArchiveDraft {
|
||||
const today = new Date();
|
||||
today.setMinutes(today.getMinutes() - today.getTimezoneOffset());
|
||||
return {
|
||||
id: null,
|
||||
title: "",
|
||||
eventDate: today.toISOString().slice(0, 10),
|
||||
location: "",
|
||||
description: "",
|
||||
participants: "",
|
||||
meetingId: "",
|
||||
photos: [],
|
||||
refused: [],
|
||||
};
|
||||
}
|
||||
@@ -4,15 +4,10 @@ import { asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||
import { ImageField } from "@/components/ImageField";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
addPastMeetingPhoto,
|
||||
createPastMeeting,
|
||||
deletePastMeeting,
|
||||
deletePastMeetingPhoto,
|
||||
updatePastMeeting,
|
||||
} from "../actions";
|
||||
import { splitLines } from "@/lib/site-settings";
|
||||
import { PastMeetingDialogButton } from "./PastMeetingDialog";
|
||||
import { emptyDraft, type ArchiveDraft, type MeetingOption } from "./draft";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,6 +17,10 @@ function dateValue(date: Date) {
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
export default async function PastMeetingsAdminPage() {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||
@@ -52,121 +51,110 @@ export default async function PastMeetingsAdminPage() {
|
||||
const refusalsByMeeting = new Map<number, string[]>();
|
||||
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
|
||||
|
||||
const meetingOptions: MeetingOption[] = meetingRows.map((meeting) => ({
|
||||
id: meeting.id,
|
||||
registered: Number(meeting.registered),
|
||||
label: `${meeting.title} — ${formatDate(meeting.startsAt)} (${Number(meeting.registered)} inscrit${Number(meeting.registered) > 1 ? "s" : ""})`,
|
||||
}));
|
||||
|
||||
const drafts: ArchiveDraft[] = archives.map((archive) => ({
|
||||
id: archive.id,
|
||||
title: archive.title,
|
||||
eventDate: dateValue(archive.eventDate),
|
||||
location: archive.location ?? "",
|
||||
description: archive.description ?? "",
|
||||
participants: archive.participants ?? "",
|
||||
meetingId: archive.meetingId ? String(archive.meetingId) : "",
|
||||
// `bytes` ne sert qu'aux photos à envoyer : celles déjà en base ne repartent pas.
|
||||
photos: (photosByPast.get(archive.id) ?? []).map((photo) => ({
|
||||
key: `photo-${photo.id}`,
|
||||
id: photo.id,
|
||||
imageUrl: photo.imageUrl,
|
||||
caption: photo.caption ?? "",
|
||||
bytes: 0,
|
||||
})),
|
||||
refused: archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 28 }}>
|
||||
<div style={{ display: "grid", gap: 24 }}>
|
||||
<section style={panelStyle}>
|
||||
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les trois archives les plus récentes apparaissent sur « L'association » et la page complète rassemble toutes les rencontres passées.</p>
|
||||
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" className="field" required />
|
||||
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Rencontres passées</h2>
|
||||
<p style={{ color: "#8c8068", fontSize: 13, margin: 0, maxWidth: 620, lineHeight: 1.6 }}>
|
||||
Une fenêtre par rencontre : récit, participants et photos au même endroit. Les trois plus récentes
|
||||
apparaissent sur « L'association », toutes sur la page des rencontres passées.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue="">
|
||||
<option value="">Aucune</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} className="field" />
|
||||
</div>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="field-label">Participants manuels (un par ligne, uniquement sans rencontre liée)</label>
|
||||
<textarea name="participants" rows={3} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Publier l'archive</button>
|
||||
</form>
|
||||
<PastMeetingDialogButton draft={emptyDraft()} meetings={meetingOptions} style={primaryButton}>
|
||||
Nouvelle rencontre passée
|
||||
</PastMeetingDialogButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={sectionTitle}>Archives publiées</div>
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{archives.map((archive) => {
|
||||
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
|
||||
return (
|
||||
<article key={archive.id} style={cardStyle}>
|
||||
{refused.length > 0 && (
|
||||
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
|
||||
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
|
||||
</div>
|
||||
)}
|
||||
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<div>
|
||||
<label className="field-label">Titre</label>
|
||||
<input name="title" defaultValue={archive.title} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Date</label>
|
||||
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Lieu</label>
|
||||
<input name="location" defaultValue={archive.location ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Rencontre liée</label>
|
||||
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
|
||||
<option value="">Aucune</option>
|
||||
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Description</label>
|
||||
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Participants manuels</label>
|
||||
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
|
||||
</div>
|
||||
<button type="submit" style={primaryButton}>Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||||
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
|
||||
<input type="hidden" name="pastMeetingId" value={archive.id} />
|
||||
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
|
||||
<input name="caption" className="field" placeholder="Légende" />
|
||||
<button type="submit" style={primaryButton}>Ajouter la photo</button>
|
||||
</form>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
|
||||
{(photosByPast.get(archive.id) ?? []).map((photo) => (
|
||||
<form key={photo.id} action={deletePastMeetingPhoto}>
|
||||
<input type="hidden" name="id" value={photo.id} />
|
||||
<button type="submit" title="Supprimer" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
|
||||
<input type="hidden" name="id" value={archive.id} />
|
||||
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
|
||||
{archives.length === 0 ? (
|
||||
<div style={emptyStyle}>Aucune rencontre passée publiée.</div>
|
||||
) : (
|
||||
<div className="grid grid-3" style={{ gap: 18 }}>
|
||||
{archives.map((archive, index) => {
|
||||
const draft = drafts[index];
|
||||
const archivePhotos = photosByPast.get(archive.id) ?? [];
|
||||
const participantCount = archive.meetingId
|
||||
? meetingOptions.find((option) => option.id === archive.meetingId)?.registered ?? 0
|
||||
: splitLines(archive.participants ?? "").length;
|
||||
const cover = archivePhotos[0]?.imageUrl;
|
||||
return (
|
||||
<PastMeetingDialogButton key={archive.id} draft={draft} meetings={meetingOptions} style={cardButton}>
|
||||
<span
|
||||
className={cover ? "pm-card__cover" : "pm-card__cover pm-card__cover--empty"}
|
||||
style={{ display: "block", borderRadius: "10px 10px 0 0", ...(cover ? { backgroundImage: `url(${cover})` } : {}) }}
|
||||
>
|
||||
{archivePhotos.length > 0 && (
|
||||
<span className="pm-card__count">{archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="pm-card__body" style={{ display: "flex" }}>
|
||||
<span style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</span>
|
||||
<span className="font-display pm-card__title" style={{ display: "-webkit-box" }}>{archive.title}</span>
|
||||
{archive.location && <span className="pm-line" style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</span>}
|
||||
{archive.description && <span className="pm-card__text" style={{ display: "-webkit-box" }}>{archive.description}</span>}
|
||||
<span className="pm-card__meta">
|
||||
<span>{participantCount} participant{participantCount > 1 ? "s" : ""}</span>
|
||||
<span className="pm-card__more">Modifier →</span>
|
||||
</span>
|
||||
</span>
|
||||
{draft.refused.length > 0 && (
|
||||
<span style={{ display: "block", background: "#fbe9e6", color: "#b53a25", fontSize: 12, fontWeight: 700, padding: "8px 14px" }}>
|
||||
Droit à l'image refusé par {draft.refused.length} participant{draft.refused.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</PastMeetingDialogButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
|
||||
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
|
||||
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
|
||||
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
|
||||
const cardButton: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: 424,
|
||||
textAlign: "left",
|
||||
background: "#fff",
|
||||
border: "1px solid #e6dcc6",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
};
|
||||
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
@@ -1554,3 +1805,306 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---- Rencontres passées : cartes égales et fenêtre de lecture ----
|
||||
Les cartes ont une hauteur fixe quels que soient la longueur du texte et le
|
||||
nombre de photos : c'est la grille qui doit rester lisible, le détail vit
|
||||
dans la fenêtre. D'où les `line-clamp` — un CSS que des styles en ligne ne
|
||||
peuvent pas porter, comme les points de rupture ci-dessous. */
|
||||
.pm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 424px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #e6dcc6;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.pm-card:hover,
|
||||
.pm-card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 14px 30px rgba(38, 32, 26, 0.13);
|
||||
}
|
||||
.pm-card__cover {
|
||||
position: relative;
|
||||
height: 178px;
|
||||
flex: none;
|
||||
background: #ece4d3 center/cover no-repeat;
|
||||
}
|
||||
.pm-card__cover--empty {
|
||||
background: repeating-linear-gradient(135deg, #efe7d6, #efe7d6 12px, #e7ddc8 12px, #e7ddc8 24px);
|
||||
}
|
||||
.pm-card__count {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
background: rgba(19, 50, 79, 0.82);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pm-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 15px 17px 16px;
|
||||
}
|
||||
.pm-card__title {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 21px;
|
||||
line-height: 1.2;
|
||||
color: #26201a;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pm-card__text {
|
||||
margin: 6px 0 0;
|
||||
color: #8c8068;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pm-card__meta {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #6c6150;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pm-card__more {
|
||||
color: #13324f;
|
||||
}
|
||||
.pm-line {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Fenêtre (site public et backoffice) ---- */
|
||||
.pm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
background: rgba(19, 50, 79, 0.74);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
.pm-panel {
|
||||
width: min(1080px, 100%);
|
||||
max-height: min(92vh, 940px);
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.85fr);
|
||||
box-shadow: 0 30px 70px rgba(15, 25, 40, 0.38);
|
||||
}
|
||||
.pm-panel--form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: min(920px, 100%);
|
||||
}
|
||||
.pm-panel__media {
|
||||
background: #101f2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.pm-panel__stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
}
|
||||
.pm-panel__stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pm-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #13324f;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pm-arrow:hover {
|
||||
background: #fff;
|
||||
}
|
||||
.pm-arrow--prev {
|
||||
left: 12px;
|
||||
}
|
||||
.pm-arrow--next {
|
||||
right: 12px;
|
||||
}
|
||||
.pm-thumbs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pm-thumb {
|
||||
flex: none;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background: #24384a center/cover no-repeat;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.62;
|
||||
}
|
||||
.pm-thumb--active {
|
||||
border-color: #e0a63c;
|
||||
opacity: 1;
|
||||
}
|
||||
.pm-panel__info {
|
||||
padding: 26px 26px 28px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.pm-panel__close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 18px;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
background: #fff;
|
||||
color: #13324f;
|
||||
border-radius: 999px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 16px rgba(15, 25, 40, 0.24);
|
||||
}
|
||||
.pm-body-text {
|
||||
color: #5f5646;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.75;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ---- Fenêtre d'édition du backoffice ---- */
|
||||
.pm-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(92vh, 940px);
|
||||
}
|
||||
.pm-form__scroll {
|
||||
overflow: auto;
|
||||
padding: 22px 24px 8px;
|
||||
}
|
||||
.pm-form__foot {
|
||||
border-top: 1px solid #efe7d6;
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: #fdfbf6;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pm-dropzone {
|
||||
border: 1px dashed #d6c9ad;
|
||||
border-radius: 12px;
|
||||
background: #fdfbf6;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
color: #8c8068;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pm-dropzone--over {
|
||||
border-color: #13324f;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.pm-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.pm-photo {
|
||||
border: 1px solid #e6dcc6;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.pm-photo__image {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #f1ebdd center/cover no-repeat;
|
||||
}
|
||||
.pm-photo__tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
.pm-photo__tool {
|
||||
border: 1px solid #e6dcc6;
|
||||
background: #fff;
|
||||
color: #6f6450;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pm-photo__tool--danger {
|
||||
color: #d8472b;
|
||||
border-color: #e9cfc7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.pm-panel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-height: 94vh;
|
||||
}
|
||||
.pm-panel__stage {
|
||||
min-height: 220px;
|
||||
}
|
||||
.pm-backdrop {
|
||||
padding: 12px;
|
||||
}
|
||||
.pm-panel__info {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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>
|
||||
);
|
||||
|
||||
+5
-5
@@ -58,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%",
|
||||
@@ -107,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 & entreprises du Bassin de Pompey
|
||||
Association des commerçants & 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
|
||||
@@ -359,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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { asc, desc, inArray } from "drizzle-orm";
|
||||
import { JsonLd } from "@/components/JsonLd";
|
||||
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
|
||||
import { PastMeetingCard } from "@/components/PastMeetingCard";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { db } from "@/db";
|
||||
@@ -79,16 +79,18 @@ export default async function PastMeetingsPage() {
|
||||
? registrationCounts.get(archive.meetingId) ?? 0
|
||||
: splitLines(archive.participants ?? "").length;
|
||||
return (
|
||||
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
|
||||
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
|
||||
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
|
||||
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
{participantCount} participant{participantCount > 1 ? "s" : ""} · {archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
<PastMeetingGallery title={archive.title} photos={archivePhotos} />
|
||||
</article>
|
||||
<PastMeetingCard
|
||||
key={archive.id}
|
||||
meeting={{
|
||||
id: archive.id,
|
||||
title: archive.title,
|
||||
dateLabel: formatDate(archive.eventDate),
|
||||
location: archive.location,
|
||||
description: archive.description,
|
||||
participantCount,
|
||||
photos: archivePhotos.map((photo) => ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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) }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'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'expédie pour l'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>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { FIELD_MAX_BYTES, compressionSummary } from "@/lib/image-compress";
|
||||
import { prepareImageFile } from "@/lib/image-compress-dom";
|
||||
|
||||
export function ImageField({
|
||||
name,
|
||||
@@ -8,35 +10,48 @@ export function ImageField({
|
||||
defaultValue = "",
|
||||
height = 130,
|
||||
fit = "cover",
|
||||
maxBytes = FIELD_MAX_BYTES,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
height?: number;
|
||||
fit?: "cover" | "contain";
|
||||
maxBytes?: number;
|
||||
}) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
// Limite raisonnable : les data-URL transitent par une server action (~1 Mo max).
|
||||
if (f.size > 900_000) {
|
||||
setError("Image trop lourde (max ~900 Ko). Compressez-la d'abord.");
|
||||
setNote(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// Plus de refus « image trop lourde » : le navigateur redimensionne et
|
||||
// compresse lui-même, en ne descendant que le nécessaire.
|
||||
const prepared = await prepareImageFile(file, { maxBytes });
|
||||
startTransition(() => {
|
||||
setValue(prepared.dataUri);
|
||||
setNote(compressionSummary(prepared));
|
||||
});
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Image refusée.");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setValue(String(reader.result));
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setValue("");
|
||||
setError(null);
|
||||
setNote(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
@@ -62,18 +77,19 @@ export function ImageField({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={value} alt="" style={{ maxWidth: "86%", maxHeight: "86%", objectFit: "contain", display: "block" }} />
|
||||
)}
|
||||
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>Aucune image</span>}
|
||||
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>{busy ? "Compression en cours…" : "Aucune image"}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}
|
||||
disabled={busy}
|
||||
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "8px 14px", borderRadius: 9, cursor: busy ? "progress" : "pointer" }}
|
||||
>
|
||||
{value ? "Changer l'image" : "Choisir une image"}
|
||||
{busy ? "Compression…" : value ? "Changer l'image" : "Choisir une image"}
|
||||
</button>
|
||||
{value && (
|
||||
{value && !busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
@@ -85,6 +101,7 @@ export function ImageField({
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
|
||||
</div>
|
||||
|
||||
{note && !error && <div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6 }}>{note}</div>}
|
||||
{error && <div style={{ fontSize: 12.5, color: "#a3372e", marginTop: 6 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`))}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'annuaire et sur la fiche
|
||||
<strong> uniquement aux visiteurs connectés</strong> : rien de tout cela n'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/**
|
||||
* Enveloppe commune aux fenêtres du site et du backoffice : fond assombri,
|
||||
* fermeture au clic extérieur, à la touche Échap et par le bouton de fermeture,
|
||||
* page figée derrière.
|
||||
*
|
||||
* Le blocage du défilement de l'arrière-plan compte plus qu'il n'en a l'air :
|
||||
* sans lui, la molette dans une galerie plein écran fait défiler la page
|
||||
* *derrière* la fenêtre, et on la retrouve ailleurs en refermant.
|
||||
*
|
||||
* Le rendu passe par un **portail** vers `<body>`, et ce n'est pas un détail :
|
||||
* une carte survolée porte un `transform`, qui redéfinit le bloc conteneur des
|
||||
* éléments `position: fixed` qu'elle contient. Rendue sur place, la fenêtre
|
||||
* s'ouvrirait donc *dans* la carte. Le portail la sort aussi du bouton qui
|
||||
* l'ouvre, où des champs de saisie n'auraient rien à faire.
|
||||
*/
|
||||
export function ModalShell({
|
||||
label,
|
||||
onClose,
|
||||
panelClassName = "pm-panel",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClose: () => void;
|
||||
panelClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
panelRef.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="pm-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={panelClassName}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
style={{ position: "relative", outline: "none" }}
|
||||
>
|
||||
<button type="button" className="pm-panel__close" onClick={onClose} aria-label="Fermer">
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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'est affiché qu'une seule fois et n'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>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ModalShell } from "./ModalShell";
|
||||
|
||||
export type PastMeetingPhotoView = { id: number; imageUrl: string; caption: string | null };
|
||||
|
||||
export type PastMeetingView = {
|
||||
id: number;
|
||||
title: string;
|
||||
/** Date déjà mise en forme côté serveur : le composant ne refait pas de locale. */
|
||||
dateLabel: string;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
participantCount: number;
|
||||
photos: PastMeetingPhotoView[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Carte d'une rencontre passée, de hauteur fixe, et sa fenêtre de lecture.
|
||||
*
|
||||
* La carte ne montre qu'un aperçu — couverture, titre sur deux lignes, trois
|
||||
* lignes de texte, compteurs : une rencontre richement racontée et une autre en
|
||||
* deux phrases occupent ainsi la même place dans la grille. Le récit complet et
|
||||
* la totalité des photos sont dans la fenêtre, où la place ne manque plus.
|
||||
*/
|
||||
export function PastMeetingCard({ meeting }: { meeting: PastMeetingView }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cover = meeting.photos[0]?.imageUrl;
|
||||
|
||||
return (
|
||||
<article className="pm-card" style={{ position: "relative" }}>
|
||||
<div
|
||||
className={cover ? "pm-card__cover" : "pm-card__cover pm-card__cover--empty"}
|
||||
style={cover ? { backgroundImage: `url(${cover})` } : undefined}
|
||||
>
|
||||
{meeting.photos.length > 0 && (
|
||||
<span className="pm-card__count">
|
||||
{meeting.photos.length} photo{meeting.photos.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pm-card__body">
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{meeting.dateLabel}</div>
|
||||
<h3 className="font-display pm-card__title">{meeting.title}</h3>
|
||||
{meeting.location && (
|
||||
<div className="pm-line" style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>
|
||||
)}
|
||||
{meeting.description && <p className="pm-card__text">{meeting.description}</p>}
|
||||
<div className="pm-card__meta">
|
||||
<span>
|
||||
{meeting.participantCount} participant{meeting.participantCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="pm-card__more">Voir la rencontre →</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toute la carte est cliquable, mais un seul élément interactif : le
|
||||
titre reste un vrai titre, et la tabulation ne traverse pas la carte. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Ouvrir la rencontre : ${meeting.title}`}
|
||||
style={{ position: "absolute", inset: 0, border: "none", background: "none", cursor: "pointer", borderRadius: 12 }}
|
||||
/>
|
||||
|
||||
{open && <PastMeetingModal meeting={meeting} onClose={() => setOpen(false)} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PastMeetingModal({ meeting, onClose }: { meeting: PastMeetingView; onClose: () => void }) {
|
||||
const photos = meeting.photos;
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = photos[index];
|
||||
|
||||
const move = useCallback(
|
||||
(step: number) => {
|
||||
if (photos.length === 0) return;
|
||||
setIndex((value) => (value + step + photos.length) % photos.length);
|
||||
},
|
||||
[photos.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowRight") move(1);
|
||||
if (event.key === "ArrowLeft") move(-1);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [move]);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
label={`Rencontre : ${meeting.title}`}
|
||||
onClose={onClose}
|
||||
panelClassName={photos.length > 0 ? "pm-panel" : "pm-panel pm-panel--form"}
|
||||
>
|
||||
{photos.length > 0 && (
|
||||
<div className="pm-panel__media">
|
||||
<div className="pm-panel__stage">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={current.imageUrl} alt={current.caption ?? `${meeting.title} — photo ${index + 1}`} />
|
||||
{photos.length > 1 && (
|
||||
<>
|
||||
<button type="button" className="pm-arrow pm-arrow--prev" onClick={() => move(-1)} aria-label="Photo précédente">‹</button>
|
||||
<button type="button" className="pm-arrow pm-arrow--next" onClick={() => move(1)} aria-label="Photo suivante">›</button>
|
||||
</>
|
||||
)}
|
||||
<span style={{ position: "absolute", left: 16, bottom: 16, background: "rgba(16,31,46,0.78)", color: "#fff", borderRadius: 999, padding: "4px 11px", fontSize: 12, fontWeight: 800 }}>
|
||||
{index + 1} / {photos.length}
|
||||
</span>
|
||||
</div>
|
||||
{current.caption && (
|
||||
<div style={{ color: "#cfe0ee", fontSize: 13, padding: "0 16px", lineHeight: 1.5 }}>{current.caption}</div>
|
||||
)}
|
||||
{photos.length > 1 && (
|
||||
<div className="pm-thumbs">
|
||||
{photos.map((photo, position) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className={position === index ? "pm-thumb pm-thumb--active" : "pm-thumb"}
|
||||
style={{ backgroundImage: `url(${photo.imageUrl})` }}
|
||||
onClick={() => setIndex(position)}
|
||||
aria-label={`Photo ${position + 1}`}
|
||||
aria-current={position === index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pm-panel__info">
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.04em", textTransform: "uppercase" }}>{meeting.dateLabel}</div>
|
||||
<h2 className="font-display" style={{ margin: "8px 0 10px", color: "#26201a", fontSize: 30, lineHeight: 1.15, paddingRight: 34 }}>{meeting.title}</h2>
|
||||
{meeting.location && <div style={{ color: "#6c6150", fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{meeting.location}</div>}
|
||||
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 16 }}>
|
||||
{meeting.participantCount} participant{meeting.participantCount > 1 ? "s" : ""}
|
||||
{photos.length > 0 && ` · ${photos.length} photo${photos.length > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
{meeting.description && <p className="pm-body-text">{meeting.description}</p>}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function PastMeetingGallery({
|
||||
title,
|
||||
photos,
|
||||
}: {
|
||||
title: string;
|
||||
photos: { id: number; imageUrl: string; caption: string | null }[];
|
||||
}) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
if (photos.length === 0) return null;
|
||||
const current = open == null ? null : photos.find((photo) => photo.id === open);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 14 }}>
|
||||
{photos.slice(0, 4).map((photo, index) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
onClick={() => setOpen(photo.id)}
|
||||
aria-label={`Ouvrir photo ${index + 1}`}
|
||||
style={{
|
||||
position: "relative",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
aspectRatio: "1 / 1",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
background: `center/cover no-repeat url(${photo.imageUrl})`,
|
||||
}}
|
||||
>
|
||||
{index === 3 && photos.length > 4 && (
|
||||
<span style={{ position: "absolute", inset: 0, background: "rgba(19,50,79,0.62)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800 }}>
|
||||
+{photos.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Galerie ${title}`}
|
||||
onClick={() => setOpen(null)}
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(19,50,79,0.72)", zIndex: 80, padding: 24, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 980, width: "100%" }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={current.imageUrl} alt={current.caption ?? title} style={{ width: "100%", maxHeight: "78vh", objectFit: "contain", background: "#fff", borderRadius: 12 }} />
|
||||
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ color: "#fff", fontWeight: 700 }}>{current.caption ?? title}</div>
|
||||
<button type="button" onClick={() => setOpen(null)} style={{ border: "none", background: "#fff", color: "#13324F", borderRadius: 999, padding: "9px 16px", fontWeight: 800, cursor: "pointer" }}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'adresse d'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -42,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", {
|
||||
@@ -62,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 }),
|
||||
@@ -285,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, {
|
||||
@@ -355,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;
|
||||
@@ -371,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;
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* Boucle de libération des publications programmées. Fichier séparé de
|
||||
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
|
||||
* le bundle edge du middleware.
|
||||
* 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 INTERVAL_MS = 60_000;
|
||||
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 tick() {
|
||||
async function promoTick() {
|
||||
try {
|
||||
const n = await releaseDuePromotions();
|
||||
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
|
||||
@@ -18,7 +24,23 @@ async function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
void tick();
|
||||
setInterval(tick, INTERVAL_MS).unref();
|
||||
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();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Démarrage du serveur : libérateur des publications programmées.
|
||||
* 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
|
||||
@@ -16,6 +16,9 @@ export async function register() {
|
||||
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
|
||||
// pas entrer dans ce bundle.
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
if (process.env.PROMO_SCHEDULER === "off") 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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 · ${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 }) };
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Compression d'une image **dans le navigateur**, avant qu'elle ne parte vers
|
||||
* une server action. Pendant du module pur `image-compress.ts`, qui porte les
|
||||
* seuils et les paliers ; ici vivent le décodage, le canvas et l'encodage,
|
||||
* c'est-à-dire tout ce qui a besoin d'un DOM.
|
||||
*
|
||||
* Trois choix méritent d'être expliqués :
|
||||
*
|
||||
* - **L'orientation EXIF est appliquée au décodage** (`createImageBitmap` avec
|
||||
* `imageOrientation: "from-image"`). Une photo prise à l'horizontale porte
|
||||
* souvent sa rotation dans ses métadonnées ; dessinée telle quelle sur un
|
||||
* canvas, elle repartirait couchée, l'information perdue.
|
||||
* - **Une image avec transparence ne devient jamais un JPEG** : le logo d'un
|
||||
* adhérent se retrouverait sur fond noir. On passe alors par WebP, et par
|
||||
* PNG si le navigateur ne sait pas encoder le WebP.
|
||||
* - **Une image déjà dans le budget n'est pas touchée** : un réencodage JPEG
|
||||
* dégrade toujours un peu, même à qualité élevée.
|
||||
*/
|
||||
|
||||
import {
|
||||
FIELD_MAX_BYTES,
|
||||
MAX_SIDE,
|
||||
base64Bytes,
|
||||
compressionSteps,
|
||||
needsCompression,
|
||||
scaleToFit,
|
||||
} from "./image-compress";
|
||||
|
||||
export type PreparedImage = {
|
||||
/** Image prête à partir, toujours en data-URI (jamais une URL : cf. SSRF). */
|
||||
dataUri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
originalBytes: number;
|
||||
recompressed: boolean;
|
||||
/** Nom du fichier d'origine, repris comme légende par défaut. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PrepareOptions = {
|
||||
maxBytes?: number;
|
||||
maxSide?: number;
|
||||
};
|
||||
|
||||
/** Formats que le serveur accepte tels quels (`asImageDataUri`). */
|
||||
const PASSTHROUGH = /^image\/(png|jpeg|jpg|webp|gif)$/i;
|
||||
|
||||
function readDataUri(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("Fichier illisible."));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type Decoded = { source: CanvasImageSource; width: number; height: number; release: () => void };
|
||||
|
||||
async function decode(file: File): Promise<Decoded> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
||||
return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() };
|
||||
} catch {
|
||||
// Navigateur sans `imageOrientation` (ou format non décodable) : on tente
|
||||
// la voie classique avant d'abandonner.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const element = new Image();
|
||||
element.onload = () => resolve(element);
|
||||
element.onerror = () => reject(new Error("decode"));
|
||||
element.src = url;
|
||||
});
|
||||
return {
|
||||
source: image,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
release: () => URL.revokeObjectURL(url),
|
||||
};
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
throw new Error(
|
||||
"Ce format d'image n'est pas lisible par le navigateur (HEIC d'iPhone, par exemple). " +
|
||||
"Exportez la photo en JPEG avant de la déposer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Une image sans transparence peut partir en JPEG, la plus économe des trois. */
|
||||
function hasAlpha(context: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
try {
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
// Un pixel sur quatre suffit à repérer une transparence : un fond
|
||||
// transparent n'est jamais un pixel isolé.
|
||||
for (let index = 3; index < data.length; index += 16) {
|
||||
if (data[index] < 255) return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
// Canvas « sali » (impossible ici : tout vient d'un fichier local) : dans le
|
||||
// doute on garde la transparence.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function draw(decoded: Decoded, size: { width: number; height: number }): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Le navigateur n'a pas pu préparer l'image.");
|
||||
context.drawImage(decoded.source, 0, 0, size.width, size.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function encode(canvas: HTMLCanvasElement, type: string, quality: number): string | null {
|
||||
const dataUri = canvas.toDataURL(type, quality);
|
||||
// Un type non géré fait retomber `toDataURL` sur le PNG sans prévenir.
|
||||
return dataUri.startsWith(`data:${type}`) ? dataUri : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare un fichier déposé : orientation redressée, définition ramenée à
|
||||
* `maxSide`, poids ramené sous `maxBytes` en descendant les paliers de qualité
|
||||
* seulement autant qu'il le faut.
|
||||
*
|
||||
* Lève une erreur porteuse d'un message affichable (fichier non image, format
|
||||
* non décodable). Ne lève jamais pour cause de poids : c'est précisément ce que
|
||||
* la fonction est là pour régler.
|
||||
*/
|
||||
export async function prepareImageFile(file: File, options: PrepareOptions = {}): Promise<PreparedImage> {
|
||||
const maxBytes = options.maxBytes ?? FIELD_MAX_BYTES;
|
||||
const maxSide = options.maxSide ?? MAX_SIDE;
|
||||
if (file.type && !file.type.startsWith("image/")) throw new Error("Ce fichier n'est pas une image.");
|
||||
|
||||
const decoded = await decode(file);
|
||||
try {
|
||||
const original = { bytes: file.size, width: decoded.width, height: decoded.height };
|
||||
|
||||
if (PASSTHROUGH.test(file.type) && !needsCompression(original, { maxBytes, maxSide })) {
|
||||
const dataUri = await readDataUri(file);
|
||||
return { ...original, dataUri, originalBytes: file.size, recompressed: false, name: file.name };
|
||||
}
|
||||
|
||||
// Premier tirage à définition pleine : il sert aussi à repérer une
|
||||
// transparence, donc à choisir le format de sortie.
|
||||
const full = scaleToFit(decoded.width, decoded.height, maxSide);
|
||||
const probe = draw(decoded, full);
|
||||
const context = probe.getContext("2d");
|
||||
const transparent = context ? hasAlpha(context, probe.width, probe.height) : false;
|
||||
const types = transparent ? ["image/webp", "image/png"] : ["image/jpeg"];
|
||||
|
||||
let best: { dataUri: string; bytes: number; size: { width: number; height: number } } | null = null;
|
||||
for (const step of compressionSteps(maxSide)) {
|
||||
const size = scaleToFit(decoded.width, decoded.height, step.maxSide);
|
||||
const canvas = size.width === probe.width && size.height === probe.height ? probe : draw(decoded, size);
|
||||
for (const type of types) {
|
||||
const dataUri = encode(canvas, type, step.quality);
|
||||
if (!dataUri) continue;
|
||||
const bytes = base64Bytes(dataUri);
|
||||
if (!best || bytes < best.bytes) best = { dataUri, bytes, size };
|
||||
if (bytes <= maxBytes) {
|
||||
return {
|
||||
dataUri,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
bytes,
|
||||
originalBytes: file.size,
|
||||
recompressed: true,
|
||||
name: file.name,
|
||||
};
|
||||
}
|
||||
// Le PNG ignore la qualité : inutile de redescendre l'échelle sur lui,
|
||||
// seule une définition plus petite le fera maigrir.
|
||||
if (type === "image/png") break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) throw new Error("Le navigateur n'a pas pu compresser cette image.");
|
||||
return {
|
||||
dataUri: best.dataUri,
|
||||
width: best.size.width,
|
||||
height: best.size.height,
|
||||
bytes: best.bytes,
|
||||
originalBytes: file.size,
|
||||
recompressed: true,
|
||||
name: file.name,
|
||||
};
|
||||
} finally {
|
||||
decoded.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Plan de compression des images déposées dans le backoffice.
|
||||
*
|
||||
* Module **pur** (aucun accès DOM, base ou réseau), verrouillé par
|
||||
* `tests/image-compress.test.ts`. Le travail sur le canvas — donc le seul code
|
||||
* qui a besoin d'un navigateur — vit dans `image-compress-dom.ts`.
|
||||
*
|
||||
* Pourquoi compresser côté navigateur : une image voyage jusqu'au serveur en
|
||||
* data-URI, à travers une server action plafonnée à 4 Mo, et finit stockée en
|
||||
* base. Une photo de téléphone (4 à 12 Mo) ne passe pas. Le réflexe précédent
|
||||
* était de la refuser — « Image trop lourde, compressez-la d'abord » — ce qui
|
||||
* revenait à demander à l'utilisateur de faire à la main ce que le navigateur
|
||||
* sait faire tout seul.
|
||||
*
|
||||
* La règle : on ne descend en qualité que le strict nécessaire. Une image déjà
|
||||
* dans le budget n'est **pas** réencodée (un réencodage JPEG n'est jamais
|
||||
* neutre), et le premier essai garde la pleine résolution à qualité quasi
|
||||
* maximale. Ce n'est qu'en cas d'échec qu'on baisse, d'abord la qualité, puis
|
||||
* la définition.
|
||||
*/
|
||||
|
||||
export type Size = { width: number; height: number };
|
||||
|
||||
/**
|
||||
* Côté le plus long conservé. 2048 px couvre le plein écran d'un portable
|
||||
* récent (et le grand format des réseaux) ; au-delà, l'œil ne voit rien de
|
||||
* plus, seul le poids grimpe.
|
||||
*/
|
||||
export const MAX_SIDE = 2048;
|
||||
|
||||
/** Budget d'une image de formulaire : couverture, logo, visuel de promotion. */
|
||||
export const FIELD_MAX_BYTES = 1_200_000;
|
||||
|
||||
/**
|
||||
* Budget d'une photo de galerie. Plus serré : plusieurs photos partent dans la
|
||||
* même requête, et une rencontre en compte facilement vingt.
|
||||
*/
|
||||
export const PHOTO_MAX_BYTES = 800_000;
|
||||
|
||||
/**
|
||||
* Taille d'un envoi groupé de photos. La limite des server actions est à 4 Mo
|
||||
* de corps de requête, et le base64 gonfle les octets d'un tiers : 2 Mo de
|
||||
* photos font ~2,7 Mo sur le fil, marge comprise.
|
||||
*/
|
||||
export const BATCH_MAX_BYTES = 2_000_000;
|
||||
|
||||
/** Plancher de qualité : en dessous, le grain se voit sur une photo de groupe. */
|
||||
export const MIN_QUALITY = 0.6;
|
||||
|
||||
const QUALITY_LADDER = [0.92, 0.84, 0.76, 0.68, MIN_QUALITY];
|
||||
|
||||
/** Définitions successives, en fraction du côté maximal demandé. */
|
||||
const SIDE_LADDER = [1, 0.78, 0.6, 0.45];
|
||||
|
||||
function positive(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions ramenées dans un carré de `maxSide`, rapport conservé. Une image
|
||||
* plus petite est renvoyée telle quelle : on ne l'agrandit jamais, cela
|
||||
* n'ajouterait que du flou et du poids.
|
||||
*/
|
||||
export function scaleToFit(width: number, height: number, maxSide: number = MAX_SIDE): Size {
|
||||
if (!positive(width) || !positive(height) || !positive(maxSide)) return { width: 0, height: 0 };
|
||||
const longest = Math.max(width, height);
|
||||
if (longest <= maxSide) return { width: Math.round(width), height: Math.round(height) };
|
||||
const ratio = maxSide / longest;
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * ratio)),
|
||||
height: Math.max(1, Math.round(height * ratio)),
|
||||
};
|
||||
}
|
||||
|
||||
export type CompressionStep = { maxSide: number; quality: number };
|
||||
|
||||
/**
|
||||
* Essais successifs, du meilleur au plus économe. On épuise la qualité à
|
||||
* définition pleine avant de réduire la définition : une photo de groupe reste
|
||||
* lisible bien plus longtemps en baissant la qualité qu'en perdant des pixels.
|
||||
*/
|
||||
export function compressionSteps(maxSide: number = MAX_SIDE): CompressionStep[] {
|
||||
if (!positive(maxSide)) return [];
|
||||
const steps: CompressionStep[] = [];
|
||||
SIDE_LADDER.forEach((fraction, index) => {
|
||||
const side = Math.max(320, Math.round(maxSide * fraction));
|
||||
// Le premier palier essaie toute l'échelle de qualité ; les suivants
|
||||
// repartent plus bas, la définition ayant déjà fait une partie du travail.
|
||||
for (const quality of QUALITY_LADDER.slice(index)) steps.push({ maxSide: side, quality });
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Poids réel d'une data-URI base64, sans la décoder. */
|
||||
export function base64Bytes(dataUri: string): number {
|
||||
const marker = dataUri.indexOf("base64,");
|
||||
if (marker < 0) return 0;
|
||||
const payload = dataUri.slice(marker + 7).replace(/\s+/g, "");
|
||||
if (payload.length === 0) return 0;
|
||||
const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
|
||||
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
/** `true` pour une data-URI d'image, la seule forme acceptée par le serveur. */
|
||||
export function isImageDataUri(value: string): boolean {
|
||||
return /^data:image\/[a-z+.-]+;base64,/i.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Faut-il réencoder ? Non si l'image tient déjà dans le budget **et** dans la
|
||||
* définition : le fichier d'origine est alors la meilleure version possible.
|
||||
*/
|
||||
export function needsCompression(
|
||||
info: { bytes: number; width: number; height: number },
|
||||
options: { maxBytes: number; maxSide?: number } = { maxBytes: FIELD_MAX_BYTES },
|
||||
): boolean {
|
||||
const maxSide = options.maxSide ?? MAX_SIDE;
|
||||
if (!positive(info.width) || !positive(info.height)) return false;
|
||||
if (Math.max(info.width, info.height) > maxSide) return true;
|
||||
return info.bytes > options.maxBytes;
|
||||
}
|
||||
|
||||
function bytesLabel(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;
|
||||
return `${(mo < 10 ? mo.toFixed(1) : String(Math.round(mo))).replace(".", ",")} Mo`;
|
||||
}
|
||||
|
||||
export type CompressionResult = {
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
originalBytes: number;
|
||||
/** `false` quand le fichier d'origine a été gardé tel quel. */
|
||||
recompressed: boolean;
|
||||
};
|
||||
|
||||
/** Phrase affichée sous l'aperçu : ce que le navigateur a fait, en clair. */
|
||||
export function compressionSummary(result: CompressionResult): string {
|
||||
const size = `${Math.round(result.width)} × ${Math.round(result.height)} px`;
|
||||
if (!result.recompressed) return `${size} · ${bytesLabel(result.bytes)} · qualité d'origine conservée`;
|
||||
return `${size} · ${bytesLabel(result.originalBytes)} → ${bytesLabel(result.bytes)} · compressée automatiquement`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe des envois consécutifs pour tenir sous la limite d'une requête. Un
|
||||
* élément plus gros que le budget part seul plutôt que d'être écarté : c'est au
|
||||
* serveur de le refuser, pas à la file de l'oublier en silence.
|
||||
*/
|
||||
export function chunkByBytes(sizes: number[], maxBytes: number = BATCH_MAX_BYTES): number[][] {
|
||||
const groups: number[][] = [];
|
||||
let current: number[] = [];
|
||||
let total = 0;
|
||||
sizes.forEach((size, index) => {
|
||||
const weight = Math.max(0, size);
|
||||
if (current.length > 0 && total + weight > maxBytes) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
total = 0;
|
||||
}
|
||||
current.push(index);
|
||||
total += weight;
|
||||
});
|
||||
if (current.length > 0) groups.push(current);
|
||||
return groups;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -135,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({
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
+8
-1
@@ -32,7 +32,11 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean):
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
||||
"img-src 'self' data: https:",
|
||||
"connect-src 'self'",
|
||||
// Mesure d'audience GA4 : gtag.js renvoie ses relevés par `fetch`. Les
|
||||
// hôtes sont listés même sans identifiant configuré — la CSP est posée
|
||||
// sur le runtime Edge, qui ne peut pas interroger la base pour savoir si
|
||||
// la mesure est active.
|
||||
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
|
||||
// Carte « Nous situer » de la fiche adhérent.
|
||||
"frame-src https://www.google.com https://maps.google.com",
|
||||
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
||||
@@ -55,6 +59,9 @@ export default auth((request: NextRequest) => {
|
||||
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
// Un layout ne connaît pas l'URL demandée : `Analytics` s'en sert pour ne
|
||||
// mesurer que les pages publiques.
|
||||
requestHeaders.set("x-pathname", request.nextUrl.pathname);
|
||||
requestHeaders.set("content-security-policy", csp);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
gtagConfigScript,
|
||||
gtagSrc,
|
||||
isTrackedPath,
|
||||
normalizeMeasurementId,
|
||||
normalizeSiteVerification,
|
||||
} from "../src/lib/analytics";
|
||||
|
||||
describe("Identifiant de mesure GA4", () => {
|
||||
it("accepte un identifiant de flux", () => {
|
||||
assert.equal(normalizeMeasurementId("G-ABC1234567"), "G-ABC1234567");
|
||||
assert.equal(normalizeMeasurementId(" g-abc1234567 "), "G-ABC1234567");
|
||||
});
|
||||
|
||||
it("refuse une saisie vide ou absente", () => {
|
||||
assert.equal(normalizeMeasurementId(""), "");
|
||||
assert.equal(normalizeMeasurementId(null), "");
|
||||
assert.equal(normalizeMeasurementId(undefined), "");
|
||||
assert.equal(normalizeMeasurementId(" "), "");
|
||||
});
|
||||
|
||||
it("refuse Universal Analytics, arrêté depuis 2023", () => {
|
||||
assert.equal(normalizeMeasurementId("UA-12345-1"), "");
|
||||
});
|
||||
|
||||
it("refuse tout ce qui pourrait s'échapper du script en ligne", () => {
|
||||
for (const payload of [
|
||||
"G-ABC12345');alert(1);//",
|
||||
"G-ABC12345</script><script>alert(1)</script>",
|
||||
'G-ABC"12345',
|
||||
"G-ABC 12345",
|
||||
"<script>alert(1)</script>",
|
||||
]) {
|
||||
assert.equal(normalizeMeasurementId(payload), "", `accepté à tort : ${payload}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Balises gtag.js", () => {
|
||||
it("ne rend rien sans identifiant valide", () => {
|
||||
assert.equal(gtagSrc(""), "");
|
||||
assert.equal(gtagSrc("UA-12345-1"), "");
|
||||
assert.equal(gtagConfigScript(""), "");
|
||||
assert.equal(gtagConfigScript("pas-un-identifiant"), "");
|
||||
});
|
||||
|
||||
it("charge gtag.js depuis Google avec l'identifiant configuré", () => {
|
||||
assert.equal(
|
||||
gtagSrc("G-ABC1234567"),
|
||||
"https://www.googletagmanager.com/gtag/js?id=G-ABC1234567",
|
||||
);
|
||||
});
|
||||
|
||||
it("produit un script d'amorçage qui ne peut pas être refermé", () => {
|
||||
const script = gtagConfigScript("G-ABC1234567");
|
||||
assert.ok(script.includes("gtag('config','G-ABC1234567');"));
|
||||
assert.ok(!script.includes("</script"));
|
||||
assert.ok(!script.includes("<"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pages mesurées", () => {
|
||||
it("mesure les pages publiques", () => {
|
||||
for (const path of ["/", "/annuaire", "/promotions", "/adherents/12-au-bon-pain", "/association"]) {
|
||||
assert.equal(isTrackedPath(path), true, `devrait être mesurée : ${path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne mesure ni le backoffice, ni l'espace adhérent, ni les URLs privées", () => {
|
||||
for (const path of [
|
||||
"/backend",
|
||||
"/backend/espace/promotions",
|
||||
"/login",
|
||||
"/inscription/adherent",
|
||||
"/api/social/facebook/callback",
|
||||
]) {
|
||||
assert.equal(isTrackedPath(path), false, `ne devrait pas être mesurée : ${path}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne confond pas un préfixe avec un début de mot", () => {
|
||||
assert.equal(isTrackedPath("/backendeur"), true);
|
||||
assert.equal(isTrackedPath("/apiculteurs"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Jeton Search Console", () => {
|
||||
it("accepte le jeton livré par Google", () => {
|
||||
assert.equal(
|
||||
normalizeSiteVerification(" aBc123_def-456ghijkl "),
|
||||
"aBc123_def-456ghijkl",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuse une balise complète ou tout autre collage", () => {
|
||||
for (const payload of [
|
||||
'<meta name="google-site-verification" content="abc">',
|
||||
"",
|
||||
"trop-court",
|
||||
"abc123 def456ghij",
|
||||
"abc\"><script>alert(1)</script>",
|
||||
]) {
|
||||
assert.equal(normalizeSiteVerification(payload), "", `accepté à tort : ${payload}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* La barre latérale d'administration tient dans deux structures littérales de
|
||||
* `BackendShell.tsx` — les titres d'en-tête et les sections — plus le jeu
|
||||
* d'icônes. Ajouter un écran demande de toucher aux trois : ce test attrape
|
||||
* l'oubli, qui se voit sinon par une icône absente ou un en-tête vide.
|
||||
*/
|
||||
|
||||
const shell = readFileSync(new URL("../src/app/backend/BackendShell.tsx", import.meta.url), "utf8");
|
||||
const icons = readFileSync(new URL("../src/components/BackendIcons.tsx", import.meta.url), "utf8");
|
||||
|
||||
/** Entrées de navigation déclarées dans `SECTIONS`. */
|
||||
const navItems = [...shell.matchAll(/\{\s*href:\s*"([^"]+)",\s*label:\s*"([^"]*)",\s*icon:\s*"([^"]+)"/g)].map(
|
||||
(match) => ({ href: match[1], label: match[2], icon: match[3] })
|
||||
);
|
||||
|
||||
/** Clés du dictionnaire `TITLES`. */
|
||||
const titled = new Set([...shell.matchAll(/^\s*"(\/backend[^"]*)":\s*\[/gm)].map((match) => match[1]));
|
||||
|
||||
/** Noms d'icônes réellement dessinés. */
|
||||
const drawn = new Set([...icons.matchAll(/^\s{2}([a-z]+):\s*(?:\(|<)/gm)].map((match) => match[1]));
|
||||
|
||||
test("la navigation n'est pas vide", () => {
|
||||
assert.ok(navItems.length >= 10, `seulement ${navItems.length} entrées trouvées`);
|
||||
assert.ok(drawn.size >= 10, `seulement ${drawn.size} icônes trouvées`);
|
||||
});
|
||||
|
||||
for (const item of navItems) {
|
||||
test(`« ${item.label} » a son icône et son en-tête`, () => {
|
||||
assert.ok(drawn.has(item.icon), `l'icône « ${item.icon} » n'est dessinée nulle part`);
|
||||
assert.ok(titled.has(item.href), `${item.href} n'a pas d'entrée dans TITLES`);
|
||||
});
|
||||
}
|
||||
|
||||
test("chaque icône déclarée dans le type est dessinée", () => {
|
||||
const declared = [...icons.matchAll(/^\s*\|\s*"([a-z]+)"/gm)].map((match) => match[1]);
|
||||
assert.ok(declared.length > 0, "aucune icône déclarée : le test ne vérifie plus rien");
|
||||
for (const name of declared) {
|
||||
assert.ok(drawn.has(name), `l'icône « ${name} » est déclarée mais pas dessinée`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
|
||||
* « Côté adhérent », déjà rendue en onglets par `EspaceHeader`. Le test tient
|
||||
* les deux bouts — la coquille doit brancher sur le rôle, et sa variante sans
|
||||
* barre ne doit rendre ni `aside`, ni bouton de tiroir.
|
||||
*/
|
||||
const memberStart = shell.indexOf("if (!isStaff(user.role))");
|
||||
// La tranche s'arrête à la coquille du staff, sinon elle l'embarquerait et
|
||||
// le test verrait son <aside> — il passerait pour un échec de la variante.
|
||||
const staffStart = shell.indexOf('<div className="backend" style', memberStart);
|
||||
const memberBranch = shell.slice(memberStart, staffStart);
|
||||
|
||||
test("la coquille distingue le staff de l'adhérent", () => {
|
||||
assert.ok(shell.includes("isStaff"), "BackendShell ne consulte plus le rôle");
|
||||
assert.ok(memberStart >= 0, "la branche sans barre latérale a disparu");
|
||||
assert.ok(staffStart > memberStart, "la coquille du staff ne suit plus la branche adhérent");
|
||||
});
|
||||
|
||||
test("la variante adhérent ne rend ni barre latérale ni tiroir", () => {
|
||||
assert.ok(!/<aside/.test(memberBranch), "un <aside> subsiste dans la variante adhérent");
|
||||
assert.ok(!memberBranch.includes("backend-burger"), "le bouton de tiroir subsiste sans tiroir à ouvrir");
|
||||
assert.ok(!memberBranch.includes("sidebar"), "une classe de barre latérale subsiste");
|
||||
});
|
||||
|
||||
test("sans tiroir, les actions du compte restent dans l'entête", () => {
|
||||
// Sur téléphone elles vivaient dans la barre : sans elle, se déconnecter
|
||||
// deviendrait impossible si la règle d'affichage n'était pas rétablie.
|
||||
assert.ok(memberBranch.includes("{signOut}"), "la déconnexion a disparu de l'entête adhérent");
|
||||
assert.ok(memberBranch.includes("{siteLink}"), "le retour au site a disparu de l'entête adhérent");
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.backend-header--plain\s+\.backend-header__actions\s*\{\s*display:\s*flex/,
|
||||
"les actions restent masquées sous 1024px, où le tiroir n'existe plus",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
BATCH_MAX_BYTES,
|
||||
MAX_SIDE,
|
||||
MIN_QUALITY,
|
||||
base64Bytes,
|
||||
chunkByBytes,
|
||||
compressionSteps,
|
||||
compressionSummary,
|
||||
isImageDataUri,
|
||||
needsCompression,
|
||||
scaleToFit,
|
||||
} from "../src/lib/image-compress";
|
||||
|
||||
describe("Mise à l'échelle", () => {
|
||||
it("ramène le côté le plus long sous la limite en gardant le rapport", () => {
|
||||
assert.deepEqual(scaleToFit(4032, 3024, 2048), { width: 2048, height: 1536 });
|
||||
assert.deepEqual(scaleToFit(3024, 4032, 2048), { width: 1536, height: 2048 });
|
||||
});
|
||||
|
||||
it("n'agrandit jamais une image plus petite que la limite", () => {
|
||||
assert.deepEqual(scaleToFit(800, 600, 2048), { width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("ne rend rien pour des dimensions absurdes", () => {
|
||||
assert.deepEqual(scaleToFit(0, 600, 2048), { width: 0, height: 0 });
|
||||
assert.deepEqual(scaleToFit(Number.NaN, 600, 2048), { width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paliers de compression", () => {
|
||||
const steps = compressionSteps(MAX_SIDE);
|
||||
|
||||
it("commence à la définition pleine et à la meilleure qualité", () => {
|
||||
assert.equal(steps[0].maxSide, MAX_SIDE);
|
||||
assert.ok(steps[0].quality >= 0.9);
|
||||
});
|
||||
|
||||
it("épuise la qualité avant de réduire la définition", () => {
|
||||
const firstDrop = steps.findIndex((step) => step.maxSide < MAX_SIDE);
|
||||
assert.ok(firstDrop > 1, "la définition ne doit pas tomber au deuxième essai");
|
||||
for (let index = 1; index < firstDrop; index += 1) {
|
||||
assert.ok(steps[index].quality < steps[index - 1].quality);
|
||||
}
|
||||
});
|
||||
|
||||
it("ne remonte jamais en définition et reste au-dessus du plancher de qualité", () => {
|
||||
for (let index = 1; index < steps.length; index += 1) {
|
||||
assert.ok(steps[index].maxSide <= steps[index - 1].maxSide);
|
||||
}
|
||||
for (const step of steps) {
|
||||
assert.ok(step.quality >= MIN_QUALITY && step.quality <= 1);
|
||||
assert.ok(step.maxSide >= 320);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Poids d'une data-URI", () => {
|
||||
it("retrouve le poids décodé sans décoder", () => {
|
||||
const bytes = Buffer.from("x".repeat(1000));
|
||||
const dataUri = `data:image/jpeg;base64,${bytes.toString("base64")}`;
|
||||
assert.equal(base64Bytes(dataUri), 1000);
|
||||
});
|
||||
|
||||
it("tient compte du remplissage", () => {
|
||||
for (const length of [1, 2, 3, 4, 5]) {
|
||||
const raw = Buffer.alloc(length, 7);
|
||||
assert.equal(base64Bytes(`data:image/png;base64,${raw.toString("base64")}`), length);
|
||||
}
|
||||
});
|
||||
|
||||
it("rend zéro hors data-URI", () => {
|
||||
assert.equal(base64Bytes("https://exemple.fr/photo.jpg"), 0);
|
||||
assert.equal(base64Bytes(""), 0);
|
||||
});
|
||||
|
||||
it("ne reconnaît qu'une image en data-URI", () => {
|
||||
assert.ok(isImageDataUri("data:image/jpeg;base64,AAAA"));
|
||||
assert.ok(!isImageDataUri("https://exemple.fr/photo.jpg"));
|
||||
assert.ok(!isImageDataUri("data:text/html;base64,AAAA"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Décision de recompression", () => {
|
||||
it("garde le fichier d'origine quand il tient dans le budget et la définition", () => {
|
||||
assert.ok(!needsCompression({ bytes: 400_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
|
||||
it("recompresse une image trop lourde", () => {
|
||||
assert.ok(needsCompression({ bytes: 4_000_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
|
||||
it("recompresse une image légère mais démesurée", () => {
|
||||
assert.ok(needsCompression({ bytes: 200_000, width: 6000, height: 4000 }, { maxBytes: 800_000 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Résumé montré à l'utilisateur", () => {
|
||||
it("dit ce qui a été gagné", () => {
|
||||
assert.equal(
|
||||
compressionSummary({ width: 2048, height: 1536, bytes: 620_000, originalBytes: 4_200_000, recompressed: true }),
|
||||
"2048 × 1536 px · 4,2 Mo → 620 Ko · compressée automatiquement",
|
||||
);
|
||||
});
|
||||
|
||||
it("dit quand rien n'a été touché", () => {
|
||||
assert.equal(
|
||||
compressionSummary({ width: 1200, height: 900, bytes: 340_000, originalBytes: 340_000, recompressed: false }),
|
||||
"1200 × 900 px · 340 Ko · qualité d'origine conservée",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Envoi groupé", () => {
|
||||
it("regroupe les photos sous la limite d'une requête", () => {
|
||||
assert.deepEqual(chunkByBytes([700_000, 700_000, 700_000, 700_000], 2_000_000), [[0, 1], [2, 3]]);
|
||||
});
|
||||
|
||||
it("laisse partir seule une photo plus lourde que le budget", () => {
|
||||
assert.deepEqual(chunkByBytes([100_000, 3_000_000, 100_000], 2_000_000), [[0], [1], [2]]);
|
||||
});
|
||||
|
||||
it("rend un groupe vide pour une liste vide et couvre chaque index une fois", () => {
|
||||
assert.deepEqual(chunkByBytes([], BATCH_MAX_BYTES), []);
|
||||
const sizes = [500_000, 900_000, 200_000, 1_900_000, 50_000];
|
||||
const flat = chunkByBytes(sizes).flat();
|
||||
assert.deepEqual(flat, [0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
aspectLabel,
|
||||
formatBytes,
|
||||
imageNotes,
|
||||
imageSummary,
|
||||
isSquare,
|
||||
orientationLabel,
|
||||
IDEAL_SIDE,
|
||||
} from "../src/lib/image-info";
|
||||
|
||||
describe("Poids d'un fichier", () => {
|
||||
it("passe des octets aux kilo-octets puis aux méga-octets", () => {
|
||||
assert.equal(formatBytes(640), "640 o");
|
||||
assert.equal(formatBytes(420_000), "420 Ko");
|
||||
assert.equal(formatBytes(1_200_000), "1,2 Mo");
|
||||
});
|
||||
|
||||
it("ne rend rien pour une valeur absurde", () => {
|
||||
assert.equal(formatBytes(-1), "");
|
||||
assert.equal(formatBytes(Number.NaN), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Format d'une image", () => {
|
||||
it("reconnaît les formats courants", () => {
|
||||
assert.equal(aspectLabel(1080, 1080), "1:1");
|
||||
assert.equal(aspectLabel(1080, 1350), "4:5");
|
||||
assert.equal(aspectLabel(1920, 1080), "16:9");
|
||||
});
|
||||
|
||||
it("tolère un écart de 2 % autour d'un format connu", () => {
|
||||
assert.equal(aspectLabel(1080, 1078), "1:1");
|
||||
assert.ok(isSquare({ width: 1080, height: 1078, bytes: 0 }));
|
||||
});
|
||||
|
||||
it("approche les formats inhabituels plutôt que de rendre une fraction illisible", () => {
|
||||
assert.equal(aspectLabel(1000, 730), "1,37:1");
|
||||
assert.equal(aspectLabel(730, 1000), "1:1,37");
|
||||
});
|
||||
|
||||
it("nomme l'orientation", () => {
|
||||
assert.equal(orientationLabel({ width: 1080, height: 1080, bytes: 0 }), "carré");
|
||||
assert.equal(orientationLabel({ width: 1080, height: 1350, bytes: 0 }), "portrait");
|
||||
assert.equal(orientationLabel({ width: 1350, height: 1080, bytes: 0 }), "paysage");
|
||||
});
|
||||
|
||||
it("résume dimensions, format et poids", () => {
|
||||
assert.equal(
|
||||
imageSummary({ width: 1080, height: 1350, bytes: 420_000 }),
|
||||
"1080 × 1350 px · portrait 4:5 · 420 Ko"
|
||||
);
|
||||
});
|
||||
|
||||
it("ne résume rien sans dimensions exploitables", () => {
|
||||
assert.equal(imageSummary({ width: 0, height: 0, bytes: 1000 }), "");
|
||||
assert.deepEqual(imageNotes({ width: 0, height: 0, bytes: 1000 }), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Remarques sur l'image déposée", () => {
|
||||
it("félicite une image carrée assez grande et assez légère", () => {
|
||||
const notes = imageNotes({ width: IDEAL_SIDE, height: IDEAL_SIDE, bytes: 300_000 });
|
||||
assert.equal(notes.length, 1);
|
||||
assert.equal(notes[0].tone, "info");
|
||||
});
|
||||
|
||||
it("prévient des bandes de couleur ajoutées par les réseaux hors du carré", () => {
|
||||
const notes = imageNotes({ width: 1080, height: 1350, bytes: 300_000 });
|
||||
assert.ok(notes.some((n) => n.tone === "warn" && /bandes de couleur/.test(n.text)));
|
||||
assert.ok(notes.some((n) => /4:5/.test(n.text)));
|
||||
});
|
||||
|
||||
it("signale une image trop petite", () => {
|
||||
const notes = imageNotes({ width: 400, height: 400, bytes: 60_000 });
|
||||
assert.ok(notes.some((n) => /floue/.test(n.text)));
|
||||
});
|
||||
|
||||
it("signale un fichier trop lourd", () => {
|
||||
const notes = imageNotes({ width: 1080, height: 1080, bytes: 3_000_000 });
|
||||
assert.ok(notes.some((n) => /Fichier lourd/.test(n.text)));
|
||||
});
|
||||
|
||||
it("cumule les remarques quand tout se ligue", () => {
|
||||
const notes = imageNotes({ width: 600, height: 900, bytes: 3_000_000 });
|
||||
assert.equal(notes.length, 3);
|
||||
assert.ok(notes.every((n) => n.tone === "warn"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { MAX_ATTEMPTS, backoffDelayMs, shouldGiveUp } from "../src/lib/mail-outbox";
|
||||
|
||||
test("l'attente entre deux tentatives croît et reste plafonnée", () => {
|
||||
const delays = [1, 2, 3, 4, 5].map(backoffDelayMs);
|
||||
for (let i = 1; i < delays.length; i += 1) {
|
||||
assert.ok(delays[i] > delays[i - 1], `la tentative ${i + 1} devrait attendre plus longtemps`);
|
||||
}
|
||||
assert.equal(backoffDelayMs(1), 2 * 60_000);
|
||||
assert.equal(backoffDelayMs(4), 16 * 60_000);
|
||||
// Au-delà du plafond, l'attente ne double plus indéfiniment.
|
||||
assert.equal(backoffDelayMs(50), backoffDelayMs(MAX_ATTEMPTS));
|
||||
});
|
||||
|
||||
test("une valeur aberrante ne produit pas une attente absurde", () => {
|
||||
assert.equal(backoffDelayMs(0), backoffDelayMs(1));
|
||||
assert.equal(backoffDelayMs(-3), backoffDelayMs(1));
|
||||
assert.ok(Number.isFinite(backoffDelayMs(1000)));
|
||||
});
|
||||
|
||||
test("on abandonne après cinq tentatives, pas avant", () => {
|
||||
assert.equal(shouldGiveUp(1), false);
|
||||
assert.equal(shouldGiveUp(MAX_ATTEMPTS - 1), false);
|
||||
assert.equal(shouldGiveUp(MAX_ATTEMPTS), true);
|
||||
assert.equal(shouldGiveUp(MAX_ATTEMPTS + 1), true);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { dedupeRecipients, isPlausibleEmail } from "../src/lib/mail-recipients";
|
||||
|
||||
test("une adresse manifestement cassée est écartée", () => {
|
||||
assert.equal(isPlausibleEmail("contact@pleinr.fr"), true);
|
||||
assert.equal(isPlausibleEmail(" contact@pleinr.fr "), true);
|
||||
assert.equal(isPlausibleEmail(""), false);
|
||||
assert.equal(isPlausibleEmail("pas-une-adresse"), false);
|
||||
assert.equal(isPlausibleEmail("contact@pleinr"), false);
|
||||
assert.equal(isPlausibleEmail("a@b.fr, c@d.fr"), false);
|
||||
assert.equal(isPlausibleEmail("victime@pleinr.fr>\r\nBcc: espion@x.fr"), false);
|
||||
});
|
||||
|
||||
test("N destinataires donnent N entrées, jamais une seule fusionnée", () => {
|
||||
const rows = [
|
||||
{ email: "a@pleinr.fr", name: "A", memberId: 1 },
|
||||
{ email: "b@pleinr.fr", name: "B", memberId: 2 },
|
||||
{ email: "c@pleinr.fr", name: "C", memberId: 3 },
|
||||
];
|
||||
const out = dedupeRecipients(rows);
|
||||
assert.equal(out.length, 3);
|
||||
assert.deepEqual(out.map((r) => r.email), ["a@pleinr.fr", "b@pleinr.fr", "c@pleinr.fr"]);
|
||||
});
|
||||
|
||||
test("un doublon ne reçoit pas deux fois, quelle que soit la casse", () => {
|
||||
const out = dedupeRecipients([
|
||||
{ email: "Contact@PleinR.fr", name: "Premier", memberId: 1 },
|
||||
{ email: "contact@pleinr.fr", name: "Second", memberId: 2 },
|
||||
{ email: " CONTACT@pleinr.FR ", name: "Troisième", memberId: 3 },
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
// La première occurrence gagne, avec son nom et son rattachement.
|
||||
assert.equal(out[0].name, "Premier");
|
||||
assert.equal(out[0].memberId, 1);
|
||||
assert.equal(out[0].email, "Contact@PleinR.fr");
|
||||
});
|
||||
|
||||
test("les lignes sans adresse utilisable disparaissent", () => {
|
||||
const out = dedupeRecipients([
|
||||
{ email: null, name: "Sans adresse", memberId: 1 },
|
||||
{ email: " ", name: "Vide", memberId: 2 },
|
||||
{ email: "cassée", name: "Invalide", memberId: 3 },
|
||||
{ email: "bon@pleinr.fr", name: "Bon", memberId: 4 },
|
||||
]);
|
||||
assert.deepEqual(out, [{ email: "bon@pleinr.fr", name: "Bon", memberId: 4 }]);
|
||||
});
|
||||
|
||||
test("un nom absent devient une chaîne vide, pas « null »", () => {
|
||||
const out = dedupeRecipients([{ email: "bon@pleinr.fr", name: null, memberId: null }]);
|
||||
assert.deepEqual(out, [{ email: "bon@pleinr.fr", name: "", memberId: null }]);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { contactName, memberContact, telHref } from "../src/lib/member-contact";
|
||||
|
||||
test("le référent joint prénom et nom", () => {
|
||||
assert.equal(contactName({ contactFirstName: "Marie", contactLastName: "Durand" }), "Marie Durand");
|
||||
assert.equal(contactName({ contactFirstName: " Marie ", contactLastName: " Durand " }), "Marie Durand");
|
||||
assert.equal(contactName({ contactFirstName: "Marie" }), "Marie");
|
||||
assert.equal(contactName({ contactLastName: "Durand" }), "Durand");
|
||||
assert.equal(contactName({}), null);
|
||||
assert.equal(contactName({ contactFirstName: " ", contactLastName: null }), null);
|
||||
});
|
||||
|
||||
test("un référent sans nom ni téléphone n'existe pas", () => {
|
||||
assert.equal(memberContact({}), null);
|
||||
assert.equal(memberContact({ contactFirstName: " ", contactPhone: "" }), null);
|
||||
assert.deepEqual(memberContact({ contactPhone: "03 83 24 10 10" }), {
|
||||
name: null,
|
||||
phone: "03 83 24 10 10",
|
||||
});
|
||||
assert.deepEqual(memberContact({ contactLastName: "Durand", contactPhone: null }), {
|
||||
name: "Durand",
|
||||
phone: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("le lien tel: ne garde que les chiffres et l'indicatif", () => {
|
||||
assert.equal(telHref("03 83 24 10 10"), "tel:0383241010");
|
||||
assert.equal(telHref("+33 3 83 24 10 10"), "tel:+33383241010");
|
||||
assert.equal(telHref("03.83.24.10.10"), "tel:0383241010");
|
||||
assert.equal(telHref("sur rendez-vous"), null);
|
||||
assert.equal(telHref(null), null);
|
||||
assert.equal(telHref(" "), null);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildMimeMessage,
|
||||
bytesToBase64,
|
||||
encodeHeaderValue,
|
||||
formatAddress,
|
||||
toBase64Url,
|
||||
utf8ToBase64,
|
||||
wrapBase64,
|
||||
} from "../src/lib/mime";
|
||||
|
||||
const BOUNDARIES = ["ALT", "REL"];
|
||||
|
||||
test("un objet purement ASCII part tel quel", () => {
|
||||
assert.equal(encodeHeaderValue("Invitation Plein R"), "Invitation Plein R");
|
||||
});
|
||||
|
||||
test("un objet accentué est encodé en mot RFC 2047", () => {
|
||||
const encoded = encodeHeaderValue("Rencontre à Pompey");
|
||||
assert.match(encoded, /^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
assert.equal(Buffer.from(encoded.slice(10, -2), "base64").toString("utf8"), "Rencontre à Pompey");
|
||||
});
|
||||
|
||||
test("un objet accentué long est replié en plusieurs mots d'au plus 75 caractères", () => {
|
||||
const encoded = encodeHeaderValue(
|
||||
"Assemblée générale de l'association Plein R du Bassin de Pompey — mardi 14 octobre à 19 h, salle Jean-Jaurès",
|
||||
);
|
||||
const words = encoded.split("\r\n ");
|
||||
assert.ok(words.length > 1, "le texte long doit être replié");
|
||||
for (const word of words) {
|
||||
assert.ok(word.length <= 75, `mot trop long : ${word.length}`);
|
||||
assert.match(word, /^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
}
|
||||
const decoded = words.map((w) => Buffer.from(w.slice(10, -2), "base64").toString("utf8")).join("");
|
||||
assert.equal(decoded, "Assemblée générale de l'association Plein R du Bassin de Pompey — mardi 14 octobre à 19 h, salle Jean-Jaurès");
|
||||
});
|
||||
|
||||
test("le repli ne coupe jamais une séquence UTF-8 en deux", () => {
|
||||
// Que des caractères à 3 octets : 45 / 3 = 15 par mot, sans reste.
|
||||
const encoded = encodeHeaderValue("€".repeat(40));
|
||||
const decoded = encoded
|
||||
.split("\r\n ")
|
||||
.map((w) => Buffer.from(w.slice(10, -2), "base64").toString("utf8"))
|
||||
.join("");
|
||||
assert.equal(decoded, "€".repeat(40));
|
||||
});
|
||||
|
||||
test("un retour à la ligne dans un en-tête ne permet pas d'en injecter un autre", () => {
|
||||
const encoded = encodeHeaderValue("Bonjour\r\nBcc: espion@example.com");
|
||||
assert.ok(!encoded.includes("\r"));
|
||||
assert.ok(!encoded.includes("\n"));
|
||||
assert.equal(encoded, "Bonjour Bcc: espion@example.com");
|
||||
});
|
||||
|
||||
test("une adresse avec nom affiché est mise en forme, une adresse nue reste nue", () => {
|
||||
assert.equal(formatAddress("contact@pleinr.fr"), "contact@pleinr.fr");
|
||||
assert.equal(formatAddress("contact@pleinr.fr", "Plein R"), '"Plein R" <contact@pleinr.fr>');
|
||||
assert.equal(
|
||||
formatAddress("contact@pleinr.fr", 'Plein "R"'),
|
||||
'"Plein \\"R\\"" <contact@pleinr.fr>',
|
||||
);
|
||||
});
|
||||
|
||||
test("un nom affiché accentué est encodé, pas mis entre guillemets", () => {
|
||||
const address = formatAddress("contact@pleinr.fr", "Association Plein R — Élan");
|
||||
assert.match(address, /^=\?UTF-8\?B\?.+\?= <contact@pleinr\.fr>$/);
|
||||
});
|
||||
|
||||
test("une adresse malformée ne peut pas glisser d'en-tête supplémentaire", () => {
|
||||
assert.equal(
|
||||
formatAddress("victime@pleinr.fr>\r\nBcc: espion@example.com", "Plein R"),
|
||||
'"Plein R" <victime@pleinr.frBcc: espion@example.com>',
|
||||
);
|
||||
});
|
||||
|
||||
test("le base64 est replié à 76 colonnes", () => {
|
||||
const lines = wrapBase64(utf8ToBase64("x".repeat(400))).split("\r\n");
|
||||
for (const line of lines) assert.ok(line.length <= 76, `ligne de ${line.length} caractères`);
|
||||
assert.ok(lines.length > 1);
|
||||
});
|
||||
|
||||
test("toBase64Url retire le remplissage et remplace + et /", () => {
|
||||
const raw = bytesToBase64(Uint8Array.from([251, 255, 190, 0]));
|
||||
assert.ok(raw.includes("+") || raw.includes("/"));
|
||||
const url = toBase64Url(raw);
|
||||
assert.ok(!url.includes("+") && !url.includes("/") && !url.includes("="));
|
||||
assert.equal(url, raw.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
|
||||
});
|
||||
|
||||
test("un message HTML seul tient en une seule partie", () => {
|
||||
const mime = buildMimeMessage({ subject: "Bonjour", html: "<p>Salut</p>" });
|
||||
assert.match(mime, /^Subject: Bonjour\r\nMIME-Version: 1\.0\r\nContent-Type: text\/html; charset=UTF-8\r\n/);
|
||||
assert.ok(!mime.includes("multipart"));
|
||||
assert.ok(mime.includes(utf8ToBase64("<p>Salut</p>")));
|
||||
});
|
||||
|
||||
test("html + texte donnent un multipart/alternative, le texte d'abord", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Bonjour",
|
||||
html: "<p>Salut</p>",
|
||||
text: "Salut",
|
||||
boundaries: BOUNDARIES,
|
||||
});
|
||||
assert.ok(mime.includes('Content-Type: multipart/alternative; boundary="ALT"'));
|
||||
assert.ok(mime.includes("--ALT--"));
|
||||
assert.ok(
|
||||
mime.indexOf("text/plain") < mime.indexOf("text/html"),
|
||||
"le repli texte doit précéder la version préférée",
|
||||
);
|
||||
});
|
||||
|
||||
test("une image en ligne enveloppe le tout dans un multipart/related", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Bonjour",
|
||||
html: '<img src="cid:logo">',
|
||||
text: "Bonjour",
|
||||
boundaries: BOUNDARIES,
|
||||
inline: [{ filename: "logo.png", contentType: "image/png", contentId: "logo", base64: "AAAA" }],
|
||||
});
|
||||
assert.ok(mime.includes('Content-Type: multipart/related; type="text/html"; boundary="REL"'));
|
||||
assert.ok(mime.includes("Content-ID: <logo>"));
|
||||
assert.ok(mime.includes('Content-Disposition: inline; filename="logo.png"'));
|
||||
// L'alternative reste imbriquée dans le related, pas l'inverse.
|
||||
assert.ok(mime.indexOf("--REL") < mime.indexOf("multipart/alternative"));
|
||||
assert.ok(mime.includes("--ALT--"));
|
||||
assert.ok(mime.includes("--REL--"));
|
||||
});
|
||||
|
||||
test("une pièce jointe sans identifiant de contenu est attachée, pas incorporée", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Compte rendu",
|
||||
html: "<p>Ci-joint</p>",
|
||||
boundaries: BOUNDARIES,
|
||||
inline: [{ filename: "cr.pdf", contentType: "application/pdf", base64: "AAAA" }],
|
||||
});
|
||||
assert.ok(mime.includes('Content-Disposition: attachment; filename="cr.pdf"'));
|
||||
assert.ok(!mime.includes("Content-ID"));
|
||||
});
|
||||
|
||||
test("le message n'utilise que des CRLF, jamais un LF isolé", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Rencontre à Pompey — programme complet de la soirée du 14 octobre",
|
||||
html: "<p>Ligne une</p>\n<p>Ligne deux</p>",
|
||||
text: "Ligne une\nLigne deux",
|
||||
boundaries: BOUNDARIES,
|
||||
inline: [{ filename: "logo.png", contentType: "image/png", contentId: "logo", base64: "AAAA" }],
|
||||
});
|
||||
// Les corps sont encodés en base64 : aucun LF de la source ne survit brut.
|
||||
assert.equal(mime.replace(/\r\n/g, ""), mime.replace(/\r\n/g, "").replace(/[\r\n]/g, ""));
|
||||
assert.ok(!/(?<!\r)\n/.test(mime), "un LF sans CR précède");
|
||||
});
|
||||
|
||||
test("une frontière ne peut pas apparaître dans la charge utile", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Essai",
|
||||
html: "<p>--ALT et --REL dans le texte</p>",
|
||||
text: "--ALT--",
|
||||
boundaries: BOUNDARIES,
|
||||
});
|
||||
// Les corps étant en base64, les seules occurrences sont les délimiteurs.
|
||||
assert.equal(mime.match(/--ALT/g)?.length, 3); // ouverture ×2 + fermeture
|
||||
});
|
||||
|
||||
test("les en-têtes libres, l'expéditeur et le répondre-à sont posés", () => {
|
||||
const mime = buildMimeMessage({
|
||||
subject: "Essai",
|
||||
html: "<p>a</p>",
|
||||
from: '"Plein R" <contact@pleinr.fr>',
|
||||
to: "adherent@example.com",
|
||||
replyTo: "bureau@pleinr.fr",
|
||||
headers: { "X-Unsent": "1" },
|
||||
});
|
||||
assert.ok(mime.startsWith("X-Unsent: 1\r\n"));
|
||||
assert.ok(mime.includes('From: "Plein R" <contact@pleinr.fr>\r\n'));
|
||||
assert.ok(mime.includes("To: adherent@example.com\r\n"));
|
||||
assert.ok(mime.includes("Reply-To: bureau@pleinr.fr\r\n"));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Invariants de l'écran « Rencontres passées ». Ils portent sur du code que les
|
||||
* tests unitaires ne peuvent pas exécuter (composants client, actions serveur),
|
||||
* d'où la lecture des sources — même approche que `tests/security.test.ts`.
|
||||
*/
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
const actions = read("../src/app/backend/actions.ts");
|
||||
const dialog = read("../src/app/backend/rencontres-passees/PastMeetingDialog.tsx");
|
||||
const page = read("../src/app/backend/rencontres-passees/page.tsx");
|
||||
const imageField = read("../src/components/ImageField.tsx");
|
||||
const modal = read("../src/components/ModalShell.tsx");
|
||||
|
||||
test("aucune image n'est refusée pour son poids : elle est compressée", () => {
|
||||
const code = imageField.replace(/\/\/[^\n]*/g, "");
|
||||
assert.ok(!/\.size\s*>/.test(code), "ImageField refuse encore un fichier sur son poids");
|
||||
assert.ok(!/trop lourde/i.test(code), "ImageField renvoie encore « image trop lourde »");
|
||||
assert.ok(imageField.includes("prepareImageFile"), "ImageField ne compresse pas");
|
||||
assert.ok(dialog.includes("prepareImageFile"), "la fenêtre des rencontres ne compresse pas");
|
||||
});
|
||||
|
||||
test("les photos n'entrent en base qu'en data-URI", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function addPastMeetingPhotos"));
|
||||
const scope = body.slice(0, body.indexOf("export async function savePastMeetingPhotos"));
|
||||
assert.ok(scope.includes("IMAGE_DATA_URI.test"), "une photo pourrait entrer sous forme d'URL (SSRF)");
|
||||
assert.ok(scope.includes("MAX_IMAGE_DATA_URI"), "aucune limite de taille côté serveur");
|
||||
});
|
||||
|
||||
test("la synchronisation des photos ne peut pas vider une galerie par accident", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function savePastMeetingPhotos"));
|
||||
assert.ok(body.includes('formData.get("syncPhotos") !== "1"'), "le drapeau de garde a disparu");
|
||||
});
|
||||
|
||||
test("les refus d'enregistrement sont renvoyés, jamais levés", () => {
|
||||
const body = actions.slice(
|
||||
actions.indexOf("export async function savePastMeeting("),
|
||||
actions.indexOf("export async function deletePastMeeting("),
|
||||
);
|
||||
assert.ok(body.includes('return { error: "Donnez un titre'), "un titre vide ne rend plus d'erreur affichable");
|
||||
// Le seul `throw` admis est la violation d'accès, qui n'a pas à s'expliquer.
|
||||
const throws = [...body.matchAll(/throw new Error\("([^"]+)"\)/g)].map((match) => match[1]);
|
||||
assert.deepEqual(throws, ["Accès refusé"], `throw inattendu : ${throws.join(", ")}`);
|
||||
});
|
||||
|
||||
test("la rencontre liée est vérifiée avant l'écriture", () => {
|
||||
const body = actions.slice(actions.indexOf("export async function savePastMeeting("));
|
||||
assert.ok(body.includes("n'existe plus"), "une rencontre supprimée casserait l'enregistrement sans explication");
|
||||
});
|
||||
|
||||
test("la fenêtre ne se referme qu'une fois la page rafraîchie", () => {
|
||||
// Sans cette attente, rouvrir la fiche montrerait l'état d'avant
|
||||
// l'enregistrement : c'est ce qui fait croire qu'une rencontre liée « ne
|
||||
// veut pas » être retenue.
|
||||
assert.ok(dialog.includes("closing && !refreshing"), "la fermeture ne suit plus le rafraîchissement");
|
||||
});
|
||||
|
||||
test("la fenêtre est rendue dans un portail", () => {
|
||||
// Une carte survolée porte un `transform` : un `position: fixed` rendu à
|
||||
// l'intérieur s'ouvrirait dans la carte, pas sur la page.
|
||||
assert.ok(modal.includes("createPortal"), "la fenêtre n'est plus portée vers <body>");
|
||||
});
|
||||
|
||||
test("le récit s'écrit dans un grand champ, et la page ne le ré-affiche pas en entier", () => {
|
||||
assert.ok(/rows=\{1[0-9]\}/.test(dialog), "le champ de récit est redevenu minuscule");
|
||||
assert.ok(dialog.includes("minHeight: 260"), "la hauteur minimale du récit a disparu");
|
||||
assert.ok(page.includes("pm-card__text"), "la carte n'écrête plus le texte");
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { serializeToRichText, type MinimalNode } from "../src/lib/rich-text-dom";
|
||||
import { parseRichText, richTextToEmailHtml, richTextToPlain } from "../src/lib/rich-text";
|
||||
|
||||
/**
|
||||
* Faux DOM minimal : le sérialiseur ne connaît du navigateur que `MinimalNode`,
|
||||
* ce qui permet de le tester sans jsdom.
|
||||
*/
|
||||
function el(name: string, children: MinimalNode[] = [], attributes: Record<string, string> = {}): MinimalNode {
|
||||
return {
|
||||
nodeType: 1,
|
||||
nodeName: name,
|
||||
childNodes: children,
|
||||
getAttribute: (key: string) => attributes[key] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function txt(value: string): MinimalNode {
|
||||
return { nodeType: 3, nodeName: "#text", textContent: value, childNodes: [] };
|
||||
}
|
||||
|
||||
const root = (...children: MinimalNode[]) => el("DIV", children);
|
||||
|
||||
test("un paragraphe simple ressort tel quel", () => {
|
||||
assert.equal(serializeToRichText(root(el("P", [txt("Bonjour à tous.")]))), "Bonjour à tous.");
|
||||
});
|
||||
|
||||
test("deux paragraphes sont séparés par une ligne vide", () => {
|
||||
const markup = serializeToRichText(root(el("P", [txt("Premier.")]), el("P", [txt("Second.")])));
|
||||
assert.equal(markup, "Premier.\n\nSecond.");
|
||||
assert.equal(parseRichText(markup).length, 2);
|
||||
});
|
||||
|
||||
test("le gras et l'italique deviennent des marqueurs", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [txt("Rendez-vous "), el("STRONG", [txt("mardi")]), txt(" à "), el("EM", [txt("Pompey")])]))),
|
||||
"Rendez-vous **mardi** à _Pompey_",
|
||||
);
|
||||
});
|
||||
|
||||
test("les balises B et I d'execCommand valent STRONG et EM", () => {
|
||||
assert.equal(serializeToRichText(root(el("P", [el("B", [txt("a")]), el("I", [txt("b")])]))), "**a**_b_");
|
||||
});
|
||||
|
||||
test("gras et italique imbriqués se relisent correctement", () => {
|
||||
// C'est la raison du tiret bas : « ***x*** » serait ambigu pour l'analyseur.
|
||||
const markup = serializeToRichText(root(el("P", [el("STRONG", [el("EM", [txt("très important")])])])));
|
||||
assert.equal(markup, "**_très important_**");
|
||||
const blocks = parseRichText(markup);
|
||||
assert.equal(blocks[0].kind, "paragraph");
|
||||
const strong = blocks[0].kind === "paragraph" ? blocks[0].children[0] : null;
|
||||
assert.equal(strong?.kind, "strong");
|
||||
assert.equal(strong?.kind === "strong" ? strong.children[0].kind : null, "em");
|
||||
});
|
||||
|
||||
test("un saut de ligne dans un paragraphe est conservé", () => {
|
||||
const markup = serializeToRichText(root(el("P", [txt("Une ligne"), el("BR"), txt("une autre")])));
|
||||
assert.equal(markup, "Une ligne\nune autre");
|
||||
assert.equal(parseRichText(markup).length, 1);
|
||||
});
|
||||
|
||||
test("les listes à puces et numérotées prennent leur préfixe", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("UL", [el("LI", [txt("un")]), el("LI", [txt("deux")])]))),
|
||||
"- un\n- deux",
|
||||
);
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("OL", [el("LI", [txt("a")]), el("LI", [txt("b")]), el("LI", [txt("c")])]))),
|
||||
"1. a\n2. b\n3. c",
|
||||
);
|
||||
});
|
||||
|
||||
test("la numérotation repart à un sur une seconde liste", () => {
|
||||
const markup = serializeToRichText(
|
||||
root(
|
||||
el("OL", [el("LI", [txt("a")])]),
|
||||
el("P", [txt("Entre les deux.")]),
|
||||
el("OL", [el("LI", [txt("b")])]),
|
||||
),
|
||||
);
|
||||
assert.equal(markup, "1. a\n\nEntre les deux.\n\n1. b");
|
||||
});
|
||||
|
||||
test("un titre devient un sous-titre", () => {
|
||||
assert.equal(serializeToRichText(root(el("H4", [txt("Ordre du jour")]))), "## Ordre du jour");
|
||||
// Les autres niveaux de titre, venus d'un collage, se rabattent sur le même.
|
||||
assert.equal(serializeToRichText(root(el("H1", [txt("Collé depuis le web")]))), "## Collé depuis le web");
|
||||
});
|
||||
|
||||
test("un lien http(s) garde sa cible, les autres perdent la leur", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("A", [txt("le site")], { href: "https://pleinr.fr/" })]))),
|
||||
"[le site](https://pleinr.fr/)",
|
||||
);
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("A", [txt("piège")], { href: "javascript:alert(1)" })]))),
|
||||
"piège",
|
||||
);
|
||||
assert.equal(serializeToRichText(root(el("P", [el("A", [txt("sans cible")])]))), "sans cible");
|
||||
});
|
||||
|
||||
test("tout ce qui n'est pas reconnu perd sa balise et garde son texte", () => {
|
||||
// Couleur, taille, police : rien de tout cela n'existe dans le format stocké.
|
||||
const markup = serializeToRichText(
|
||||
root(el("P", [el("SPAN", [txt("du texte")], { style: "color:red;font-size:32px" }), el("FONT", [txt(" coloré")], { color: "blue" })])),
|
||||
);
|
||||
assert.equal(markup, "du texte coloré");
|
||||
});
|
||||
|
||||
test("le contenu exécutable est retiré, texte compris", () => {
|
||||
const markup = serializeToRichText(
|
||||
root(
|
||||
el("SCRIPT", [txt("alert(1)")]),
|
||||
el("STYLE", [txt("body{display:none}")]),
|
||||
el("P", [txt("Bonjour"), el("IFRAME", [txt("piège")])]),
|
||||
),
|
||||
);
|
||||
assert.equal(markup, "Bonjour");
|
||||
assert.ok(!markup.includes("alert"));
|
||||
assert.ok(!markup.includes("display:none"));
|
||||
});
|
||||
|
||||
test("un collage de Word — div imbriqués et styles — se ramène au texte structuré", () => {
|
||||
const pasted = root(
|
||||
el("DIV", [
|
||||
el("P", [el("SPAN", [txt("Compte rendu")], { style: "font-size:24px" })]),
|
||||
el("UL", [el("LI", [el("SPAN", [el("B", [txt("Point 1")])])]), el("LI", [txt("Point 2")])]),
|
||||
]),
|
||||
);
|
||||
assert.equal(serializeToRichText(pasted), "Compte rendu\n\n- **Point 1**\n- Point 2");
|
||||
});
|
||||
|
||||
test("les espaces insécables de l'éditeur redeviennent des espaces", () => {
|
||||
assert.equal(serializeToRichText(root(el("P", [txt("19\u00a0h\u00a0précises")]))), "19 h précises");
|
||||
});
|
||||
|
||||
test("une mise en forme appliquée à une sélection vide ne produit pas de marqueur", () => {
|
||||
// Chrome laisse un `<b></b>` quand on active le gras sans rien taper.
|
||||
assert.equal(serializeToRichText(root(el("P", [el("B", []), txt("texte")]))), "texte");
|
||||
assert.ok(!serializeToRichText(root(el("P", [el("B", [txt(" ")]), txt("x")]))).includes("**"));
|
||||
});
|
||||
|
||||
test("un éditeur vide produit une chaîne vide", () => {
|
||||
assert.equal(serializeToRichText(root()), "");
|
||||
assert.equal(serializeToRichText(root(el("P", [el("BR")]))), "");
|
||||
assert.equal(serializeToRichText(root(el("P", [txt(" ")]))), "");
|
||||
});
|
||||
|
||||
test("l'aller-retour édition → stockage → rendu conserve le sens", () => {
|
||||
const markup = serializeToRichText(
|
||||
root(
|
||||
el("H4", [txt("Assemblée générale")]),
|
||||
el("P", [txt("Elle se tiendra "), el("STRONG", [txt("salle Jean-Jaurès")]), txt(".")]),
|
||||
el("UL", [el("LI", [txt("comptes 2026")]), el("LI", [txt("budget")])]),
|
||||
el("P", [el("A", [txt("le compte rendu")], { href: "https://pleinr.fr/association" })]),
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
markup,
|
||||
"## Assemblée générale\n\nElle se tiendra **salle Jean-Jaurès**.\n\n- comptes 2026\n- budget\n\n[le compte rendu](https://pleinr.fr/association)",
|
||||
);
|
||||
|
||||
const html = richTextToEmailHtml(markup);
|
||||
assert.ok(html.includes("<strong"));
|
||||
assert.ok(html.includes("<ul"));
|
||||
assert.ok(html.includes('href="https://pleinr.fr/association"'));
|
||||
assert.ok(richTextToPlain(markup).includes("Assemblée générale"));
|
||||
});
|
||||
|
||||
test("le gras codé en style CSS est reconnu — cas Google Docs", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("SPAN", [txt("important")], { style: "font-weight:700" })]))),
|
||||
"**important**",
|
||||
);
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("SPAN", [txt("nuance")], { style: "font-style: italic" })]))),
|
||||
"_nuance_",
|
||||
);
|
||||
});
|
||||
|
||||
test("le style explicite l'emporte sur la balise", () => {
|
||||
// Google Docs enveloppe tout le document dans un `<b style="font-weight:normal">`.
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("B", [el("P", [txt("tout le document")])], { style: "font-weight:normal" }))),
|
||||
"tout le document",
|
||||
);
|
||||
});
|
||||
|
||||
test("un marqueur n'est jamais doublé", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("B", [el("SPAN", [txt("x")], { style: "font-weight:bold" })])]))),
|
||||
"**x**",
|
||||
);
|
||||
});
|
||||
|
||||
test("gras et italique combinés s'imbriquent dans le bon ordre", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("SPAN", [txt("les deux")], { style: "font-weight:700;font-style:italic" })]))),
|
||||
"**_les deux_**",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
parseRichText,
|
||||
richTextExcerpt,
|
||||
richTextNodes,
|
||||
richTextToEditorHtml,
|
||||
richTextToEmailHtml,
|
||||
richTextToPlain,
|
||||
safeHttpUrl,
|
||||
} from "../src/lib/rich-text";
|
||||
|
||||
/** Sérialise l'arbre React pour inspecter ce qui a réellement été construit. */
|
||||
function serialize(source: string) {
|
||||
return JSON.stringify(richTextNodes(source));
|
||||
}
|
||||
|
||||
test("les paragraphes sont séparés par une ligne vide", () => {
|
||||
const blocks = parseRichText("Premier.\n\nSecond.");
|
||||
assert.equal(blocks.length, 2);
|
||||
assert.equal(blocks[0].kind, "paragraph");
|
||||
assert.equal(richTextToPlain("Premier.\n\nSecond."), "Premier.\n\nSecond.");
|
||||
});
|
||||
|
||||
test("un saut de ligne simple reste un saut de ligne", () => {
|
||||
const blocks = parseRichText("Une ligne\nune autre");
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.deepEqual(
|
||||
blocks[0].kind === "paragraph" ? blocks[0].children.map((c) => c.kind) : [],
|
||||
["text", "break", "text"],
|
||||
);
|
||||
assert.ok(richTextToEmailHtml("Une ligne\nune autre").includes("<br>"));
|
||||
});
|
||||
|
||||
test("gras et italique sont reconnus, et imbriqués", () => {
|
||||
assert.ok(richTextToEmailHtml("**gras**").includes("<strong"));
|
||||
assert.ok(richTextToEmailHtml("*doux*").includes("<em>doux</em>"));
|
||||
assert.ok(richTextToEmailHtml("**du *doux* dedans**").includes("<em>doux</em>"));
|
||||
});
|
||||
|
||||
test("une étoile non appariée reste littérale", () => {
|
||||
assert.equal(richTextToPlain("**gras sans fin"), "**gras sans fin");
|
||||
assert.equal(richTextToPlain("2 * 3 = 6"), "2 * 3 = 6");
|
||||
assert.ok(!richTextToEmailHtml("**gras sans fin").includes("<strong"));
|
||||
});
|
||||
|
||||
test("les listes à puces et numérotées regroupent leurs lignes", () => {
|
||||
const blocks = parseRichText("- un\n- deux\n\n1. a\n2. b");
|
||||
assert.equal(blocks.length, 2);
|
||||
assert.equal(blocks[0].kind, "bullets");
|
||||
assert.equal(blocks[0].kind === "bullets" ? blocks[0].items.length : 0, 2);
|
||||
assert.equal(blocks[1].kind, "ordered");
|
||||
const html = richTextToEmailHtml("- un\n- deux");
|
||||
assert.equal(html.match(/<li/g)?.length, 2);
|
||||
assert.ok(html.startsWith("<ul"));
|
||||
});
|
||||
|
||||
test("un italique en début de ligne n'est pas confondu avec une puce", () => {
|
||||
const blocks = parseRichText("*insistons* sur ce point");
|
||||
assert.equal(blocks[0].kind, "paragraph");
|
||||
});
|
||||
|
||||
test("un sous-titre devient un bloc à part", () => {
|
||||
const blocks = parseRichText("## Ordre du jour\nPremier point");
|
||||
assert.equal(blocks.length, 2);
|
||||
assert.equal(blocks[0].kind, "heading");
|
||||
assert.ok(richTextToEmailHtml("## Ordre du jour").includes("Georgia"));
|
||||
});
|
||||
|
||||
test("un lien http(s) est rendu, en nouvel onglet et sans fuite d'ouvreur", () => {
|
||||
const nodes = serialize("[le site](https://pleinr.fr/promotions)");
|
||||
assert.ok(nodes.includes("https://pleinr.fr/promotions"));
|
||||
assert.ok(nodes.includes("noopener noreferrer"));
|
||||
assert.ok(nodes.includes('"target":"_blank"'));
|
||||
assert.ok(richTextToEmailHtml("[le site](https://pleinr.fr)").includes('href="https://pleinr.fr/"'));
|
||||
});
|
||||
|
||||
test("un lien javascript: ou data: perd sa cible et ne garde que son libellé", () => {
|
||||
for (const hostile of [
|
||||
"[clique ici](javascript:alert(1))",
|
||||
"[clique ici](data:text/html,<script>alert(1)</script>)",
|
||||
"[clique ici](vbscript:msgbox)",
|
||||
"[clique ici]( JavaScript:alert(1) )",
|
||||
]) {
|
||||
const html = richTextToEmailHtml(hostile);
|
||||
assert.ok(!html.includes("<a "), `cible conservée pour ${hostile}`);
|
||||
assert.ok(!/javascript:/i.test(html), `protocole conservé pour ${hostile}`);
|
||||
assert.ok(html.includes("clique ici"), "le libellé doit rester");
|
||||
}
|
||||
assert.equal(safeHttpUrl("javascript:alert(1)"), "");
|
||||
assert.equal(safeHttpUrl("pas une url"), "");
|
||||
assert.equal(safeHttpUrl("https://pleinr.fr"), "https://pleinr.fr/");
|
||||
});
|
||||
|
||||
test("un crochet dans le libellé ne permet pas de sortir du lien", () => {
|
||||
const html = richTextToEmailHtml("[a]b](https://pleinr.fr)");
|
||||
assert.ok(!html.includes("<a "), "le lien malformé ne doit pas être construit");
|
||||
assert.ok(html.includes("[a]b]"));
|
||||
});
|
||||
|
||||
test("le HTML écrit dans la saisie est rendu littéralement", () => {
|
||||
const payloads = [
|
||||
"<script>alert(1)</script>",
|
||||
'<img src=x onerror="alert(1)">',
|
||||
"<svg/onload=alert(1)>",
|
||||
'<iframe src="javascript:alert(1)"></iframe>',
|
||||
"<ScRiPt>alert(1)</ScRiPt>",
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
"<style>body{display:none}</style>",
|
||||
];
|
||||
// Les seules balises que le HTML d'e-mail a le droit de contenir sont
|
||||
// celles que l'analyseur construit lui-même.
|
||||
const OURS = /<\/?(?:div|strong|em|a|br|ul|ol|li)\b[^>]*>/g;
|
||||
|
||||
for (const payload of payloads) {
|
||||
const html = richTextToEmailHtml(payload);
|
||||
const leftovers = html.replace(OURS, "");
|
||||
assert.ok(!leftovers.includes("<"), `balise conservée : ${payload}`);
|
||||
assert.ok(!leftovers.includes(">"), `balise conservée : ${payload}`);
|
||||
assert.ok(html.includes("<"), `balise non échappée : ${payload}`);
|
||||
// « onerror » survit comme texte échappé, et c'est sain : il n'est plus
|
||||
// dans un contexte d'attribut, donc plus exécutable.
|
||||
|
||||
// Côté écran, le texte arrive tel quel dans un nœud React : c'est React
|
||||
// qui échappe au rendu, la balise n'est jamais construite.
|
||||
const tree = serialize(payload);
|
||||
assert.ok(!tree.includes('"type":"script"'), `élément script construit : ${payload}`);
|
||||
assert.ok(!tree.includes('"type":"img"'), `élément img construit : ${payload}`);
|
||||
assert.ok(!tree.includes("onError"), `gestionnaire construit : ${payload}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("le rendu n'emprunte jamais dangerouslySetInnerHTML", () => {
|
||||
assert.ok(!serialize("**a** *b* [c](https://d.fr)\n- e\n\n## f").includes("dangerouslySetInnerHTML"));
|
||||
});
|
||||
|
||||
test("les guillemets et esperluettes sont échappés dans le HTML d'e-mail", () => {
|
||||
const html = richTextToEmailHtml('Marie & "Paul" <chez eux>');
|
||||
assert.ok(html.includes("&"));
|
||||
assert.ok(html.includes("""));
|
||||
assert.ok(html.includes("<chez eux>"));
|
||||
});
|
||||
|
||||
test("le repli texte conserve le contenu et explicite les liens", () => {
|
||||
assert.equal(
|
||||
richTextToPlain("Voir **ici** : [les promos](https://pleinr.fr/promotions)"),
|
||||
"Voir ici : les promos (https://pleinr.fr/promotions)",
|
||||
);
|
||||
assert.equal(richTextToPlain("- un\n- deux"), "- un\n- deux");
|
||||
assert.equal(richTextToPlain("1. un\n2. deux"), "1. un\n2. deux");
|
||||
});
|
||||
|
||||
test("le résumé coupe sur un mot et ajoute des points de suspension", () => {
|
||||
assert.equal(richTextExcerpt("Court."), "Court.");
|
||||
const long = richTextExcerpt("Vingt-deux chalets cette année, du 12 au 21 décembre prochain.", 30);
|
||||
assert.ok(long.endsWith("…"));
|
||||
assert.ok(long.length <= 31);
|
||||
assert.ok(!long.includes(" "));
|
||||
});
|
||||
|
||||
test("une saisie vide ne produit aucun bloc", () => {
|
||||
assert.deepEqual(parseRichText(""), []);
|
||||
assert.deepEqual(parseRichText(" \n\n "), []);
|
||||
assert.equal(richTextToEmailHtml(""), "");
|
||||
assert.equal(richTextExcerpt(""), "");
|
||||
});
|
||||
|
||||
test("le tiret bas vaut italique, mais seulement en bord de mot", () => {
|
||||
assert.ok(richTextToEmailHtml("_doux_").includes("<em>doux</em>"));
|
||||
assert.ok(richTextToEmailHtml("**_gras italique_**").includes("<em>gras italique</em>"));
|
||||
// Un nom de fichier ne doit pas devenir italique en son milieu.
|
||||
assert.equal(richTextToPlain("fichier_de_sauvegarde.pdf"), "fichier_de_sauvegarde.pdf");
|
||||
assert.ok(!richTextToEmailHtml("fichier_de_sauvegarde.pdf").includes("<em>"));
|
||||
assert.equal(richTextToPlain("un _ seul"), "un _ seul");
|
||||
});
|
||||
|
||||
test("le rendu vers l'éditeur reprend les balises que le sérialiseur sait relire", () => {
|
||||
const html = richTextToEditorHtml("## Titre\n\nDu **gras** et un [lien](https://pleinr.fr)\n\n- un\n- deux\n\n1. a");
|
||||
assert.ok(html.includes("<h4>Titre</h4>"));
|
||||
assert.ok(html.includes("<strong>gras</strong>"));
|
||||
assert.ok(html.includes('<a href="https://pleinr.fr/">lien</a>'));
|
||||
assert.ok(html.includes("<ul><li>un</li><li>deux</li></ul>"));
|
||||
assert.ok(html.includes("<ol><li>a</li></ol>"));
|
||||
});
|
||||
|
||||
test("un contenu vide offre une ligne où écrire", () => {
|
||||
assert.equal(richTextToEditorHtml(""), "<p><br></p>");
|
||||
assert.equal(richTextToEditorHtml(" "), "<p><br></p>");
|
||||
});
|
||||
|
||||
test("le rendu vers l'éditeur échappe le texte et refuse les cibles hostiles", () => {
|
||||
const html = richTextToEditorHtml('<script>alert(1)</script> & "guillemets"');
|
||||
assert.ok(html.includes("<script>"));
|
||||
assert.ok(html.includes("&"));
|
||||
assert.ok(!html.includes("<script"));
|
||||
assert.ok(!richTextToEditorHtml("[x](javascript:alert(1))").includes("<a "));
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
|
||||
@@ -109,3 +110,257 @@ describe("Chiffrement des secrets réseaux", () => {
|
||||
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coordonnées du référent — jamais dans les lectures publiques", () => {
|
||||
const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
|
||||
|
||||
/** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */
|
||||
function bodyOf(name: string): string {
|
||||
const start = source.indexOf(`export async function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`);
|
||||
const next = source.indexOf("\nexport ", start + 1);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
// Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur
|
||||
// connecté. La garantie tient à une seule chose : aucune requête servant une
|
||||
// page publique ne les rapatrie — sinon ils partiraient dans le HTML, le
|
||||
// JSON-LD ou les props du composant client de l'annuaire.
|
||||
const publicReads = [
|
||||
"getActiveMembersWithCategory",
|
||||
"getActiveMembersByCategory",
|
||||
"getRotatingActiveMembers",
|
||||
"getPublicMember",
|
||||
"getLivePromotions",
|
||||
];
|
||||
|
||||
for (const name of publicReads) {
|
||||
it(`${name} ne lit pas le référent`, () => {
|
||||
const body = bodyOf(name);
|
||||
for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) {
|
||||
assert.ok(
|
||||
!body.includes(column),
|
||||
`${name} sélectionne ${column} : ces coordonnées deviendraient publiques.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("les lectures réservées existent et sont séparées", () => {
|
||||
for (const name of ["getMemberContacts", "getMemberContact"]) {
|
||||
const body = bodyOf(name);
|
||||
assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mots de passe temporaires — jamais mis en file d'attente", () => {
|
||||
const source = readFileSync(new URL("../src/app/backend/actions.ts", import.meta.url), "utf8");
|
||||
|
||||
/** Corps d'une fonction exportée, jusqu'à la suivante. */
|
||||
function actionBody(name: string): string {
|
||||
const start = source.indexOf(`export async function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} introuvable dans src/app/backend/actions.ts`);
|
||||
const next = source.indexOf("\nexport ", start + 1);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
// `mail_messages.html` est stocké en base. Mettre un message d'identifiants
|
||||
// dans la file y écrirait le mot de passe en clair, alors que tout le reste
|
||||
// du produit s'emploie à ne jamais le conserver : il part donc en ligne
|
||||
// directe, et seule une trace sans contenu est journalisée.
|
||||
const issuers = [
|
||||
"addMember",
|
||||
"createMissingMemberAccounts",
|
||||
"resetMemberPassword",
|
||||
"inviteAdmin",
|
||||
"approveMembershipRequest",
|
||||
];
|
||||
|
||||
for (const name of issuers) {
|
||||
it(`${name} envoie directement, sans passer par la file`, () => {
|
||||
const body = actionBody(name);
|
||||
assert.ok(body.includes("generateTempPassword()"), `${name} devrait émettre un mot de passe`);
|
||||
assert.ok(body.includes("deliverCredentials("), `${name} devrait transmettre les identifiants`);
|
||||
assert.ok(
|
||||
!body.includes("queueMail"),
|
||||
`${name} met un mot de passe temporaire dans la file : il finirait stocké en base.`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("la trace journalisée ne porte aucun contenu", () => {
|
||||
const outbox = readFileSync(new URL("../src/lib/mail-outbox.ts", import.meta.url), "utf8");
|
||||
const start = outbox.indexOf("export async function logSentMail(");
|
||||
assert.notEqual(start, -1, "logSentMail introuvable");
|
||||
const body = outbox.slice(start, outbox.indexOf("\n/**", start + 1));
|
||||
assert.ok(body.includes('html: ""'), "logSentMail devrait écrire un corps vide");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Messagerie — aucun secret vers le navigateur", () => {
|
||||
const page = readFileSync(new URL("../src/app/backend/boite-mail/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
// Même règle que l'écran Réseaux sociaux : la page ne connaît que la
|
||||
// *présence* d'un secret, jamais sa valeur.
|
||||
for (const column of ["appSecret", "smtpPassword", "accessToken", "refreshToken"]) {
|
||||
it(`l'écran ne lit jamais ${column} en clair`, () => {
|
||||
// La présence du champ sert au libellé du champ de saisie ; ce qui est
|
||||
// interdit, c'est de le déchiffrer.
|
||||
assert.ok(
|
||||
!page.includes(`decryptSecret(`) && !page.includes(`tryDecryptSecret(`),
|
||||
`boite-mail/page.tsx déchiffre un secret : il partirait dans le HTML.`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("le champ de saisie est masqué et un champ vide conserve la valeur", () => {
|
||||
assert.ok(page.includes('type="password"'), "les secrets doivent être saisis en champ masqué");
|
||||
assert.ok(
|
||||
page.includes("laissez vide pour le conserver"),
|
||||
"l'écran doit annoncer qu'un champ vide conserve le secret enregistré"
|
||||
);
|
||||
});
|
||||
|
||||
it("les secrets sont chiffrés à l'écriture", () => {
|
||||
const accounts = readFileSync(new URL("../src/lib/mail-accounts.ts", import.meta.url), "utf8");
|
||||
|
||||
/** Corps d'une fonction exportée, jusqu'à la suivante. */
|
||||
function writerBody(name: string): string {
|
||||
const start = accounts.indexOf(`export async function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} introuvable dans src/lib/mail-accounts.ts`);
|
||||
const next = accounts.indexOf("\nexport ", start + 1);
|
||||
return accounts.slice(start, next === -1 ? accounts.length : next);
|
||||
}
|
||||
|
||||
// Seuls ces trois écrivent en base ; `exchangeMailCode` rend les jetons en
|
||||
// mémoire à son appelant, qui les chiffre.
|
||||
for (const name of ["saveOAuthApp", "saveSmtpAccount", "saveMailConnection"]) {
|
||||
const body = writerBody(name);
|
||||
const assignments = body.match(/\b(appSecret|smtpPassword|accessToken|refreshToken):\s*[^,\n]+/g) ?? [];
|
||||
// On écarte les annotations de type de la signature et les valeurs
|
||||
// littérales : seules les affectations réelles nous intéressent.
|
||||
const writes = assignments.filter(
|
||||
(assignment) => !/:\s*(null|""|string|number|boolean|Date)\b/.test(assignment)
|
||||
);
|
||||
assert.ok(writes.length > 0, `${name} n'écrit aucun secret : le test ne vérifie plus rien`);
|
||||
for (const assignment of writes) {
|
||||
assert.ok(
|
||||
assignment.includes("encryptSecret("),
|
||||
`${name} écrit un secret sans le chiffrer : ${assignment}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Diffusion groupée — aucune adresse partagée", () => {
|
||||
it("ni copie ni copie cachée nulle part dans la chaîne d'envoi", () => {
|
||||
for (const file of ["../src/lib/mailer.ts", "../src/lib/mail-outbox.ts"]) {
|
||||
const source = readFileSync(new URL(file, import.meta.url), "utf8");
|
||||
assert.ok(!/\bcc\s*:/i.test(source), `${file} pose une copie`);
|
||||
assert.ok(!/\bbcc\s*:/i.test(source), `${file} pose une copie cachée`);
|
||||
}
|
||||
});
|
||||
|
||||
it("la file ne porte qu'un destinataire par ligne", () => {
|
||||
const schema = readFileSync(new URL("../src/db/schema.ts", import.meta.url), "utf8");
|
||||
assert.ok(
|
||||
/toAddress: varchar\("to_address", \{ length: \d+ \}\)\.notNull\(\)/.test(schema),
|
||||
"to_address devrait être une adresse unique et obligatoire"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Informations — rien n'est rendu en HTML brut", () => {
|
||||
const files = [
|
||||
"../src/lib/rich-text.ts",
|
||||
"../src/components/InformationCard.tsx",
|
||||
"../src/app/backend/informations/InformationForm.tsx",
|
||||
"../src/components/RichTextEditor.tsx",
|
||||
"../src/app/backend/espace/informations/page.tsx",
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
it(`${file.split("/").pop()} n'emprunte pas dangerouslySetInnerHTML`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), "utf8");
|
||||
// L'usage réel, pas la mention : `rich-text.ts` explique en commentaire
|
||||
// pourquoi il ne s'en sert pas.
|
||||
assert.ok(
|
||||
!/dangerouslySetInnerHTML\s*[={]/.test(source),
|
||||
"le texte des informations vient de tiers : il doit rester des nœuds React"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("les lectures publiques ignorent les informations", () => {
|
||||
// Le fil est réservé aux adhérents connectés : aucune requête servant une
|
||||
// page publique ne doit y toucher.
|
||||
const queries = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
|
||||
assert.ok(!queries.includes("informations"), "src/lib/queries.ts lit les informations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Boucles de fond — jamais dans le bundle edge", () => {
|
||||
it("le module Node reste chargé à la demande et écarté du runtime edge", () => {
|
||||
const entry = readFileSync(new URL("../src/instrumentation.ts", import.meta.url), "utf8");
|
||||
assert.ok(entry.includes('NEXT_RUNTIME !== "nodejs"'), "la garde de runtime a disparu");
|
||||
assert.ok(!entry.includes("mail-outbox"), "instrumentation.ts importe la file directement");
|
||||
assert.ok(!entry.includes("nodemailer"), "instrumentation.ts importe nodemailer");
|
||||
|
||||
const config = readFileSync(new URL("../next.config.mjs", import.meta.url), "utf8");
|
||||
assert.ok(config.includes("IgnorePlugin"), "l'exclusion du bundle edge a disparu");
|
||||
assert.ok(config.includes('serverExternalPackages: ["nodemailer"]'), "nodemailer doit rester externe");
|
||||
});
|
||||
|
||||
it("chaque boucle a son propre interrupteur", () => {
|
||||
// Couper le libérateur de promotions ne doit pas couper l'envoi des
|
||||
// e-mails : les deux n'ont rien à voir.
|
||||
const worker = readFileSync(new URL("../src/instrumentation-node.ts", import.meta.url), "utf8");
|
||||
assert.ok(worker.includes('process.env.PROMO_SCHEDULER !== "off"'));
|
||||
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Éditeur visuel — le HTML ne quitte jamais la page", () => {
|
||||
const editor = readFileSync(new URL("../src/components/RichTextEditor.tsx", import.meta.url), "utf8");
|
||||
const form = readFileSync(new URL("../src/app/backend/informations/InformationForm.tsx", import.meta.url), "utf8");
|
||||
|
||||
// L'édition est visuelle, mais ce qui part au serveur reste le format
|
||||
// balisé restreint : c'est tout l'arrangement qui permet un éditeur
|
||||
// confortable sans stocker de HTML tiers.
|
||||
it("le formulaire envoie le balisage sérialisé, jamais du HTML", () => {
|
||||
assert.ok(form.includes('<input type="hidden" name="body"'), "le corps doit voyager en champ caché");
|
||||
assert.ok(!form.includes("innerHTML"), "le formulaire ne manipule pas de HTML");
|
||||
assert.ok(editor.includes("serializeToRichText("), "l'éditeur doit resérialiser ce qu'il édite");
|
||||
});
|
||||
|
||||
it("seul du HTML produit par nous est injecté dans la zone d'édition", () => {
|
||||
// Toute écriture de HTML dans l'éditeur passe par notre générateur : jamais
|
||||
// une chaîne venue du presse-papier ou du serveur.
|
||||
for (const call of editor.match(/innerHTML\s*=\s*[^;\n]+/g) ?? []) {
|
||||
assert.ok(call.includes("richTextToEditorHtml("), `HTML injecté sans générateur : ${call}`);
|
||||
}
|
||||
for (const call of editor.match(/insertHTML"[^)]*\)/g) ?? []) {
|
||||
assert.ok(call.includes("richTextToEditorHtml("), `HTML inséré sans générateur : ${call}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("le collage est analysé dans un document inerte", () => {
|
||||
// `DOMParser` n'exécute rien et ne charge aucune ressource, contrairement
|
||||
// à une affectation d'`innerHTML` — même sur un nœud détaché, un
|
||||
// `<img onerror>` peut s'y déclencher.
|
||||
assert.ok(editor.includes('getData("text/html")'), "le collage HTML doit être traité");
|
||||
assert.ok(editor.includes("new DOMParser().parseFromString("), "le HTML collé doit passer par DOMParser");
|
||||
const pasteIndex = editor.indexOf('getData("text/html")');
|
||||
const parserIndex = editor.indexOf("new DOMParser().parseFromString(");
|
||||
assert.ok(parserIndex > pasteIndex, "le HTML collé doit être analysé avant toute réinjection");
|
||||
});
|
||||
|
||||
it("le sérialiseur écarte les balises exécutables", () => {
|
||||
const serializer = readFileSync(new URL("../src/lib/rich-text-dom.ts", import.meta.url), "utf8");
|
||||
for (const tag of ["SCRIPT", "STYLE", "IFRAME", "OBJECT"]) {
|
||||
assert.ok(serializer.includes(`"${tag}"`), `${tag} devrait être explicitement écarté`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user