mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Compare commits
5
Commits
7e7d307cdd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e340dde58 | ||
|
|
2da125eca2 | ||
|
|
cc33cf5fbc | ||
|
|
90b121a3c1 | ||
|
|
b860eeaf29 |
No files matched your search
@@ -206,7 +206,9 @@ même raison.
|
|||||||
journal, limitation des connexions, chiffrement des secrets, coordonnées du
|
journal, limitation des connexions, chiffrement des secrets, coordonnées du
|
||||||
référent, **mots de passe temporaires jamais mis en file**, secrets de
|
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
|
messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne
|
||||||
d'envoi, et rien rendu en HTML brut côté informations.
|
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)
|
## Référencement (SEO)
|
||||||
|
|
||||||
@@ -225,6 +227,24 @@ même raison.
|
|||||||
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
|
`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
|
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
|
||||||
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
|
`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
|
- **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`)
|
(`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
|
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
|
||||||
@@ -248,6 +268,17 @@ même raison.
|
|||||||
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
|
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
|
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
|
||||||
onglets ; toute action qui touche l'espace revalide les deux chemins.
|
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**
|
- 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
|
(`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.
|
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
|
||||||
@@ -291,16 +322,38 @@ porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
|
|||||||
`information_reads`.
|
`information_reads`.
|
||||||
|
|
||||||
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
|
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
|
||||||
et analyse un sous-ensemble de Markdown — `**gras**`, `*italique*`, `- puce`,
|
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
|
||||||
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Deux rendus, un seul
|
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
|
||||||
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
|
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
|
||||||
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message. L'aperçu
|
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
|
||||||
du formulaire passe par le premier, il est donc fidèle par construction. Un
|
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
|
||||||
lien hors `http`/`https` perd sa cible et ne garde que son libellé
|
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
|
||||||
(`safeHttpUrl`, partagé avec `email-templates.ts`).
|
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
|
||||||
- **Pas de WYSIWYG** : il produirait du HTML, qu'il faudrait stocker puis
|
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
|
||||||
assainir — seconde dépendance, seconde surface d'attaque. La saisie est un
|
accepté en lecture, et un tiret bas au milieu d'un mot
|
||||||
`<textarea>` avec une barre qui encadre la sélection (`setRangeText`).
|
(`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
|
- **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
|
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
|
servie aux clients mail par `/api/informations/[id]/image`, qui ne répond que
|
||||||
@@ -329,6 +382,21 @@ pas de cascade automatique, la bascule est un choix visible.
|
|||||||
**Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP
|
**Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP
|
||||||
est désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
|
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.
|
**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,
|
- `mail_accounts.from_address` est **lu chez le fournisseur** au retour OAuth,
|
||||||
jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la
|
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.
|
boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
|
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 type { AppRole } from "@/types/next-auth";
|
||||||
import { doSignOut } from "./actions";
|
import { doSignOut } from "./actions";
|
||||||
|
|
||||||
@@ -192,6 +192,43 @@ export function BackendShell({
|
|||||||
</form>
|
</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 (
|
return (
|
||||||
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
|
|||||||
@@ -1449,7 +1449,9 @@ export async function saveInformation(
|
|||||||
|
|
||||||
const id = Number(formData.get("id") ?? 0) || null;
|
const id = Number(formData.get("id") ?? 0) || null;
|
||||||
const title = asString(formData, "title");
|
const title = asString(formData, "title");
|
||||||
const body = asString(formData, "body");
|
// L'encodage des formulaires HTML convertit tout saut de ligne en CRLF :
|
||||||
|
// sans cela le balisage stocké différerait de celui que l'éditeur a produit.
|
||||||
|
const body = asString(formData, "body").replace(/\r\n?/g, "\n");
|
||||||
if (!title) return { error: "Donnez un titre à cette information." };
|
if (!title) return { error: "Donnez un titre à cette information." };
|
||||||
if (!body) return { error: "Le message est vide." };
|
if (!body) return { error: "Le message est vide." };
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type MailHealth,
|
type MailHealth,
|
||||||
} from "@/lib/mail-accounts";
|
} from "@/lib/mail-accounts";
|
||||||
import { recentMails } from "@/lib/mail-outbox";
|
import { recentMails } from "@/lib/mail-outbox";
|
||||||
|
import { SmtpAccountForm } from "@/components/SmtpAccountForm";
|
||||||
import { publicBaseUrl } from "@/lib/social-accounts";
|
import { publicBaseUrl } from "@/lib/social-accounts";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
import type { MailAccount, MailProvider } from "@/db/schema";
|
import type { MailAccount, MailProvider } from "@/db/schema";
|
||||||
@@ -194,61 +195,24 @@ export default async function BoiteMailPage({
|
|||||||
|
|
||||||
{verdict && !verdict.ok && (
|
{verdict && !verdict.ok && (
|
||||||
<div style={{ marginTop: 13 }}>
|
<div style={{ marginTop: 13 }}>
|
||||||
<Banner tone="error">{verdict.reason.slice(0, 220)}</Banner>
|
<Banner tone="error">{verdict.reason.slice(0, 400)}</Banner>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
|
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
|
||||||
|
|
||||||
{provider === "smtp" ? (
|
{provider === "smtp" ? (
|
||||||
<form action={saveMailSmtp}>
|
<SmtpAccountForm
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
|
saveAction={saveMailSmtp}
|
||||||
<label className="field-label">
|
host={account?.smtpHost ?? ""}
|
||||||
Serveur
|
port={account?.smtpPort ?? 465}
|
||||||
<input className="field" name="smtpHost" defaultValue={account?.smtpHost ?? ""} placeholder="smtp.ionos.fr" style={{ marginTop: 6 }} />
|
secure={account?.smtpSecure ?? true}
|
||||||
</label>
|
user={account?.smtpUser ?? ""}
|
||||||
<label className="field-label">
|
hasPassword={Boolean(account?.smtpPassword)}
|
||||||
Port
|
fromAddress={account?.fromAddress ?? ""}
|
||||||
<input className="field" name="smtpPort" type="number" min={1} max={65535} defaultValue={account?.smtpPort ?? 465} style={{ marginTop: 6 }} />
|
fromName={account?.fromName ?? settings.association_name}
|
||||||
</label>
|
submitStyle={primary}
|
||||||
<label className="field-label">
|
/>
|
||||||
Identifiant
|
|
||||||
<input className="field" name="smtpUser" defaultValue={account?.smtpUser ?? ""} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
|
|
||||||
</label>
|
|
||||||
</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={account?.smtpPassword ? "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={account?.fromAddress ?? ""} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
|
|
||||||
</label>
|
|
||||||
<label className="field-label">
|
|
||||||
Nom affiché
|
|
||||||
<input className="field" name="fromName" defaultValue={account?.fromName ?? settings.association_name} style={{ marginTop: 6 }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
|
|
||||||
<input type="checkbox" name="smtpSecure" defaultChecked={account?.smtpSecure ?? true} />
|
|
||||||
Connexion chiffrée dès l'ouverture (port 465). Décochez pour STARTTLS (port 587).
|
|
||||||
</label>
|
|
||||||
<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={primary}>Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<form action={saveMailApp}>
|
<form action={saveMailApp}>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { richTextNodes } from "@/lib/rich-text";
|
import { RichTextEditor } from "@/components/RichTextEditor";
|
||||||
import { saveInformation } from "../actions";
|
import { saveInformation } from "../actions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rédaction d'une information.
|
* Rédaction d'une information.
|
||||||
*
|
*
|
||||||
* Pas d'éditeur WYSIWYG : un `<textarea>` et des boutons qui encadrent la
|
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
|
||||||
* sélection, plus un aperçu rendu par **le même** analyseur que le site
|
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
|
||||||
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
|
* d'association n'a pas à connaître Markdown.
|
||||||
* qui est saisi ne peut devenir du HTML.
|
*
|
||||||
|
* 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 = {
|
type Draft = {
|
||||||
@@ -24,18 +28,8 @@ type Draft = {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOOLS = [
|
|
||||||
{ label: "B", title: "Gras", before: "**", after: "**", sample: "texte en gras", strong: true },
|
|
||||||
{ label: "I", title: "Italique", before: "*", after: "*", sample: "texte en italique", italic: true },
|
|
||||||
{ label: "Titre", title: "Sous-titre", before: "## ", after: "", sample: "Sous-titre", line: true },
|
|
||||||
{ label: "• liste", title: "Liste à puces", before: "- ", after: "", sample: "premier point", line: true },
|
|
||||||
{ label: "1. liste", title: "Liste numérotée", before: "1. ", after: "", sample: "première étape", line: true },
|
|
||||||
{ label: "Lien", title: "Lien", before: "[", after: "](https://)", sample: "libellé du lien" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function InformationForm({ draft }: { draft: Draft }) {
|
export function InformationForm({ draft }: { draft: Draft }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [title, setTitle] = useState(draft.title);
|
const [title, setTitle] = useState(draft.title);
|
||||||
const [body, setBody] = useState(draft.body);
|
const [body, setBody] = useState(draft.body);
|
||||||
const [pinned, setPinned] = useState(draft.pinned);
|
const [pinned, setPinned] = useState(draft.pinned);
|
||||||
@@ -45,19 +39,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
|
|||||||
// seul un changement de clé le remet à vide.
|
// seul un changement de clé le remet à vide.
|
||||||
const [round, setRound] = useState(0);
|
const [round, setRound] = useState(0);
|
||||||
|
|
||||||
function applyTool(tool: (typeof TOOLS)[number]) {
|
|
||||||
const field = bodyRef.current;
|
|
||||||
if (!field) return;
|
|
||||||
const { selectionStart, selectionEnd } = field;
|
|
||||||
const selected = field.value.slice(selectionStart, selectionEnd) || tool.sample;
|
|
||||||
// `setRangeText` conserve la pile d'annulation du navigateur, ce qu'une
|
|
||||||
// réécriture complète de la valeur ferait perdre.
|
|
||||||
const insert = "line" in tool && tool.line && selectionStart > 0 && field.value[selectionStart - 1] !== "\n";
|
|
||||||
field.setRangeText(`${insert ? "\n" : ""}${tool.before}${selected}${tool.after}`, selectionStart, selectionEnd, "select");
|
|
||||||
field.focus();
|
|
||||||
setBody(field.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(formData: FormData) {
|
async function submit(formData: FormData) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -99,44 +80,18 @@ export function InformationForm({ draft }: { draft: Draft }) {
|
|||||||
style={{ marginBottom: 15, fontWeight: 700 }}
|
style={{ marginBottom: 15, fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="field-label" htmlFor="information-body">Message</label>
|
<label className="field-label" id="information-body-label">Message</label>
|
||||||
<div
|
<RichTextEditor
|
||||||
style={{
|
value={draft.body}
|
||||||
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
|
onChange={setBody}
|
||||||
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
|
ariaLabel="Message de l'information"
|
||||||
background: "#fff", padding: "6px 7px",
|
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TOOLS.map((tool) => (
|
|
||||||
<button
|
|
||||||
key={tool.label}
|
|
||||||
type="button"
|
|
||||||
title={tool.title}
|
|
||||||
onClick={() => applyTool(tool)}
|
|
||||||
style={{
|
|
||||||
minWidth: 30, height: 28, padding: "0 9px", borderRadius: 7, border: "none", background: "transparent",
|
|
||||||
color: "#6c6150", fontSize: 12.5, fontWeight: 700, cursor: "pointer",
|
|
||||||
fontStyle: "italic" in tool && tool.italic ? "italic" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tool.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
id="information-body"
|
|
||||||
ref={bodyRef}
|
|
||||||
className="field"
|
|
||||||
name="body"
|
|
||||||
rows={10}
|
|
||||||
value={body}
|
|
||||||
onChange={(event) => setBody(event.target.value)}
|
|
||||||
placeholder={"Rédigez ici. Laissez une ligne vide entre deux paragraphes.\n\n- une puce\n**du gras**, [un lien](https://pleinr.fr)"}
|
|
||||||
style={{ borderRadius: "0 0 10px 10px", resize: "vertical", lineHeight: 1.7 }}
|
|
||||||
/>
|
/>
|
||||||
|
{/* 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 }}>
|
<p className="field-hint" style={{ marginTop: 6 }}>
|
||||||
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
|
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
|
||||||
<code>[libellé](https://…)</code>. Les liens s'ouvrent dans un nouvel onglet.
|
conserve la mise en forme reconnue et laisse tomber le reste.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
@@ -153,20 +108,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{body.trim() && (
|
|
||||||
<div style={{ marginTop: 18, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
|
|
||||||
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 10 }}>
|
|
||||||
Aperçu
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 12, padding: "14px 16px" }}>
|
|
||||||
<h3 className="font-display" style={{ margin: "0 0 8px", fontWeight: 700, fontSize: 17, color: "#26201a" }}>
|
|
||||||
{title || "Sans titre"}
|
|
||||||
</h3>
|
|
||||||
<div className="rich-text">{richTextNodes(body)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
|
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -850,6 +850,81 @@ img {
|
|||||||
min-width: 0;
|
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 {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -1039,6 +1114,92 @@ img {
|
|||||||
text-underline-offset: 2px;
|
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
|
/* 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. */
|
que la colonne de droite n'a plus la place d'être lisible. */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Analytics } from "@/components/Analytics";
|
||||||
import { JsonLd } from "@/components/JsonLd";
|
import { JsonLd } from "@/components/JsonLd";
|
||||||
import {
|
import {
|
||||||
INDEX_FOLLOW,
|
INDEX_FOLLOW,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
organizationJsonLd,
|
organizationJsonLd,
|
||||||
webSiteJsonLd,
|
webSiteJsonLd,
|
||||||
} from "@/lib/seo";
|
} from "@/lib/seo";
|
||||||
|
import { normalizeSiteVerification } from "@/lib/analytics";
|
||||||
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
|
||||||
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -33,8 +35,14 @@ export const viewport: Viewport = {
|
|||||||
* relatives (canonique, Open Graph, image de partage).
|
* relatives (canonique, Open Graph, image de partage).
|
||||||
*/
|
*/
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
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 {
|
return {
|
||||||
metadataBase: await metadataBase(),
|
metadataBase: base,
|
||||||
title: {
|
title: {
|
||||||
default: SITE_TITLE,
|
default: SITE_TITLE,
|
||||||
template: `%s · ${SITE_NAME}`,
|
template: `%s · ${SITE_NAME}`,
|
||||||
@@ -63,6 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
description: SITE_DESCRIPTION,
|
description: SITE_DESCRIPTION,
|
||||||
},
|
},
|
||||||
robots: INDEX_FOLLOW,
|
robots: INDEX_FOLLOW,
|
||||||
|
verification: google ? { google } : undefined,
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
icon: [{ url: "/assets/logo.png", type: "image/png" }],
|
||||||
apple: [{ 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>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
<JsonLd data={structuredData} />
|
<JsonLd data={structuredData} />
|
||||||
|
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 : "";
|
||||||
|
}
|
||||||
+39
-12
@@ -1,5 +1,6 @@
|
|||||||
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
|
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
|
||||||
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
|
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
|
||||||
|
import { describeSmtpError } from "@/lib/smtp-config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expédition d'un message.
|
* Expédition d'un message.
|
||||||
@@ -102,14 +103,19 @@ async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise<Sen
|
|||||||
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
|
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
|
||||||
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
|
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
|
||||||
const transport = await smtpTransport(sender.smtp);
|
const transport = await smtpTransport(sender.smtp);
|
||||||
await transport.sendMail({
|
try {
|
||||||
from: formatAddress(sender.fromAddress, sender.fromName),
|
await transport.sendMail({
|
||||||
to: formatAddress(mail.to, mail.toName),
|
from: formatAddress(sender.fromAddress, sender.fromName),
|
||||||
replyTo: mail.replyTo ?? undefined,
|
to: formatAddress(mail.to, mail.toName),
|
||||||
subject: mail.subject,
|
replyTo: mail.replyTo ?? undefined,
|
||||||
html: mail.html,
|
subject: mail.subject,
|
||||||
text: mail.text ?? undefined,
|
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" };
|
return { ok: true, provider: "smtp" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +123,17 @@ async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<Send
|
|||||||
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
|
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
|
||||||
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
|
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
|
||||||
* jamais entrer dans un bundle qui ne les a pas.
|
* 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) {
|
async function smtpTransport(config: SmtpConfig) {
|
||||||
const nodemailer = (await import("nodemailer")).default;
|
const nodemailer = (await import("nodemailer")).default;
|
||||||
@@ -124,18 +141,28 @@ async function smtpTransport(config: SmtpConfig) {
|
|||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
secure: config.secure,
|
secure: config.secure,
|
||||||
|
requireTLS: !config.secure,
|
||||||
auth: { user: config.user, pass: config.password },
|
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. */
|
/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */
|
||||||
export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> {
|
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 {
|
try {
|
||||||
const transport = await smtpTransport(config);
|
|
||||||
await transport.verify();
|
await transport.verify();
|
||||||
transport.close();
|
const how = config.secure ? "chiffrée dès l'ouverture" : "STARTTLS";
|
||||||
return { ok: true, detail: `Connexion établie avec ${config.host}` };
|
return { ok: true, detail: `Connexion établie avec ${config.host} sur le port ${config.port} (${how})` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, reason: error instanceof Error ? error.message : "Serveur SMTP injoignable" };
|
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,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("");
|
||||||
|
}
|
||||||
+81
-1
@@ -11,10 +11,15 @@ import { createElement, Fragment } from "react";
|
|||||||
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
|
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
|
||||||
* dépendance, deuxième surface d'attaque.
|
* dépendance, deuxième surface d'attaque.
|
||||||
*
|
*
|
||||||
* Grammaire : `**gras**`, `*italique*`, `- puce`, `1. numéro`,
|
* Grammaire : `**gras**`, `*italique*` ou `_italique_`, `- puce`, `1. numéro`,
|
||||||
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
|
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
|
||||||
* Un saut de ligne simple reste un saut de ligne.
|
* 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,
|
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
|
||||||
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
|
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
|
||||||
* `richTextNodes()`, il est donc fidèle par construction.
|
* `richTextNodes()`, il est donc fidèle par construction.
|
||||||
@@ -46,6 +51,19 @@ export function safeHttpUrl(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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[] {
|
function parseInline(text: string): Inline[] {
|
||||||
const nodes: Inline[] = [];
|
const nodes: Inline[] = [];
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@@ -77,6 +95,19 @@ function parseInline(text: string): Inline[] {
|
|||||||
continue;
|
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] === "[") {
|
if (text[index] === "[") {
|
||||||
const close = text.indexOf("]", index + 1);
|
const close = text.indexOf("]", index + 1);
|
||||||
if (close > index && text[close + 1] === "(") {
|
if (close > index && text[close + 1] === "(") {
|
||||||
@@ -316,3 +347,52 @@ export function richTextExcerpt(source: string, max = 160): string {
|
|||||||
const lastSpace = cut.lastIndexOf(" ");
|
const lastSpace = cut.lastIndexOf(" ");
|
||||||
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
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("");
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ export const SITE_SETTING_DEFAULTS = {
|
|||||||
association_publication_director: "",
|
association_publication_director: "",
|
||||||
association_privacy_contact: "",
|
association_privacy_contact: "",
|
||||||
legal_updated: "",
|
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;
|
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
|
||||||
|
|||||||
@@ -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",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
||||||
"img-src 'self' data: https:",
|
"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.
|
// Carte « Nous situer » de la fiche adhérent.
|
||||||
"frame-src https://www.google.com https://maps.google.com",
|
"frame-src https://www.google.com https://maps.google.com",
|
||||||
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
// 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.
|
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||||
const requestHeaders = new Headers(request.headers);
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set("x-nonce", nonce);
|
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);
|
requestHeaders.set("content-security-policy", csp);
|
||||||
|
|
||||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,3 +42,41 @@ test("chaque icône déclarée dans le type est dessinée", () => {
|
|||||||
assert.ok(drawn.has(name), `l'icône « ${name} » est déclarée mais pas dessinée`);
|
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,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_**",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
parseRichText,
|
parseRichText,
|
||||||
richTextExcerpt,
|
richTextExcerpt,
|
||||||
richTextNodes,
|
richTextNodes,
|
||||||
|
richTextToEditorHtml,
|
||||||
richTextToEmailHtml,
|
richTextToEmailHtml,
|
||||||
richTextToPlain,
|
richTextToPlain,
|
||||||
safeHttpUrl,
|
safeHttpUrl,
|
||||||
@@ -164,3 +165,34 @@ test("une saisie vide ne produit aucun bloc", () => {
|
|||||||
assert.equal(richTextToEmailHtml(""), "");
|
assert.equal(richTextToEmailHtml(""), "");
|
||||||
assert.equal(richTextExcerpt(""), "");
|
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 "));
|
||||||
|
});
|
||||||
@@ -277,6 +277,7 @@ describe("Informations — rien n'est rendu en HTML brut", () => {
|
|||||||
"../src/lib/rich-text.ts",
|
"../src/lib/rich-text.ts",
|
||||||
"../src/components/InformationCard.tsx",
|
"../src/components/InformationCard.tsx",
|
||||||
"../src/app/backend/informations/InformationForm.tsx",
|
"../src/app/backend/informations/InformationForm.tsx",
|
||||||
|
"../src/components/RichTextEditor.tsx",
|
||||||
"../src/app/backend/espace/informations/page.tsx",
|
"../src/app/backend/espace/informations/page.tsx",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -320,3 +321,46 @@ describe("Boucles de fond — jamais dans le bundle edge", () => {
|
|||||||
assert.ok(worker.includes('process.env.MAIL_WORKER !== "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é`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
IMPLICIT_TLS_PORT,
|
||||||
|
STARTTLS_PORTS,
|
||||||
|
describeSmtpError,
|
||||||
|
impliedSecure,
|
||||||
|
tlsMismatch,
|
||||||
|
} from "../src/lib/smtp-config";
|
||||||
|
|
||||||
|
test("smtp — seul le port 465 chiffre dès l'ouverture", () => {
|
||||||
|
assert.equal(impliedSecure(IMPLICIT_TLS_PORT), true);
|
||||||
|
for (const port of STARTTLS_PORTS) assert.equal(impliedSecure(port), false);
|
||||||
|
assert.equal(impliedSecure(1025), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — le couple port / chiffrement cohérent ne dit rien", () => {
|
||||||
|
assert.equal(tlsMismatch(465, true), null);
|
||||||
|
assert.equal(tlsMismatch(587, false), null);
|
||||||
|
assert.equal(tlsMismatch(25, false), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — case cochée sur un port STARTTLS : on prévient avec le port", () => {
|
||||||
|
const warning = tlsMismatch(587, true);
|
||||||
|
assert.ok(warning);
|
||||||
|
assert.match(warning, /587/);
|
||||||
|
assert.match(warning, /STARTTLS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — case décochée sur le port 465 : on prévient aussi", () => {
|
||||||
|
const warning = tlsMismatch(465, false);
|
||||||
|
assert.ok(warning);
|
||||||
|
assert.match(warning, /465/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — « wrong version number » désigne la case à décocher", () => {
|
||||||
|
const message = describeSmtpError(
|
||||||
|
"140342:error:0A00010B:SSL routines:ssl3_get_record:wrong version number",
|
||||||
|
{ host: "mail.infomaniak.com", port: 587, secure: true },
|
||||||
|
);
|
||||||
|
assert.match(message, /Décochez/);
|
||||||
|
assert.match(message, /587/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — délai dépassé sur le 465 sans chiffrement : on désigne la case à cocher", () => {
|
||||||
|
const message = describeSmtpError("Greeting never received", { host: "mail.infomaniak.com", port: 465, secure: false });
|
||||||
|
assert.match(message, /Cochez/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — délai dépassé sur un port STARTTLS : on évoque le filtrage sortant", () => {
|
||||||
|
const message = describeSmtpError("connect ETIMEDOUT 1.2.3.4:587", { host: "mail.infomaniak.com", port: 587, secure: false });
|
||||||
|
assert.match(message, /filtré/);
|
||||||
|
assert.doesNotMatch(message, /Cochez/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — authentification refusée : on oriente vers le mot de passe d'application", () => {
|
||||||
|
const message = describeSmtpError("Invalid login: 535 5.7.8 Authentication failed", { host: "smtp.gmail.com", port: 587 });
|
||||||
|
assert.match(message, /mot de passe d'application/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — nom de serveur introuvable : on cite le nom saisi", () => {
|
||||||
|
const message = describeSmtpError("getaddrinfo ENOTFOUND mail.exemple.invalid", { host: "mail.exemple.invalid" });
|
||||||
|
assert.match(message, /mail\.exemple\.invalid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — l'explication précède toujours la trace technique", () => {
|
||||||
|
const raw = "140342:error:0A00010B:SSL routines:ssl3_get_record:wrong version number";
|
||||||
|
const message = describeSmtpError(raw, { host: "mail.infomaniak.com", port: 587, secure: true });
|
||||||
|
assert.ok(message.indexOf("détail") > 0);
|
||||||
|
assert.ok(!message.startsWith(raw));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smtp — un échec inconnu est rendu tel quel, jamais vide", () => {
|
||||||
|
assert.equal(describeSmtpError("Panne inattendue"), "Panne inattendue");
|
||||||
|
assert.equal(describeSmtpError(""), "Serveur SMTP injoignable");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user