This commit is contained in:
Ladebeze66 2026-04-23 11:12:54 +02:00
parent 1267d60cf2
commit 916ae8dfef
12 changed files with 805 additions and 110 deletions

View File

@ -16,6 +16,7 @@ my-next-site/
├── cmsbackend/ # Backend Strapi ├── cmsbackend/ # Backend Strapi
├── llm-api/ # API FastAPI pour IA ├── llm-api/ # API FastAPI pour IA
├── start-my-site.ps1 # Script de démarrage ├── start-my-site.ps1 # Script de démarrage
├── stop-my-site.ps1 # Script d'arrêt propre
└── package.json # Dépendances frontend └── package.json # Dépendances frontend
``` ```
@ -29,6 +30,23 @@ cd J:\my-next-site
Ce script lance automatiquement les 3 services dans des fenêtres PowerShell séparées. Ce script lance automatiquement les 3 services dans des fenêtres PowerShell séparées.
**Améliorations :**
- Configuration centralisée via un tableau `$services` (plus de duplication entre les 3 blocs).
- **Détection du port déjà occupé** : si un service tourne déjà, il n'est pas relancé (évite `EADDRINUSE`).
- **Portabilité** : chemins résolus via `$PSScriptRoot`, pas de `J:\my-next-site` codé en dur.
- **`-NoExit`** sur chaque fenêtre : le message d'erreur reste visible si un service crashe au démarrage.
- **Bilan final** : nombre de services lancés / déjà actifs / échecs.
### Arrêt des services
```powershell
cd J:\my-next-site
.\stop-my-site.ps1
```
Termine les processus qui écoutent les ports **1337** (Strapi), **3000** (Next.js) et **8000** (FastAPI). Ne touche pas aux autres processus Node de ta machine. Les fenêtres PowerShell lancées par `start-my-site.ps1` restent ouvertes (à fermer manuellement). En cas d'échec sur certains PIDs → relancer dans un PowerShell admin.
> Note encodage : les deux scripts PowerShell sont encodés en **UTF-8 avec BOM**. C'est nécessaire pour que Windows PowerShell 5.1 (version par défaut) les lise correctement avec les emojis et accents. PowerShell 7 n'a pas ce souci mais reste compatible avec le BOM.
## 🔧 Commandes Manuelles ## 🔧 Commandes Manuelles
### 1. Frontend Next.js ### 1. Frontend Next.js

View File

@ -1,27 +0,0 @@
import { getApiUrl } from "../../utils/getApiUrl";
export default async function MessagesPage() {
const apiUrl = getApiUrl();
const res = await fetch(`${apiUrl}/api/messages`);
const { data } = await res.json();
return (
<div className="bg-white/70 rounded-md max-w-3xl mx-auto p-6">
{/* Titre de la page */}
<h1 className="text-3xl font-bold text-center mb-6">📬 Messages reçus</h1>
{data.length === 0 ? (
<p className="text-center text-gray-600">Aucun message reçu.</p>
) : (
<ul className="space-y-4">
{data.map((msg: any) => (
<li key={msg.id} className="p-4 border rounded shadow">
<p><strong>👤 {msg.name}</strong> ({msg.email})</p>
<p>📅 {new Date(msg.createdAt).toLocaleString("fr-FR")}</p>
<p className="mt-2">{msg.message}</p>
</li>
))}
</ul>
)}
</div>
);
}

291
app/api/contact/route.ts Normal file
View File

