Chapitre 1635 minIntermédiaire
React à l'E6 : bonnes pratiques et exercices corrigés
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- traiter les évolutions qui reviennent sur un front React à l'E6 ;
- répondre aux questions d'entretien sur tes choix techniques ;
- vérifier ton projet avec une liste de bonnes pratiques ;
- faire tourner le front avec l'API de démonstration.
5 exercices corrigés
Ce dernier chapitre rassemble ce qui sert le jour de l'épreuve. À l'E6 SLAM, une application React est souvent le front d'une de tes deux réalisations : la commission te demande de la faire évoluer en une heure, puis t'interroge sur tes choix. Tu y trouveras les évolutions qui reviennent sur un front React et la façon de les traiter, les questions d'entretien fréquentes avec des éléments de réponse, une liste de bonnes pratiques à vérifier, des exercices corrigés de synthèse, et l'API de démonstration utilisée dans tout le cours.
React dans ta réalisation E6
Le déroulement de l'épreuve (30 minutes d'analyse, 20 d'entretien, une heure de réalisation, 20 de recette) est détaillé dans le cours Épreuve E6 SLAM. Un front React y apporte de bons arguments pour plusieurs compétences du bloc 2 :
| Compétence (intitulé abrégé) | Ce que ton front React peut montrer |
|---|---|
| Échanges entre applications, Web et mobile | Le front consomme une API REST en JSON ; tu sais expliquer une requête dans l'onglet Réseau |
| Développer ou adapter des composants | Des composants réutilisables (BadgeEtat, Champ), des hooks personnalisés |
| Exploiter le framework | React Router, TanStack Query, les Actions de React 19 utilisés au lieu d'être réécrits |
| Tests de validation et de non-régression | Des tests Vitest et Testing Library qui passent, dont un qui reproduit le bogue corrigé |
| Intégrer en continu | Une chaîne GitHub Actions qui lance lint, tests et build à chaque push |
| Documentation | Un README : installation, variables d'environnement, lancement de l'API et du front |
Prépare un environnement qui démarre du premier coup : npm install déjà fait, l'API et le front lancés en deux commandes, un jeu de données de démonstration, et un plan B (une version déployée, une vidéo) si la machine de l'épreuve pose problème (un environnement qui démarre le jour de l'épreuve).
Les évolutions qui reviennent sur un front React
L'expression des besoins porte sur ton application ; voici les formes qu'elle prend le plus souvent côté front, et la démarche pour chacune.
| Évolution demandée | Démarche | Fichiers touchés |
|---|---|---|
| Afficher une nouvelle information (un total, une date, un statut) | Vérifier que l'API la renvoie (sinon, la faire évoluer d'abord) ; compléter le type ; l'afficher, calculée si elle se déduit | types.ts, un composant |
| Ajouter un champ à un formulaire | Champ avec name et étiquette ; lecture dans l'Action ; validation côté client et côté API ; type NouveauFrais | Formulaire, types.ts, l'API |
| Ajouter un filtre ou un tri | Paramètre dans l'URL (useSearchParams) ; filtrage par l'API (paramètre de requête) ou à l'affichage ; le paramètre dans la clé de requête | Page, hook de données, module d'appels |
| Ajouter un écran | Une route, une page, un lien de navigation ; route protégée si besoin | router.tsx, pages/, Layout.tsx |
| Réserver une action à un rôle | Masquer le bouton selon utilisateur.role et vérifier le rôle dans l'API (403) | Composant, API |
| Corriger un bogue | Le reproduire, écrire un test qui échoue, corriger, vérifier que le test passe | Le composant, son test |
Le cours Épreuve E6 SLAM déroule ces évolutions en détail, dont une application web Node.js et React prise comme exemple.
Les questions d'entretien fréquentes
« Pourquoi React plutôt que du JavaScript sans bibliothèque ? » Parce que l'interface est décrite à partir des données : quand les données changent, React met la page à jour, sans manipulation manuelle du DOM. Le code est découpé en composants réutilisables et testables, et React encode ce qu'il affiche, ce qui protège des injections XSS.
« Quelle différence entre props et state ? » Les props sont les données qu'un composant reçoit de son parent ; il ne les modifie pas. L'état est la mémoire propre du composant, qu'il modifie avec sa fonction set…, ce qui provoque un nouveau rendu. L'état d'un parent devient souvent la prop d'un enfant (chapitres 3 et 4).
« Comment ton front récupère-t-il les données ? » Par des requêtes HTTP vers l'API REST, regroupées dans un module de service ; TanStack Query les met en cache et les recharge après chaque modification (chapitres 7 et 9). Montre-le dans l'onglet Réseau.
« Ton application est-elle sécurisée ? Un utilisateur peut-il voir les fiches d'un autre ? » La sécurité est dans l'API : elle vérifie le jeton et que la fiche appartient à l'utilisateur, à chaque requête. Le front masque les écrans et les boutons, mais ce n'est qu'un confort. Le jeton d'accès est gardé en mémoire, jamais dans localStorage : un script injecté ne le trouve dans aucun stockage, et il expire au bout de 15 minutes (chapitre 12).
« Que se passe-t-il si l'API est en panne ? » Le module d'appels distingue l'échec réseau de l'erreur HTTP ; les pages affichent un message (role="alert") et un bouton « Réessayer » ; TanStack Query réessaie seul les pannes, pas les erreurs 4xx.
« Comment as-tu testé ? » Des tests unitaires sur les fonctions de calcul, des tests de composants qui simulent l'API (formulaire, route protégée), lancés à chaque push par l'intégration continue (chapitre 13).
« Ton application est-elle accessible ? » Éléments natifs, étiquettes reliées, régions live pour les messages, titre et focus gérés à chaque changement de page, test au clavier (chapitre 14).
Les bonnes pratiques à vérifier
Une liste à passer sur ton projet avant l'épreuve :
Organisation
- Un composant par fichier, nommé comme le composant (
CarteFiche.tsx), en PascalCase. - Les appels à l'API regroupés dans
src/api/, jamais defetchdans un composant. - Les types des données dans
src/types.ts, sansany. - Les fonctions de calcul et de mise en forme hors des composants, testées.
État et effets
- Aucune valeur calculable stockée dans l'état.
- Aucun état modifié directement (
push,obj.x = …) : toujours une copie. - Des
keystables, issues des données, dans chaque liste. - Un
useEffectseulement pour se synchroniser avec l'extérieur, avec un nettoyage s'il démarre quelque chose. - Les données de l'API dans TanStack Query, pas copiées dans un
useState.
Interface et accessibilité
- Les quatre cas de chaque chargement traités : chargement, erreur, vide, données.
- Des
buttonpour les actions, desLinkpour la navigation. - Une étiquette par champ ; les erreurs annoncées.
- Un titre d'onglet par page.
Sécurité
- Aucun secret dans le front ni dans une variable
VITE_. - Pas de jeton dans
localStorage. - Pas de
dangerouslySetInnerHTMLavec une donnée saisie par un utilisateur. - Chaque contrôle d'accès du front doublé dans l'API.
Outillage
-
npm run lint,npx vitest runetnpm run buildpassent sans erreur. - Un README qui permet à la commission de lancer le projet.
Exercices corrigés
Exercice 1 sur 5 Évolution : le total de la fiche et le plafond Réussi À revoir
Expression des besoins. « Sur la page d'une fiche, le visiteur doit voir le total de ses frais hors forfait. Si ce total dépasse 500 €, un message l'avertit que la fiche sera examinée en détail par le comptable. »
- Faut-il modifier l'API ? Faut-il un nouvel état ?
- Écris le composant
TotalFiche({ frais }: { frais: Frais[] }). - Écris deux tests : l'un sans message (total 35,50 €), l'autre avec message (total 512,00 €).
Voir la réponseMasquer la réponse de l'exercice 1
1. Non dans les deux cas : la liste des frais est déjà renvoyée par GET /fiches/{id}, et le total se calcule à partir d'elle.
2.
import { formatMontant } from '../format';
import type { Frais } from '../types';
const SEUIL_EXAMEN = 500;
export function TotalFiche({ frais }: { frais: Frais[] }) {
const total = frais.reduce((somme, f) => somme + f.montant, 0);
return (
<>
<p>Total des frais hors forfait : {formatMontant(total)}</p>
{total > SEUIL_EXAMEN && (
<p role="status">Au-delà de {formatMontant(SEUIL_EXAMEN)}, ta fiche sera examinée en détail par le comptable.</p>
)}
</>
);
}
3.
import { render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { TotalFiche } from './TotalFiche';
const frais = (montants: number[]) =>
montants.map((montant, i) => ({ id: i + 1, date: '2026-10-14', libelle: `Frais ${i + 1}`, montant }));
it("n'avertit pas sous le seuil", () => {
render(<TotalFiche frais={frais([23.5, 12])} />);
expect(screen.getByText(/Total des frais hors forfait : 35,50/)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('avertit au-delà de 500 €', () => {
render(<TotalFiche frais={frais([480, 32])} />);
expect(screen.getByText(/Total des frais hors forfait : 512,00/)).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('examinée en détail');
});
Les expressions régulières (/…35,50/) évitent d'écrire l'espace insécable que Intl.NumberFormat place avant « € ». Le seuil est une constante nommée : si la règle change, une seule ligne bouge. Une règle de gestion qui a des conséquences (un remboursement bloqué, par exemple) devrait aussi être vérifiée par l'API ; ici, ce n'est qu'un avertissement.
Tu avais trouvé ?
Exercice 2 sur 5 Correction : la liste ne se met pas à jour Réussi À revoir
Signalement. « Quand je supprime un frais sur la page d'une fiche, il reste affiché. Il disparaît seulement si je recharge la page. » Voici le hook utilisé :
export function useSupprimerFrais() {
return useMutation({
mutationFn: supprimerFrais,
});
}
- L'onglet Réseau montre
DELETE /v1/frais-hors-forfait/58avec la réponse204. Le frais est-il supprimé en base ? - Explique le bogue et corrige-le.
- Pourquoi le frais disparaît-il au rechargement ?
Voir la réponseMasquer la réponse de l'exercice 2
1. Oui : 204 No Content est la réponse d'une suppression réussie. Le problème est côté front.
2. Après la mutation, le cache de TanStack Query contient toujours l'ancienne fiche : rien ne lui dit que ses données sont périmées. Il faut invalider les requêtes des fiches en cas de succès :
export function useSupprimerFrais() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: supprimerFrais,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fiches'] }),
});
}
L'invalidation recharge le détail de la fiche et les listes, car toutes leurs clés commencent par 'fiches'.
3. Le rechargement vide le cache (il est en mémoire) : la fiche est redemandée à l'API, qui renvoie la liste à jour.
Pour la non-régression, un test de ListeFrais peut simuler supprimerFrais, cliquer sur « Supprimer » et vérifier que invalidateQueries est appelé, ou, plus simplement, que la requête de la fiche est refaite.
Tu avais trouvé ?
Exercice 3 sur 5 Nouvel écran : les fiches à valider Réussi À revoir
Expression des besoins. « Le comptable doit disposer d'un écran listant les fiches clôturées de tous les visiteurs, avec pour chacune le montant à valider et un bouton Valider. Les visiteurs ne doivent pas y avoir accès. » L'API fournit GET /v1/fiches?etat=CL (pour un comptable, les fiches clôturées de tous les visiteurs ; pour un visiteur, seulement les siennes) et POST /v1/fiches/{id}/validations avec le corps { "montantValide": 567 } (201 et la fiche validée ; 403 pour un visiteur, 409 si la fiche n'est pas clôturée).
- Écris les deux fonctions du module d'appels.
- Écris les hooks
useFichesATraiteretuseValiderFiche. - Écris la page
PageValidation(chargement, erreur, liste vide, liste avec boutons). - Déclare la route et le lien de navigation.
- Que se passe-t-il si un visiteur tape
/validationdans la barre d'adresse ? Et s'il appelle avec curl et son jetonGET /v1/fiches?etat=CL, puisPOST /v1/fiches/13/validations?
Voir la réponseMasquer la réponse de l'exercice 3
1.
// src/api/validations.ts
import { api } from './client';
import { getFiches } from './fiches';
import type { FicheDetail } from '../types';
// Pour un comptable, l'API renvoie les fiches clôturées de tous les visiteurs.
export function getFichesATraiter(signal?: AbortSignal) {
return getFiches('CL', signal);
}
export function validerFiche({ id, montantValide }: { id: number; montantValide: number }) {
return api<FicheDetail>(`/fiches/${id}/validations`, { method: 'POST', corps: { montantValide } });
}
2.
// src/hooks/useValidations.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getFichesATraiter, validerFiche } from '../api/validations';
export function useFichesATraiter() {
return useQuery({
queryKey: ['fiches', 'a-traiter'],
queryFn: ({ signal }) => getFichesATraiter(signal),
});
}
export function useValiderFiche() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: validerFiche,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fiches'] }),
});
}
3.
// src/pages/PageValidation.tsx
import { useFichesATraiter, useValiderFiche } from '../hooks/useValidations';
import { useTitrePage } from '../hooks/useTitrePage';
import { formatMois } from '../format';
export function PageValidation() {
const { data: fiches, isPending, isError, error } = useFichesATraiter();
const validation = useValiderFiche();
useTitrePage('Fiches à valider');
if (isPending) return <p role="status">Chargement…</p>;
if (isError) return <p role="alert">{error.message}</p>;
return (
<>
<h1>Fiches à valider</h1>
{validation.isError && <p role="alert">{validation.error.message}</p>}
{fiches.length === 0 ? (
<p>Aucune fiche à valider.</p>
) : (
<ul>
{fiches.map((f) => (
<li key={f.id}>
<form action={(formData) => validation.mutate({ id: f.id, montantValide: Number(formData.get('montantValide')) })}>
Fiche n° {f.id}, {formatMois(f.mois)}{' '}
<label htmlFor={`montant-${f.id}`}>Montant validé (€)</label>{' '}
<input id={`montant-${f.id}`} name="montantValide" type="number" min="0" step="0.01" required />{' '}
<button type="submit" disabled={validation.isPending}>Valider la fiche n° {f.id}</button>
</form>
</li>
))}
</ul>
)}
</>
);
}
L'API exige le montant validé : chaque fiche a donc son petit formulaire, envoyé par une Action (chapitre 8) qui lit la saisie et lance la mutation. Après le succès, l'invalidation recharge la liste : la fiche validée en disparaît, puisqu'elle n'est plus à l'état CL.
4. Dans router.tsx, parmi les enfants de Layout :
{
element: <RouteProtegee role="comptable" />,
children: [{ path: 'validation', element: <PageValidation /> }],
},
Dans Layout.tsx : {utilisateur?.role === 'comptable' && <NavLink to="/validation">Fiches à valider</NavLink>}.
5. Dans la barre d'adresse, la route protégée affiche « Cette page est réservée au rôle « comptable » » : la page n'est pas affichée. Avec curl, c'est l'API qui protège, à partir du rôle inscrit dans le jeton signé : GET /v1/fiches?etat=CL répond 200, mais avec ses seules fiches clôturées, car le service ignore les fiches des autres visiteurs ; POST /v1/fiches/13/validations répond 403 Forbidden (exigerRole('comptable')). Si l'API oubliait ces contrôles, le visiteur lirait les fiches de ses collègues et validerait des fiches malgré la route protégée : c'est pour cela que la vérification côté API est obligatoire.
Tu avais trouvé ?
Exercice 4 sur 5 Analyse de code, comme à l'E7 Réussi À revoir
Un développeur propose ce composant pour afficher le commentaire du comptable, qui peut contenir du gras et des retours à la ligne. Un testeur enregistre un commentaire qui contient une balise d'image piégée : son attribut d'événement exécute un script qui tente d'envoyer à un site extérieur les cookies et le contenu de localStorage du lecteur.
function Commentaire({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
- Nomme la vulnérabilité et explique ce que fait ce commentaire quand un visiteur ouvre sa fiche.
- Le jeton d'AppliFrais est gardé en mémoire, pas dans
localStorageni dans un cookie. Qu'est-ce que cela change ? Le risque disparaît-il ? - Propose deux corrections, de la plus simple à la plus complète.
Voir la réponseMasquer la réponse de l'exercice 4
- Une XSS stockée : le commentaire est enregistré en base, puis inséré comme HTML dans la page de chaque personne qui l'affiche. Le navigateur interprète la balise piégée : le script de son attribut d'événement s'exécute dans la page du visiteur et tente d'envoyer ses données de connexion à un site extérieur (faille XSS).
- Le script ne trouve le jeton ni dans
localStorageni dansdocument.cookie: il ne peut pas l'emporter pour s'en servir ailleurs. Mais le risque ne disparaît pas : le script s'exécute dans la page, pendant que la victime est connectée, et peut appeler l'API en son nom, puisque le module d'appels ajoute le jeton à toutes les requêtes (supprimer ses frais, lire ses données) ; il peut aussi modifier l'affichage ou afficher un faux formulaire de connexion. Garder le jeton en mémoire limite les dégâts, ça ne corrige pas la faille. -
- La plus simple : ne pas interpréter de HTML.
<div style={{ whiteSpace: 'pre-line' }}>{texte}</div>affiche les retours à la ligne, et React encode tout le reste. - Si une mise en forme est vraiment nécessaire : nettoyer le HTML avec une bibliothèque éprouvée (DOMPurify) en ne gardant qu'une liste blanche de balises (
b,i,br), ou utiliser un format sans HTML (Markdown) converti par une bibliothèque qui nettoie sa sortie. En complément, une CSP stricte bloque les scripts injectés qui auraient échappé au nettoyage.
- La plus simple : ne pas interpréter de HTML.
Tu avais trouvé ?
Exercice 5 sur 5 Questions de cours rapides Réussi À revoir
Réponds en une ou deux phrases.
- Pourquoi ne faut-il pas appeler un hook dans un
if? - Que fait
e.preventDefault()dans unonSubmit, et pourquoi n'est-il pas nécessaire avec<form action={…}>? - À quoi sert la clé de requête de TanStack Query ?
- Quelle est la différence entre
<Link>et<a href>dans une SPA ? - Pourquoi une variable
VITE_ne peut-elle pas contenir un secret ? - Pourquoi
StrictModeexécute-t-il les effets deux fois en développement ?
Voir la réponseMasquer la réponse de l'exercice 5
- React retrouve l'état de chaque hook grâce à leur ordre d'appel ; un hook conditionnel change cet ordre d'un rendu à l'autre et mélange les états.
- Il empêche le navigateur d'envoyer le formulaire et de recharger la page. Avec une Action, React le fait lui-même avant d'appeler la fonction.
- Elle identifie la donnée dans le cache : même clé, même donnée partagée ; elle contient tout ce dont dépend la requête (le filtre, l'identifiant), et sert à invalider un groupe de données par son préfixe.
<Link>change d'écran sans recharger la page (l'état et le cache sont gardés) ;<a href>recharge toute l'application.- Sa valeur est écrite dans le JavaScript envoyé au navigateur : n'importe qui peut la lire.
- Pour révéler les effets sans nettoyage (minuteurs, abonnements, requêtes non annulées) : un effet correct donne le même résultat après « effet, nettoyage, effet ».
Tu avais trouvé ?
Annexe : l'API de démonstration
Les exemples du cours ont été testés contre cette API, qui reproduit le contrat de l'API du cours Node.js et Express décrit au chapitre 7 : mêmes routes, mêmes réponses, mêmes codes d'erreur, mêmes comptes de test. Elle tient en un fichier et garde ses données en mémoire : tout est perdu à l'arrêt. Elle sert à faire tourner le front si tu n'as pas encore terminé ton API ; le jour où celle-ci est prête, tu la lances à la place, sur le même port, sans rien changer au front.
mkdir api-demo && cd api-demo
npm init -y
npm pkg set type=module
npm install express
# copie le fichier ci-dessous dans api-demo.mjs, puis :
node api-demo.mjs
// api-demo.mjs : une API de démonstration d'AppliFrais, données en mémoire.
// Même contrat que l'API du cours Node.js. Lancement : node api-demo.mjs (Node.js 24, après npm install express)
import express from 'express';
import { randomBytes } from 'node:crypto';
const app = express();
app.use(express.json());
const DUREE_JETON = 15 * 60; // secondes, comme l'API du cours Node.js
const FORFAITS = { ETP: ['Forfait étape', 110], KM: ['Frais kilométriques', 0.62], NUI: ['Nuitée hôtel', 80], REP: ['Repas restaurant', 25] };
const TRANSITIONS = { CR: ['CL'], CL: ['VA'], VA: ['MP'], MP: [] };
const utilisateurs = [
{ id: 1, login: 'adurand', motDePasse: 'Comptable#2026', nom: 'Durand', prenom: 'Anne', role: 'comptable' },
{ id: 7, login: 'lmartin', motDePasse: 'Visiteur#2026', nom: 'Martin', prenom: 'Lucas', role: 'visiteur' },
{ id: 8, login: 'svincent', motDePasse: 'Visiteur#2026', nom: 'Vincent', prenom: 'Sarah', role: 'visiteur' },
];
const fiches = [
{ id: 11, visiteurId: 7, mois: '2026-09', etat: 'VA', montantValide: 389.2, dateModification: '2026-10-05 09:12:44', forfait: { ETP: 1, KM: 160, NUI: 1, REP: 3 } },
{ id: 12, visiteurId: 7, mois: '2026-10', etat: 'CR', montantValide: null, dateModification: '2026-11-02 14:10:38', forfait: { ETP: 3, KM: 420, NUI: 2, REP: 5 } },
{ id: 13, visiteurId: 8, mois: '2026-10', etat: 'CL', montantValide: null, dateModification: '2026-11-01 18:03:12', forfait: { ETP: 2, KM: 250, NUI: 1, REP: 4 } },
];
const frais = [
{ id: 57, ficheId: 11, date: '2026-09-17', libelle: 'Taxi gare – hôtel', montant: 25 },
{ id: 58, ficheId: 12, date: '2026-10-14', libelle: 'Repas client à Lyon', montant: 23.5 },
{ id: 59, ficheId: 13, date: '2026-10-09', libelle: 'Parking gare de Grenoble', montant: 12 },
];
const jetons = new Map(); // jeton -> { id de l'utilisateur, expiration en millisecondes }
let prochainId = 60;
// Les erreurs au format de la RFC 9457, comme celles de l'API du cours Node.js.
function probleme(req, res, status, code, title, detail, extra = {}) {
res.status(status).type('application/problem+json')
.json({ type: `https://api.gsb.example/problemes/${code}`, title, status, detail, instance: req.originalUrl, ...extra });
}
const invalide = (req, res, erreurs) =>
probleme(req, res, 422, 'validation', 'Données invalides', 'Un ou plusieurs champs sont invalides.', { erreurs });
const refuse = (req, res) => probleme(req, res, 403, 'acces-refuse', 'Accès refusé', 'Ton rôle ne permet pas cette opération.');
const maintenant = () => new Date().toISOString().slice(0, 19).replace('T', ' ');
const centimes = (montant) => Math.round(montant * 100);
app.post('/v1/auth/connexion', (req, res) => {
const { login, motDePasse } = req.body ?? {};
if (typeof login !== 'string' || typeof motDePasse !== 'string') {
return invalide(req, res, [{ champ: 'body', message: 'login et motDePasse sont obligatoires' }]);
}
const u = utilisateurs.find((x) => x.login === login.trim() && x.motDePasse === motDePasse);
if (!u) return probleme(req, res, 401, 'identifiants-invalides', 'Identifiants invalides', 'Login ou mot de passe incorrect.');
const accessToken = randomBytes(32).toString('base64url'); // jeton opaque : le front ne voit pas la différence avec un JWT
jetons.set(accessToken, { id: u.id, expire: Date.now() + DUREE_JETON * 1000 });
res.set('Cache-Control', 'no-store').json({ accessToken, tokenType: 'Bearer', expiresIn: DUREE_JETON });
});
// Tout ce qui suit exige l'en-tête « Authorization: Bearer <jeton> ».
app.use('/v1', (req, res, next) => {
const [schema, jeton] = req.get('Authorization')?.split(' ') ?? [];
if (schema !== 'Bearer' || !jeton) {
res.set('WWW-Authenticate', 'Bearer realm="api"');
return probleme(req, res, 401, 'non-authentifie', 'Authentification requise', 'Jeton absent.');
}
const session = jetons.get(jeton);
if (!session || session.expire < Date.now()) {
jetons.delete(jeton);
res.set('WWW-Authenticate', 'Bearer realm="api", error="invalid_token"');
return probleme(req, res, 401, 'jeton-invalide', 'Jeton invalide', 'Le jeton est invalide ou a expiré.');
}
req.utilisateur = utilisateurs.find((u) => u.id === session.id);
next();
});
app.get('/v1/auth/moi', (req, res) => {
const { id, login, nom, prenom, role } = req.utilisateur;
res.json({ id, login, nom, prenom, role });
});
const resume = ({ id, visiteurId, mois, etat, montantValide, dateModification }) =>
({ id, visiteurId, mois, etat, montantValide, dateModification });
function detail(f) {
const v = utilisateurs.find((u) => u.id === f.visiteurId);
const lignesForfait = Object.entries(f.forfait).map(([code, quantite]) =>
({ code, libelle: FORFAITS[code][0], quantite, montantUnitaire: FORFAITS[code][1] }));
const fraisHorsForfait = frais.filter((x) => x.ficheId === f.id)
.map((x) => ({ id: x.id, date: x.date, libelle: x.libelle, montant: x.montant, justificatif: false }));
const total = lignesForfait.reduce((s, l) => s + l.quantite * centimes(l.montantUnitaire), 0)
+ fraisHorsForfait.reduce((s, x) => s + centimes(x.montant), 0);
const { visiteurId, forfait, ...fiche } = f;
return { ...fiche, visiteur: { id: v.id, nom: v.nom, prenom: v.prenom }, lignesForfait, fraisHorsForfait, total: total / 100 };
}
// Un visiteur ne voit que ses fiches : celle d'un autre répond 404, comme si elle n'existait pas.
function ficheOu404(req, res) {
const f = fiches.find((x) => x.id === Number(req.params.id));
if (f && (req.utilisateur.role === 'comptable' || f.visiteurId === req.utilisateur.id)) return f;
probleme(req, res, 404, 'fiche-introuvable', 'Fiche introuvable', `La fiche ${req.params.id} n'existe pas.`);
return null;
}
// Les frais d'une fiche ne se modifient que tant qu'elle est en cours.
function enCours(req, res, f) {
if (f.etat === 'CR') return true;
probleme(req, res, 409, 'fiche-cloturee', 'Fiche non modifiable',
`La fiche est à l'état ${f.etat} : seuls les frais d'une fiche en cours se modifient.`, { etatActuel: f.etat });
return false;
}
app.get('/v1/fiches', (req, res) => {
const { etat, visiteur } = req.query;
const page = Number(req.query.page ?? 1);
const limite = Number(req.query.limite ?? 20);
const erreurs = [];
if (etat !== undefined && !(etat in TRANSITIONS)) erreurs.push({ champ: 'etat', message: 'CR, CL, VA ou MP attendu' });
if (!Number.isInteger(page) || page < 1) erreurs.push({ champ: 'page', message: 'Entier positif attendu' });
if (!Number.isInteger(limite) || limite < 1 || limite > 100) erreurs.push({ champ: 'limite', message: 'Entier de 1 à 100 attendu' });
if (erreurs.length > 0) return invalide(req, res, erreurs);
// Un visiteur ne voit que ses fiches, quel que soit le filtre envoyé.
const visiteurId = req.utilisateur.role === 'visiteur' ? req.utilisateur.id : Number(visiteur) || null;
const liste = fiches
.filter((f) => (!visiteurId || f.visiteurId === visiteurId) && (!etat || f.etat === etat))
.toSorted((a, b) => b.mois.localeCompare(a.mois) || b.id - a.id);
res.json({
donnees: liste.slice((page - 1) * limite, page * limite).map(resume),
pagination: { page, limite, total: liste.length, pages: Math.ceil(liste.length / limite) },
});
});
app.get('/v1/fiches/:id', (req, res) => {
const f = ficheOu404(req, res);
if (f) res.json(detail(f));
});
// Clôture (CL) par le visiteur propriétaire, mise en paiement (MP) par un comptable.
app.patch('/v1/fiches/:id', (req, res) => {
const etat = req.body?.etat;
if (etat !== 'CL' && etat !== 'MP') return invalide(req, res, [{ champ: 'etat', message: 'CL ou MP attendu' }]);
const f = ficheOu404(req, res);
if (!f) return;
const roleRequis = etat === 'CL' ? 'visiteur' : 'comptable';
if (req.utilisateur.role !== roleRequis) {
return probleme(req, res, 403, 'acces-refuse', 'Accès refusé', `Seul un ${roleRequis} peut passer une fiche à l'état ${etat}.`);
}
if (!TRANSITIONS[f.etat].includes(etat)) {
return probleme(req, res, 409, 'transition-interdite', "Changement d'état interdit",
`Une fiche à l'état ${f.etat} ne peut pas passer à l'état ${etat}.`, { etatActuel: f.etat });
}
Object.assign(f, { etat, dateModification: maintenant() });
res.json(detail(f));
});
app.post('/v1/fiches/:id/validations', (req, res) => {
if (req.utilisateur.role !== 'comptable') return refuse(req, res);
const montant = req.body?.montantValide;
if (typeof montant !== 'number' || montant < 0 || Math.abs(montant * 100 - centimes(montant)) > 1e-6) {
return invalide(req, res, [{ champ: 'montantValide', message: 'Montant positif, au centime près' }]);
}
const f = ficheOu404(req, res);
if (!f) return;
if (f.etat !== 'CL') {
return probleme(req, res, 409, 'transition-interdite', "Changement d'état interdit", "La fiche n'est pas clôturée.", { etatActuel: f.etat });
}
Object.assign(f, { etat: 'VA', montantValide: montant, dateModification: maintenant() });
res.status(201).json(detail(f));
});
app.post('/v1/fiches/:id/frais-hors-forfait', (req, res) => {
if (req.utilisateur.role !== 'visiteur') return refuse(req, res);
const { date, libelle, montant, ...autres } = req.body ?? {};
const erreurs = Object.keys(autres).map((cle) => ({ champ: 'body', message: `Clé non reconnue : "${cle}"` }));
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) erreurs.push({ champ: 'date', message: 'Date AAAA-MM-JJ attendue' });
if (typeof libelle !== 'string' || libelle.trim().length < 3 || libelle.trim().length > 100) erreurs.push({ champ: 'libelle', message: 'De 3 à 100 caractères' });
if (typeof montant !== 'number' || !(montant > 0) || montant > 5000) erreurs.push({ champ: 'montant', message: 'Montant de 0,01 à 5 000 € attendu' });
if (erreurs.length > 0) return invalide(req, res, erreurs);
const f = ficheOu404(req, res);
if (!f || !enCours(req, res, f)) return;
const nouveau = { id: prochainId++, ficheId: f.id, date, libelle: libelle.trim(), montant };
frais.push(nouveau);
f.dateModification = maintenant();
res.status(201).location(`/v1/frais-hors-forfait/${nouveau.id}`)
.json({ id: nouveau.id, date, libelle: nouveau.libelle, montant, justificatif: false });
});
app.delete('/v1/frais-hors-forfait/:id', (req, res) => {
if (req.utilisateur.role !== 'visiteur') return refuse(req, res);
const x = frais.find((y) => y.id === Number(req.params.id));
const f = x && fiches.find((y) => y.id === x.ficheId && y.visiteurId === req.utilisateur.id);
if (!f) return probleme(req, res, 404, 'frais-introuvable', 'Frais introuvable', `Le frais ${req.params.id} n'existe pas.`);
if (!enCours(req, res, f)) return;
frais.splice(frais.indexOf(x), 1);
f.dateModification = maintenant();
res.sendStatus(204);
});
app.use((req, res) => probleme(req, res, 404, 'route-inconnue', 'Route inconnue', `${req.method} ${req.path} n'existe pas.`));
app.use((err, req, res, next) => // JSON illisible (400) ou erreur imprévue (500)
probleme(req, res, err.status ?? 500, 'erreur', 'Erreur', err.expose ? err.message : 'Erreur interne.'));
const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`API de démonstration sur http://localhost:${port}/v1`));
Trois comptes, ceux du jeu de données de test de l'API du cours Node.js : lmartin / Visiteur#2026 (Lucas, visiteur, deux fiches dont celle d'octobre, en cours), svincent / Visiteur#2026 (Sarah, visiteuse, sa fiche d'octobre est clôturée) et adurand / Comptable#2026 (Anne, comptable, qui voit toutes les fiches).
Et ensuite
Tu sais construire une application monopage complète : composants, état, formulaires, données d'une API, routes, authentification, tests, accessibilité et déploiement. Les mêmes composants, props, états et hooks servent à écrire des applications mobiles avec React Native, et des sites rendus côté serveur avec des frameworks comme Next.js ou React Router en mode framework. Côté serveur, le cours Node.js et Express construit l'API qu'appelle ce front, et le cours Architecture MVC situe le front et l'API dans l'architecture d'ensemble d'une application.