@ -0,0 +1,291 @@
/**
* Endpoint POST /api/contact reçoit les messages du formulaire de contact
* et envoie une notification par email via l'API HTTP Brevo.
*
* Flux :
* Navigateur POST /api/contact (cette route, serveur)
* validation + honeypot + rate-limit
* POST https://api.brevo.com/v3/smtp/email
*
* Gmail : CONTACT_TO_EMAIL
*
* Aucun stockage côté Strapi (voir docs-site-interne/contact-flow.md pour la
* décision d'architecture). Si l'envoi Brevo échoue, on renvoie une erreur 502
* au client pour qu'il puisse -essayer.
*
* Variables d'environnement requises (voir .env.example) :
* - BREVO_API_KEY
* - CONTACT_FROM_EMAIL (doit être un expéditeur vérifié dans Brevo)
* - CONTACT_FROM_NAME
* - CONTACT_TO_EMAIL
* - CONTACT_TO_NAME
*/
import { NextResponse, type NextRequest } from "next/server";
// Force Node runtime : `fetch` est dispo sur l'edge aussi, mais on veut la
// stdlib Node pour les logs serveur et parce que Brevo peut mettre >1s à
// répondre (edge a une limite de timeout plus stricte sur Vercel free).
export const runtime = "nodejs";
// Map en mémoire pour le rate-limit. `Map<ip, number[]>` où number[] est la
// liste des timestamps d'envois récents. On nettoie à la volée. Suffisant pour
// un portfolio — pas besoin de Redis tant qu'on reste sur une instance.
// Note : en mode serverless (plusieurs lambdas), cette map n'est pas partagée,
// donc le rate-limit est "best effort". Pour du ship prod sous charge, passer
// sur Redis ou Upstash.
const rateLimitStore = new Map<string, number[]>();
const RATE_LIMIT_MAX = 3; // 3 envois max
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // par tranche de 10 minutes
const MAX_NAME_LENGTH = 120;
const MAX_EMAIL_LENGTH = 160;
const MAX_MESSAGE_LENGTH = 5000;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type ContactPayload = {
name?: string;
email?: string;
message?: string;
// Champ honeypot : rempli = bot. Ne jamais loguer son contenu.
website?: string;
};
function getClientIp(req: NextRequest): string {
// Ordre de priorité classique derrière un reverse-proxy (Vercel, Nginx).
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xrip = req.headers.get("x-real-ip");
if (xrip) return xrip.trim();
// Fallback local : pas d'IP extractible (ex. `localhost`), on bucketise tout
// le trafic dans un même compartiment. Acceptable en dev.
return "unknown";
}
function checkRateLimit(ip: string): { ok: boolean; retryAfterMs: number } {
const now = Date.now();
const recent = (rateLimitStore.get(ip) ?? []).filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (recent.length >= RATE_LIMIT_MAX) {
const oldest = recent[0];
return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - oldest) };
}
recent.push(now);
rateLimitStore.set(ip, recent);
return { ok: true, retryAfterMs: 0 };
}
function escapeHtml(raw: string): string {
return raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function buildEmailContents(input: {
name: string;
email: string;
message: string;
ip: string;
}) {
const dateTime = new Date().toLocaleString("fr-FR", {
timeZone: "Europe/Paris",
});
const safeName = escapeHtml(input.name);
const safeEmail = escapeHtml(input.email);
const safeMessage = escapeHtml(input.message).replace(/\n/g, "<br>");
const subject = `Nouveau message portfolio — ${input.name}`;
const textContent = [
`Nouveau message reçu sur le formulaire de contact.`,
``,
`Nom : ${input.name}`,
`Email : ${input.email}`,
`Date : ${dateTime}`,
`IP : ${input.ip}`,
``,
`Message :`,
input.message,
``,
`---`,
`Répondre directement à ce mail renverra vers ${input.email}.`,
].join("\n");
const htmlContent = `
<!doctype html>
<html>
<body style="font-family: system-ui, -apple-system, Segoe UI, sans-serif; color: #191c1d; background: #f8fafa; margin: 0; padding: 24px;">
<div style="max-width: 560px; margin: 0 auto; background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 4px 12px rgba(25,28,29,0.06);">
<h1 style="font-size: 18px; font-weight: 700; color: #26445d; margin: 0 0 16px;">
Nouveau message portfolio
</h1>
<table role="presentation" style="width: 100%; font-size: 14px; line-height: 1.5;">
<tr>
<td style="color: #516169; padding: 4px 0; width: 80px;">Nom</td>
<td style="font-weight: 600;">${safeName}</td>
</tr>
<tr>
<td style="color: #516169; padding: 4px 0;">Email</td>
<td><a href="mailto:${safeEmail}" style="color: #26445d;">${safeEmail}</a></td>
</tr>
<tr>
<td style="color: #516169; padding: 4px 0;">Date</td>
<td>${dateTime}</td>
</tr>
<tr>
<td style="color: #516169; padding: 4px 0;">IP</td>
<td style="font-family: monospace; font-size: 12px; color: #42474d;">${escapeHtml(input.ip)}</td>
</tr>
</table>
<div style="margin-top: 20px; padding: 16px; background: #f2f4f4; border-radius: 12px;">
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.15em; color: #516169; margin-bottom: 8px;">Message</div>
<div style="font-size: 14px; line-height: 1.6; white-space: pre-wrap;">${safeMessage}</div>
</div>
<p style="margin-top: 20px; font-size: 12px; color: #73777d;">
Répondre directement à ce mail renverra vers <strong>${safeEmail}</strong>.
</p>
</div>
</body>
</html>
`.trim();
return { subject, textContent, htmlContent };
}
export async function POST(req: NextRequest) {
const apiKey = process.env.BREVO_API_KEY;
const fromEmail = process.env.CONTACT_FROM_EMAIL;
const fromName = process.env.CONTACT_FROM_NAME ?? "Portfolio — nouveau message";
const toEmail = process.env.CONTACT_TO_EMAIL;
const toName = process.env.CONTACT_TO_NAME ?? "Portfolio";
if (!apiKey || !fromEmail || !toEmail) {
console.error("[/api/contact] Configuration Brevo incomplète", {
hasKey: Boolean(apiKey),
hasFrom: Boolean(fromEmail),
hasTo: Boolean(toEmail),
});
return NextResponse.json(
{ ok: false, error: "SERVER_MISCONFIGURED" },
{ status: 500 }
);
}
let payload: ContactPayload;
try {
payload = (await req.json()) as ContactPayload;
} catch {
return NextResponse.json(
{ ok: false, error: "INVALID_JSON" },
{ status: 400 }
);
}
const name = (payload.name ?? "").trim();
const email = (payload.email ?? "").trim();
const message = (payload.message ?? "").trim();
const honeypot = (payload.website ?? "").trim();
// Honeypot : si un bot a rempli le champ caché, on simule un succès silencieux.
// Ne jamais renvoyer une erreur → ça donnerait aux bots un signal pour retry.
if (honeypot.length > 0) {
console.warn("[/api/contact] Honeypot déclenché, IP :", getClientIp(req));
return NextResponse.json({ ok: true }, { status: 200 });
}
if (!name || !email || !message) {
return NextResponse.json(
{ ok: false, error: "MISSING_FIELDS" },
{ status: 400 }
);
}
if (
name.length > MAX_NAME_LENGTH ||
email.length > MAX_EMAIL_LENGTH ||
message.length > MAX_MESSAGE_LENGTH
) {
return NextResponse.json(
{ ok: false, error: "TOO_LONG" },
{ status: 413 }
);
}
if (!EMAIL_REGEX.test(email)) {
return NextResponse.json(
{ ok: false, error: "INVALID_EMAIL" },
{ status: 400 }
);
}
const ip = getClientIp(req);
const rate = checkRateLimit(ip);
if (!rate.ok) {
return NextResponse.json(
{ ok: false, error: "RATE_LIMITED" },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rate.retryAfterMs / 1000)),
},
}
);
}
const { subject, textContent, htmlContent } = buildEmailContents({
name,
email,
message,
ip,
});
try {
const brevoRes = await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: {
"api-key": apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
sender: { name: fromName, email: fromEmail },
to: [{ email: toEmail, name: toName }],
// replyTo : clé de confort UX. Cliquer "Répondre" dans Gmail écrit
// directement au visiteur, sans aller-retour manuel pour recopier
// l'email depuis le corps du mail.
replyTo: { email, name },
subject,
htmlContent,
textContent,
tags: ["portfolio", "contact-form"],
}),
});
if (!brevoRes.ok) {
const text = await brevoRes.text();
console.error(
"[/api/contact] Brevo error",
brevoRes.status,
brevoRes.statusText,
text
);
return NextResponse.json(
{ ok: false, error: "BREVO_ERROR" },
{ status: 502 }
);
}
} catch (err) {
console.error("[/api/contact] Brevo fetch failed:", err);
return NextResponse.json(
{ ok: false, error: "BREVO_UNREACHABLE" },
{ status: 502 }
);
}
return NextResponse.json({ ok: true }, { status: 200 });
}

View File

@ -1,23 +1,33 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { sendMessage } from "../utils/sendMessage";
/** /**
* Formulaire de contact refonte "Digital Atelier" (étape 8). * Formulaire de contact refonte "Digital Atelier" (étape 8) + envoi via Brevo (étape 9).
* *
* - Plus de `bg-white shadow-lg rounded-lg` sur le form : il est désormais * Architecture :
* monté dans la carte vellum de `app/contact/page.js`. * Form POST /api/contact (Next.js server route, voir app/api/contact/route.ts)
* - Champs : `bg-surface-container-low`, radius `rounded-tile`, `focus-visible:ring-2 focus-visible:ring-primary`. *
* - CTA jewel : `bg-primary text-on-primary shadow-jewel` avec Material Symbol * Brevo API HTTP Gmail
* `send` + effet `-translate-y-0.5` au hover, état disabled en `bg-outline-variant/60`. *
* - Bandeau status Stitch : succès en `primary-fixed`, erreur en `error-container`, * Plus de passage par Strapi pour les messages : la route serveur valide,
* chargement en `surface-container`. Chaque état porte une Material Symbol. * filtre (honeypot + rate-limit), puis envoie une notification email. Voir
* `docs-site-interne/contact-flow.md` pour l'architecture détaillée.
*
* Anti-spam :
* - Champ honeypot `website` caché (sr-only + tabindex=-1). Les bots le
* remplissent systématiquement, les humains non. Côté serveur, un champ
* rempli succès silencieux (aucun email envoyé).
* - Rate-limit côté serveur : 3 envois / 10 min / IP (voir route.ts).
* - Validation longueur + format email côté serveur en plus du client.
*/ */
export default function ContactForm() { export default function ContactForm() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
// Honeypot : doit rester vide. S'il est rempli, on soumet quand même pour ne
// rien changer côté UX (le serveur ignore silencieusement ces payloads).
const [website, setWebsite] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [statusKind, setStatusKind] = useState< const [statusKind, setStatusKind] = useState<
"idle" | "loading" | "success" | "error" "idle" | "loading" | "success" | "error"
@ -44,24 +54,55 @@ export default function ContactForm() {
setStatusKind("loading"); setStatusKind("loading");
try { try {
await sendMessage(name, email, message); const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message, website }),
});
if (res.status === 429) {
setStatus(
"Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
);
setStatusKind("error");
return;
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as {
error?: string;
};
const errorCode = data.error ?? "UNKNOWN";
setStatus(
errorCode === "INVALID_EMAIL"
? "Email invalide."
: errorCode === "MISSING_FIELDS"
? "Tous les champs sont obligatoires."
: errorCode === "TOO_LONG"
? "Message trop long."
: "Erreur lors de l'envoi du message."
);
setStatusKind("error");
return;
}
setStatus("Message envoyé. Merci, je reviens vers vous rapidement."); setStatus("Message envoyé. Merci, je reviens vers vous rapidement.");
setStatusKind("success"); setStatusKind("success");
setName(""); setName("");
setEmail(""); setEmail("");
setMessage(""); setMessage("");
setWebsite("");
} catch (error) { } catch (error) {
setStatus("Erreur lors de l'envoi du message."); console.error("[ContactForm] submit failed:", error);
setStatus("Erreur réseau. Vérifiez votre connexion et réessayez.");
setStatusKind("error"); setStatusKind("error");
} }
}; };
const statusStyles: Record<typeof statusKind, string> = { const statusStyles: Record<typeof statusKind, string> = {
idle: "", idle: "",
loading: loading: "bg-surface-container text-on-surface-variant",
"bg-surface-container text-on-surface-variant", success: "bg-primary-fixed/70 text-on-primary-fixed",
success:
"bg-primary-fixed/70 text-on-primary-fixed",
error: "bg-error-container text-on-error-container", error: "bg-error-container text-on-error-container",
}; };
@ -77,6 +118,32 @@ export default function ContactForm() {
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate> <form onSubmit={handleSubmit} className="flex flex-col gap-3" noValidate>
{/* Honeypot : caché visuellement ET aux lecteurs d'écran. tabindex=-1 pour
qu'un utilisateur clavier ne tombe jamais dessus. Les bots qui
parsent le DOM le remplissent quasi-systématiquement. */}
<div
aria-hidden="true"
style={{
position: "absolute",
left: "-10000px",
width: "1px",
height: "1px",
overflow: "hidden",
}}
>
<label>
Ne pas remplir ce champ
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</label>
</div>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary"> <span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Votre nom Votre nom
@ -88,6 +155,7 @@ export default function ContactForm() {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className={fieldClass} className={fieldClass}
required required
maxLength={120}
autoComplete="name" autoComplete="name"
/> />
</label> </label>
@ -103,6 +171,7 @@ export default function ContactForm() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className={fieldClass} className={fieldClass}
required required
maxLength={160}
autoComplete="email" autoComplete="email"
/> />
</label> </label>
@ -116,6 +185,7 @@ export default function ContactForm() {
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
rows={5} rows={5}
maxLength={5000}
className={`${fieldClass} min-h-[9rem] resize-y`} className={`${fieldClass} min-h-[9rem] resize-y`}
required required
/> />

View File

@ -1,35 +0,0 @@
import { getApiUrl } from "./getApiUrl";
export async function sendMessage(name: string, email: string, message: string) {
const dateTime = new Date().toLocaleString("fr-FR", { timeZone: "Europe/Paris" });
const messageWithDate = `${message}\n\n📅 Envoyé le : ${dateTime}`;
console.log("📨 Envoi du message...", { name, email, messageWithDate });
const apiUrl = getApiUrl();
const res = await fetch(`${apiUrl}/api/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
name: name,
email: email,
message: messageWithDate,
},
}),
});
const responseData = await res.json();
if (!res.ok) {
console.error("❌ Erreur API Strapi :", responseData);
throw new Error(`Échec de l'envoi du message: ${responseData.error.message}`);
}
console.log("✅ Message envoyé avec succès !", responseData);
return responseData;
}

View File

@ -17,8 +17,8 @@
| `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) | | `/portfolio/[slug]` | `app/portfolio/[slug]/page.tsx` | Détail projet (`fetchData('projects', slug)`) |
| `/competences` | `app/competences/page.jsx` | Liste compétences | | `/competences` | `app/competences/page.jsx` | Liste compétences |
| `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) | | `/competences/[slug]` | `app/competences/[slug]/page.tsx` | Détail (`fetchData('competences', slug)`) |
| `/contact` | `app/contact/page.js` | Formulaire → Strapi `messages` | | `/contact` | `app/contact/page.js` | Formulaire → `/api/contact` (Brevo, voir `contact-flow.md`) |
| `/admin/messages` | `app/admin/messages/page.tsx` | Consultation messages (côté Next) | | `/api/contact` | `app/api/contact/route.ts` | Endpoint serveur : envoie un email via Brevo, honeypot + rate-limit |
| `/api/proxy` | `app/api/proxy/route.js` | Proxy GET vers API LLM distante | | `/api/proxy` | `app/api/proxy/route.js` | Proxy GET vers API LLM distante |
## Layout ## Layout
@ -42,7 +42,7 @@
- Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel). - Carrousels : `Carousel.tsx`, `CarouselCompetences.tsx` (swiper / react-responsive-carousel).
- Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`. - Sections : `ContentSection.tsx`, `ContentSectionCompetences*.tsx`.
- `ContactForm.tsx``sendMessage.ts`. - `ContactForm.tsx``POST /api/contact` → Brevo API (voir `docs-site-interne/contact-flow.md`).
- `ChatBot.js``askAI.js``/api/proxy`. - `ChatBot.js``askAI.js``/api/proxy`.
- `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages). - `ModalGlossaire.tsx` — glossaire (données Strapi selon usage dans les pages).
@ -53,6 +53,6 @@ app/layout.tsx
app/page.tsx app/page.tsx
app/utils/getApiUrl.ts app/utils/getApiUrl.ts
app/utils/fetchData.ts app/utils/fetchData.ts
app/utils/sendMessage.ts app/api/contact/route.ts
next.config.ts next.config.ts
``` ```

View File

@ -43,15 +43,9 @@ Utilisation front : `app/page.tsx` — premier enregistrement `populate=*`, imag
| `slug` | uid ← `name` | requis | | `slug` | uid ← `name` | requis |
| `order` | integer | optionnel | | `order` | integer | optionnel |
### `message` (collection `messages`) ### `message` (supprimé le 2026-04-23)
| Champ | Type | Notes | Ancien content-type pour stocker les soumissions du formulaire de contact. Supprimé car le formulaire envoie désormais une notification email via **Brevo** (voir `docs-site-interne/contact-flow.md`) — plus besoin de stockage Strapi. Les 4 fichiers `cmsbackend/src/api/message/**` ont été supprimés ; la table SQLite `messages` reste orpheline (inoffensive, peut être droppée manuellement).
|-------|------|--------|
| `name` | string | requis |
| `email` | email | requis |
| `message` | text | requis |
Création via **POST** `/api/messages` depuis `sendMessage.ts` (permissions Strapi **create** publique à valider en prod).
### `glossaire` (collection `glossaires`) ### `glossaire` (collection `glossaires`)

View File

@ -361,6 +361,51 @@ Après : **carte vellum légère** centrée, sans ombre ambient (le footer ne do
- **Validation serveur des champs du form** (anti-spam, honeypot, reCAPTCHA) : hors scope refonte visuelle. Strapi ne filtre pour l'instant que sur la structure JSON attendue. - **Validation serveur des champs du form** (anti-spam, honeypot, reCAPTCHA) : hors scope refonte visuelle. Strapi ne filtre pour l'instant que sur la structure JSON attendue.
- **Fusion Carousel.tsx / CarouselCompetences.tsx** : reste en dette technique (déjà noté §7). - **Fusion Carousel.tsx / CarouselCompetences.tsx** : reste en dette technique (déjà noté §7).
## 9. Post-refonte — Contact effectif via Brevo (2026-04-23)
La refonte visuelle a laissé le formulaire de contact en état "joli mais non fonctionnel" : il stockait les messages dans Strapi (content-type `message`), à consulter via `/admin/messages` (page publique, non protégée). Aucune notification.
**Décision** (discutée avec l'utilisateur) : supprimer le passage par Strapi, passer à une **notification email directe** via l'**API HTTP Brevo** (compte existant pour la newsletter). Plus simple, moins de surface d'attaque, notification immédiate.
### Changements
- **Nouveau** : `app/api/contact/route.ts` (Next.js App Router, runtime Node) — reçoit le form, valide, applique honeypot + rate-limit, appelle `POST https://api.brevo.com/v3/smtp/email`, retourne `{ ok: true | false, error }`.
- **Modifié** : `app/components/ContactForm.tsx` — appel vers `/api/contact` au lieu de `sendMessage(...)`. Ajout d'un **champ honeypot** `website` caché (position absolue hors écran + `aria-hidden` + `tabindex=-1`). Codes d'erreur serveur mappés en messages FR.
- **Supprimé** : `app/utils/sendMessage.ts` (plus utilisé), `app/admin/messages/page.tsx` (plus de consultation Strapi nécessaire, et cette page était exposée publiquement sans auth).
- **Supprimé côté Strapi** : `cmsbackend/src/api/message/` (content-type + routes + services + controllers). La table SQLite `messages` est laissée en place (orpheline, inoffensive).
- **Nouveau** : `.env.example` en racine pour documenter les variables requises, `docs-site-interne/contact-flow.md` pour l'architecture complète.
### Variables d'env (.env.local)
```env
BREVO_API_KEY=xkeysib-...
CONTACT_FROM_EMAIL=<expéditeur vérifié Brevo>
CONTACT_FROM_NAME=Portfolio — nouveau message
CONTACT_TO_EMAIL=grascalvet.fernand@gmail.com
CONTACT_TO_NAME=Fernand Gras-Calvet
```
### Anti-abus
- **Honeypot** : champ caché `website`. Si rempli → 200 silencieux + log warning (pas d'erreur pour ne pas signaler aux bots).
- **Rate-limit** : 3 envois / IP / 10 min (Map en mémoire). Limité en serverless multi-instance — acceptable pour un portfolio.
- **Validation serveur** : longueurs (120 / 160 / 5000), regex email, codes d'erreur en `UPPER_SNAKE_CASE` stables.
### Email reçu
- Sujet : `Nouveau message portfolio — {Nom}`
- Expéditeur : `CONTACT_FROM_NAME <CONTACT_FROM_EMAIL>`
- Reply-To : email du visiteur (un clic "Répondre" dans Gmail et le mail part au visiteur).
- Corps HTML : carte Stitch simplifiée (primary/secondary/surface-container-low) avec table nom/email/date/IP + zone message `pre-wrap`.
### Leçons retenues
1. **Surveiller les pages admin "dev"** qui fuitent en prod : `/admin/messages` listait les emails de tous les visiteurs en clair, sans auth. Avec la page supprimée, plus d'exposition.
2. **Clé API fuitant en clair dans un chat** : considérer comme compromise et régénérer. Documenté dans `contact-flow.md` § "Sécurité — rappels".
3. **Pour un besoin "1 seul destinataire" avec faible volume**, une route Next + API transactionnelle bat clairement une solution SMTP + plugin Strapi : moins de pièces, moins de config, même garantie de délivrabilité.
Voir `docs-site-interne/contact-flow.md` pour la procédure de test, les codes d'erreur, et le rollback éventuel.
## 5. Checklist relecture (à passer à la fin de chaque étape) ## 5. Checklist relecture (à passer à la fin de chaque étape)
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter). - [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).

View File

@ -104,12 +104,13 @@ Les captures suivantes **nont pas révélé de problème spécifique** après
| 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `fait` (étape 8, 2026-04-22) | | 18 | Contact formulaire mobile | `/contact` | `18-contact-formulaire-mobile.webp` | `fait` (étape 8, 2026-04-22) |
| 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `fait` (étape 8, 2026-04-22) | | 19 | Footer desktop | `/` | `19-layout-footer-desktop.webp` | `fait` (étape 8, 2026-04-22) |
| 20 | Compteur visites desktop | `/` | `20-layout-compteur-visites-desktop.webp` | `OK` | | 20 | Compteur visites desktop | `/` | `20-layout-compteur-visites-desktop.webp` | `OK` |
| 21 | Admin messages desktop | `/admin/messages` | `21-admin-messages-desktop.webp` | `OK` | | 21 | ~~Admin messages desktop~~ | ~~`/admin/messages`~~ | `21-admin-messages-desktop.webp` | `obsolète` (route supprimée le 2026-04-23, voir `contact-flow.md`) |
--- ---
## Suite ## Suite
- **Étape 8 Digital Atelier bouclée** (2026-04-22) : contact + formulaire + footer migrés à la charte Stitch (voir `docs-site-interne/REFONTE-VISUELLE.md §8`). - **Étape 8 Digital Atelier bouclée** (2026-04-22) : contact + formulaire + footer migrés à la charte Stitch (voir `docs-site-interne/REFONTE-VISUELLE.md §8`).
- Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`). - **Formulaire de contact rendu effectif** (2026-04-23) : envoi via **Brevo** au lieu de Strapi. Content-type `message` supprimé, page `/admin/messages` supprimée, honeypot + rate-limit ajoutés. Voir `docs-site-interne/contact-flow.md` et `REFONTE-VISUELLE.md §9`.
- Dette technique identifiée pour une future passe : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites, validation anti-spam du formulaire. - Prendre de **nouvelles captures** 17 / 18 / 19 pour figer le rendu post-refonte (remplacement des WebP existants dans `docs-site-interne/captures/`). La capture 21 est désormais **obsolète** (route supprimée).
- Dette technique identifiée : fusion `Carousel.tsx` / `CarouselCompetences.tsx` (doublons), persistance serveur du compteur de visites. Anti-spam formulaire : **fait** (honeypot + rate-limit, 2026-04-23).

View File

@ -0,0 +1,186 @@
# Flux "Contact" — notification par email (Brevo)
**Créé :** 2026-04-23
**Statut :** en production
## Vue d'ensemble
Le formulaire de contact du site envoie un **email de notification** sur la boîte Gmail personnelle (`grascalvet.fernand@gmail.com`), via l'**API HTTP transactionnelle de Brevo**.
**Plus aucun passage par Strapi pour les messages.** Le content-type `message` a été supprimé (ainsi que la page admin associée) le 2026-04-23. La table SQLite `messages` reste présente en base pour l'historique, mais n'est plus écrite ni lue.
```
Visiteur → /contact (Next.js front)
POST /api/contact (Next.js App Router, server runtime)
Validation + honeypot + rate-limit
POST https://api.brevo.com/v3/smtp/email
Gmail : CONTACT_TO_EMAIL
```
## Pourquoi cette architecture
### Simplification par rapport à l'ancien flux
Avant : `Front → Strapi POST /api/messages → SQLite` + consultation via `/admin/messages` (page publique, non protégée).
Problèmes :
- **Pas de notification** : il fallait penser à consulter l'admin pour voir les nouveaux messages.
- **Page admin exposée** : `/admin/messages` accessible à n'importe qui en clair (pas d'authentification). Fuite potentielle d'emails privés des visiteurs.
- **Content-type dédié** pour un usage limité (3 champs, pas d'historique utile).
Après : notification directe sur Gmail, zéro stockage intermédiaire, zéro page à sécuriser.
### Pourquoi l'API HTTP Brevo plutôt que SMTP
Discussion menée le 2026-04-23. Résumé :
| Critère | SMTP | API HTTP |
|---------|------|----------|
| Simplicité plugin Strapi | ✓ | (custom) |
| Port potentiellement bloqué | oui | non (HTTPS 443) |
| Débogage JSON | non | ✓ |
| Notre besoin (1 endroit, 1 mail) | ok | ok |
Comme on ne passe plus par Strapi, l'avantage "plugin Strapi" disparaît. On a pris **l'API HTTP** pour rester 100 % en HTTPS (aucun souci de port) et avoir des erreurs JSON lisibles.
## Fichiers concernés
| Fichier | Rôle |
|---------|------|
| `app/api/contact/route.ts` | Endpoint serveur Next.js qui reçoit le form et appelle Brevo |
| `app/components/ContactForm.tsx` | UI côté client, honeypot, gestion du status |
| `.env.local` | Secrets + config (non committé) |
| `.env.example` | Template documentaire des variables à remplir |
## Variables d'environnement
À définir dans `.env.local` (voir `.env.example` pour la structure) :
| Variable | Description | Où l'obtenir |
|----------|-------------|--------------|
| `BREVO_API_KEY` | Clé API Brevo | [app.brevo.com → SMTP & API → API Keys](https://app.brevo.com/settings/keys/api) |
| `CONTACT_FROM_EMAIL` | Expéditeur **vérifié** | Senders, Domains & IPs → Senders (pastille SPF/DKIM verte) |
| `CONTACT_FROM_NAME` | Nom affiché dans Gmail | Texte libre, ex. "Portfolio — nouveau message" |
| `CONTACT_TO_EMAIL` | Destinataire final | L'adresse Gmail personnelle |
| `CONTACT_TO_NAME` | Nom du destinataire | Texte libre |
**L'expéditeur doit être vérifié dans Brevo** : sinon Brevo renvoie une erreur au `POST`. On peut réutiliser l'expéditeur newsletter (pas de cloisonnement côté Brevo) et surcharger le nom d'affichage via `CONTACT_FROM_NAME`.
## Anti-abus
### Honeypot
Un champ caché `website` est ajouté au formulaire :
- Invisible aux humains (`position: absolute; left: -10000px; width: 1px`).
- Masqué aux lecteurs d'écran (`aria-hidden="true"`).
- Ignoré par le clavier (`tabindex="-1"`, `autocomplete="off"`).
Les bots qui parsent le DOM remplissent systématiquement tous les champs texte. Côté serveur, si `website` est non vide :
- On **renvoie un 200 silencieux** (pas de signal d'erreur aux bots → ils ne retentent pas).
- On **n'envoie pas d'email**.
- On **logue** un warning avec l'IP pour monitoring.
### Rate-limit
`app/api/contact/route.ts` limite à **3 envois par IP par fenêtre de 10 minutes**, via une `Map` en mémoire :
```ts
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
```
Au-delà : réponse `429 Too Many Requests` avec header `Retry-After`.
**Limite connue** : en déploiement serverless avec plusieurs instances (Vercel free, Cloudflare Workers), la `Map` n'est pas partagée — le rate-limit est "best effort". Pour un portfolio, c'est acceptable. Pour du vrai ship : passer à Redis ou [Upstash](https://upstash.com/) (wrapper compatible serverless).
### Validation
Côté serveur (défense en profondeur, jamais faire confiance au client) :
- Champs requis non vides : `name`, `email`, `message`.
- Longueurs max : `name ≤ 120`, `email ≤ 160`, `message ≤ 5000` caractères (retourne `413`).
- Format email : regex `^[^\s@]+@[^\s@]+\.[^\s@]+$` (pragmatique, pas RFC 5322 stricte).
Les codes d'erreur renvoyés au client sont en `UPPER_SNAKE_CASE` (stables pour l'i18n future) : `MISSING_FIELDS`, `INVALID_EMAIL`, `TOO_LONG`, `RATE_LIMITED`, `BREVO_ERROR`, `BREVO_UNREACHABLE`, `SERVER_MISCONFIGURED`, `INVALID_JSON`.
## Format de l'email reçu
- **Sujet** : `Nouveau message portfolio — {Nom du visiteur}`
- **Expéditeur** : `{CONTACT_FROM_NAME} <{CONTACT_FROM_EMAIL}>`
- **Reply-To** : email du visiteur → cliquer "Répondre" dans Gmail écrit directement au visiteur.
- **Corps HTML** : carte Stitch simplifiée (palette primary / secondary) avec table récap (nom, email, date, IP) + zone message `pre-wrap`.
- **Corps texte** (fallback) : version plain text équivalente.
- **Tags Brevo** : `["portfolio", "contact-form"]` pour filtrer dans les statistiques Brevo si besoin.
## Procédure de test
### Local (développement)
1. S'assurer que `.env.local` contient les 5 variables requises + une clé Brevo valide.
2. `npm run dev` dans le repo Next (port 3000).
3. `/contact` → remplir le formulaire avec une **adresse email qu'on contrôle** (pour vérifier le Reply-To).
4. Vérifier :
- Feedback visuel "Message envoyé" en succès Stitch vert.
- Réception dans Gmail avec expéditeur = `CONTACT_FROM_NAME`.
- Clic "Répondre" → destinataire = l'email saisi dans le formulaire.
### Honeypot
1. Ouvrir la console DevTools, onglet Elements.
2. Trouver l'input caché `<input name="website">` (dans un div `position: absolute; left: -10000px`).
3. Y saisir une valeur (ex. "bot").
4. Soumettre le formulaire normalement.
5. Résultat attendu : UI affiche "Message envoyé" (succès silencieux) MAIS **aucun email n'arrive** dans Gmail.
6. Vérifier le warning serveur dans les logs Next (`[/api/contact] Honeypot déclenché`).
### Rate-limit
1. Envoyer un formulaire valide 4 fois de suite (modifier juste le message à chaque fois).
2. Au 4ᵉ envoi : réponse `429`, UI affiche "Trop d'envois depuis votre IP. Réessayez dans quelques minutes."
3. Attendre 10 min puis réessayer → doit repasser.
## Nettoyage de l'ancienne table SQLite (optionnel)
La table `messages` existe toujours dans `cmsbackend/.tmp/data.db` (ou le fichier configuré). Strapi ne la touche plus. Pour la supprimer proprement :
```bash
# Arrêter Strapi d'abord !
cd cmsbackend
sqlite3 .tmp/data.db "DROP TABLE IF EXISTS messages;"
sqlite3 .tmp/data.db "DROP TABLE IF EXISTS messages_cmps;"
# Reprise normale
npm run develop
```
Non fait par défaut : la table reste orpheline mais inoffensive. Ça évite toute régression en cas de rollback éventuel.
## Quotas Brevo
Le **plan gratuit Brevo** inclut traditionnellement **300 emails transactionnels par jour**, partagés avec la newsletter. Pour un portfolio qui reçoit 0 à 5 messages par semaine, c'est largement suffisant.
Si le site reçoit un jour beaucoup plus de trafic (ou si Brevo change ses quotas gratuits), surveiller :
- **Brevo → Transactional → Statistics** : nombre d'envois, taux d'erreur.
- **Brevo → SMTP & API → Statistics** : quota quotidien consommé.
## Rollback
Si le flux Brevo tombe en panne (compte suspendu, quota dépassé, clé compromise) et qu'on veut **temporairement** retomber sur le flux Strapi ancien :
1. `git checkout <commit-pre-refonte-contact>` sur les fichiers : `app/api/contact/route.ts` (supprimer), `app/components/ContactForm.tsx` (restaurer `sendMessage`), `app/utils/sendMessage.ts` (restaurer), `cmsbackend/src/api/message/` (restaurer les 4 fichiers), et `app/admin/messages/page.tsx` si on veut la page de consultation.
2. Re-démarrer Strapi pour qu'il régénère le content-type en mémoire.
3. Vérifier les **permissions publiques** sur `POST /api/messages` dans l'admin Strapi (peut avoir besoin d'être recoché).
Préférable : **renouveler la clé Brevo** plutôt que rollback.
## Sécurité — rappels
- **Ne jamais committer `.env.local`**. Déjà dans `.gitignore` (`.env*`).
- **Si la clé Brevo fuit** (exposée dans un commit, un chat, un screenshot) : la **supprimer immédiatement** sur Brevo et en générer une nouvelle. Une clé compromise permet d'envoyer des emails frauduleux depuis l'expéditeur vérifié, ce qui peut faire blacklister le domaine.
- L'**IP du visiteur** est loguée dans l'email (pour abus). À supprimer des emails si on veut un template plus "commercial".

View File

@ -1,24 +1,104 @@
# Configuration des variables d'environnement # Script de démarrage du portfolio — lance les 3 services dans des fenêtres PowerShell séparées.
$env:NEXT_PUBLIC_API_URL = "https://api.fernandgrascalvet.com" #
$env:PUBLIC_URL = "https://api.fernandgrascalvet.com" # Pendant inverse : stop-my-site.ps1 (arrête proprement les services par port).
#
# Services lancés :
# - Strapi (CMS) → port 1337 → cmsbackend/ (npm run develop)
# - Next.js (site) → port 3000 → racine (npm run dev)
# - FastAPI (GrasBot) → port 8000 → llm-api/ (uvicorn api:app)
#
# Variables d'environnement propagées aux sous-shells :
# - NEXT_PUBLIC_API_URL / PUBLIC_URL = https://api.fernandgrascalvet.com
#
# Vérifications automatiques :
# - Si un port est déjà occupé, on ne relance pas le service concerné
# (évite l'erreur "address already in use" et le doublon de fenêtre).
# - Si Start-Process échoue, on continue avec les autres services et on
# consigne l'échec dans le bilan final.
#
# Portabilité :
# - $PSScriptRoot résout la racine du repo à partir du script → pas de chemin
# codé en dur. Le script marche donc même si le repo est cloné ailleurs.
#
# Usage :
# PS > .\start-my-site.ps1
Write-Host "🚀 Démarrage du site avec configuration HTTPS..." -ForegroundColor Green $root = $PSScriptRoot
Write-Host "📡 API URL: $env:NEXT_PUBLIC_API_URL" -ForegroundColor Cyan $apiUrl = "https://api.fernandgrascalvet.com"
# Lancer Strapi avec configuration HTTPS $services = @(
Write-Host "🔧 Démarrage de Strapi..." -ForegroundColor Yellow @{
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\cmsbackend'; `$env:PUBLIC_URL='https://api.fernandgrascalvet.com'; npm run develop" -WindowStyle Normal Nom = "Strapi (CMS)"
Port = 1337
Cwd = Join-Path $root "cmsbackend"
Commande = "`$env:PUBLIC_URL='$apiUrl'; npm run develop"
},
@{
Nom = "Next.js (site)"
Port = 3000
Cwd = $root
Commande = "`$env:NEXT_PUBLIC_API_URL='$apiUrl'; npm run dev"
},
@{
Nom = "FastAPI (GrasBot)"
Port = 8000
Cwd = Join-Path $root "llm-api"
Commande = "uvicorn api:app --host 0.0.0.0 --port 8000"
}
)
# Attendre un peu pour que Strapi démarre Write-Host "🚀 Démarrage des services du portfolio..." -ForegroundColor Green
Start-Sleep -Seconds 3 Write-Host " API URL : $apiUrl" -ForegroundColor Cyan
Write-Host ""
# Lancer Next.js $lances = 0
Write-Host "⚡ Démarrage de Next.js..." -ForegroundColor Yellow $dejaActifs = 0
Start-Process powershell -ArgumentList "cd 'J:\my-next-site'; `$env:NEXT_PUBLIC_API_URL='https://api.fernandgrascalvet.com'; npm run dev" -WindowStyle Normal $echecs = 0
# Lancer FastAPI foreach ($s in $services) {
Write-Host "🤖 Démarrage de FastAPI..." -ForegroundColor Yellow # Si le port est déjà écouté, le service tourne sans doute déjà — on saute.
Start-Process powershell -ArgumentList "cd 'J:\my-next-site\llm-api'; uvicorn api:app --host 0.0.0.0 --port 8000" -WindowStyle Normal $existing = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
if ($existing) {
Write-Host "$($s.Nom) — port $($s.Port) déjà occupé, pas de relance" -ForegroundColor Gray
$dejaActifs++
continue
}
Write-Host "✅ Tous les services sont en cours de démarrage!" -ForegroundColor Green try {
Write-Host " 🔵 $($s.Nom) — port $($s.Port)$($s.Cwd)" -ForegroundColor Cyan
# Construction de la commande passée au sous-shell : on se place dans
# le bon répertoire, puis on lance la commande du service. -NoExit
# laisse la fenêtre ouverte même si la commande se termine (utile pour
# voir le message d'erreur si un service crashe au démarrage).
$fullCommand = "Set-Location -LiteralPath '$($s.Cwd)'; $($s.Commande)"
Start-Process powershell `
-ArgumentList @('-NoExit', '-Command', $fullCommand) `
-WindowStyle Normal `
-ErrorAction Stop
Write-Host " ✅ lancé" -ForegroundColor Green
$lances++
# Petite respiration entre les services pour étaler la charge CPU au
# boot (Strapi et Next sont tous deux gourmands au premier démarrage).
Start-Sleep -Milliseconds 800
} catch {
Write-Host " ⚠️ impossible de lancer : $($_.Exception.Message)" -ForegroundColor Red
$echecs++
}
}
Write-Host ""
Write-Host "── Bilan ─────────────────────────────" -ForegroundColor Cyan
Write-Host " Services lancés : $lances" -ForegroundColor Green
Write-Host " Déjà actifs : $dejaActifs" -ForegroundColor Gray
if ($echecs -gt 0) {
Write-Host " Échecs : $echecs" -ForegroundColor Red
}
if ($lances -gt 0 -or $dejaActifs -ge $services.Count) {
Write-Host ""
Write-Host "🌐 Site accessible sur : http://localhost:3000" -ForegroundColor Cyan Write-Host "🌐 Site accessible sur : http://localhost:3000" -ForegroundColor Cyan
Write-Host " (attendre ~15 s que Strapi et Next soient prêts au premier lancement)" -ForegroundColor Gray
}

72
stop-my-site.ps1 Normal file
View File

@ -0,0 +1,72 @@
# Script inverse de start-my-site.ps1 — arrête proprement les 3 services du portfolio.
#
# Stratégie : on identifie le processus qui écoute chaque port attendu, puis on
# le termine. C'est plus sûr que `Stop-Process -Name node` qui tuerait TOUS les
# node.exe de la machine (y compris d'autres projets que tu aurais ouverts).
#
# Ports surveillés :
# - 1337 → Strapi (CMS)
# - 3000 → Next.js (site)
# - 8000 → FastAPI / uvicorn (GrasBot LLM)
#
# Ce script ne ferme PAS les fenêtres PowerShell elles-mêmes : elles restent
# ouvertes avec un prompt une fois le processus enfant terminé. Tu peux les
# fermer manuellement ou ajouter un alias "exit" dans ton profil PowerShell.
#
# Usage :
# PS > .\stop-my-site.ps1
$services = @(
@{ Nom = "Strapi (CMS)"; Port = 1337 },
@{ Nom = "Next.js (site)"; Port = 3000 },
@{ Nom = "FastAPI (GrasBot)"; Port = 8000 }
)
Write-Host "🛑 Arrêt des services du portfolio..." -ForegroundColor Yellow
Write-Host ""
$arretes = 0
$nonTrouves = 0
$echecs = 0
foreach ($s in $services) {
# -ErrorAction SilentlyContinue : Get-NetTCPConnection lève une erreur
# bruyante si aucun processus n'écoute le port. On préfère gérer nous-mêmes.
$conn = Get-NetTCPConnection -LocalPort $s.Port -State Listen -ErrorAction SilentlyContinue
if ($null -eq $conn) {
Write-Host "$($s.Nom) — port $($s.Port) libre, rien à arrêter" -ForegroundColor Gray
$nonTrouves++
continue
}
# Un même processus peut exposer le port en IPv4 et IPv6 (2 lignes, même PID).
$procIds = $conn | Select-Object -ExpandProperty OwningProcess -Unique
foreach ($procId in $procIds) {
try {
$proc = Get-Process -Id $procId -ErrorAction Stop
Write-Host " 🔴 $($s.Nom) — port $($s.Port) → PID $procId ($($proc.ProcessName))" -ForegroundColor Red
Stop-Process -Id $procId -Force -ErrorAction Stop
Write-Host " ✅ arrêté" -ForegroundColor Green
$arretes++
} catch {
Write-Host " ⚠️ impossible d'arrêter le PID $procId : $($_.Exception.Message)" -ForegroundColor Yellow
$echecs++
}
}
}
Write-Host ""
Write-Host "── Bilan ─────────────────────────────" -ForegroundColor Cyan
Write-Host " Services arrêtés : $arretes" -ForegroundColor Green
Write-Host " Déjà libres : $nonTrouves" -ForegroundColor Gray
if ($echecs -gt 0) {
Write-Host " Échecs : $echecs" -ForegroundColor Red
Write-Host ""
Write-Host "💡 Échecs possibles : droits administrateur requis pour tuer certains processus." -ForegroundColor Yellow
Write-Host " Relance le script dans un PowerShell admin si besoin." -ForegroundColor Yellow
} else {
Write-Host ""
Write-Host "✅ Tous les services ciblés ont été traités." -ForegroundColor Green
}